client: 修裁判端两格不等宽,加滚轮缩放与拖动平移,启动即最大化

**塌缩问题**(用户报的现象):裁判端窗口放大时,左边那格吃掉约 55% 宽度,右边
只有 45%;空的那一侧更极端时会被压成一条缝。

原因不是缺 stretch,而是 `HorizontalLayout` 先按内容的**最大**需求分配、剩下
的才按 stretch 分。左格写着"已连接,等待画面…"(8 个字),右格"未收到画面"
(5 个字),左格就多要十几个像素。而没有画面的一格最小宽度接近 0,可以被压没。

三处一起改:

  1. 占位文字改成绝对定位(给出 x/y),不再参与父级尺寸计算。文字长短本不该
     决定画面大小。
  2. 两格统一 `preferred-width: 480px` + `horizontal-stretch: 1`,需求变成相同,
     于是精确平分。`min-width: 320px` 兜底,保证任何一侧至少有一个能看画的宽度。
  3. 缩放指示与复位按钮也绝对定位。否则它们一出现就改变这一格的尺寸需求,把
     不等宽重新引进来——而且是在用户放大之后才出现,最难排查。

**缩放**:Slint 的 `Image` 没有缩放因子属性,所以把它的 `width`/`height` 按 zoom
放大后放进 `clip: true` 的容器,靠 offset 平移。滚轮改 zoom、拖动改 offset、双击
复位、缩回 1.0 时自动归位(否则下一次放大会从偏移位置开始,看起来像"跳了一下")。

`image-fit` 要跟着 zoom 切:`contain` 在 zoom=1 时保证完整可见且不变形,`fill`
才让画面跟着放大——只用 contain 的话放大不会有任何视觉变化。

**启动即最大化**:Slint 没有"最大化"这个状态,但有 `set_maximized`,它铺满工作区
且保留菜单栏和 Dock(真全屏 `set_fullscreen` 会盖掉它们,裁判终端要切去别的窗口
核对数据)。必须在 `show()` 之前调用,之后调用会被后端当成"用户点了最大化按钮"。

抽出 `VideoPane` 与 `StatusBar` 两个组件给两种身份复用。两者都不加 `export`——
它们只在 .slint 内部使用,加了会触发"doesn't inherit Window. No code will be
generated for it"的警告。

Slint 的两处写法记在这里,都是编译器试出来的:
  - `TouchArea` 的 `pressed` 是**属性**不是回调,写 `pressed => {}` 编译不过。
    拖动要用 `pointer-event(e)` 配 `PointerEventKind.down` / `.move`。
  - `EventResult.accept` 要写全限定;`accept` 单独写无法解析。
This commit is contained in:
Horldsence
2026-10-05 21:28:32 +08:00
parent a16ad8595e
commit cd8344c698
+324 -191
View File
@@ -1,6 +1,10 @@
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
/* 一块画面的读数。裁判端有两块,队伍端一块。 */
/* 一块画面的读数。
*
* 技术字段(fps / 丢帧 / 数据报数等)在队伍端 release 下**不产出**:Rust 侧
* `render_camera_stats(..., TEAM_DIAGNOSTICS)` 传 false 时这些字段是空字符串,
* 界面也不显示它们。裁判端始终显示。 */
export struct CameraStats {
// 在线状态
online: bool,
@@ -43,9 +47,260 @@ export struct CameraChoice {
online: bool,
}
// ---------------------------------------------------------------- 队伍端
/* ------------------------------------------------------------------ 视频窗格
*
* 一路画面 + 滚轮缩放 + 拖动平移。两种身份共用这个组件。
*
* 缩放的实现方式:把 `Image` 的 `width`/`height` 按 `zoom` 放大后放进一个
* `clip: true` 的容器里,靠 `offset-x`/`offset-y` 平移。Slint 的 `Image` 没有
* 缩放因子属性,所以只能用这个办法——它也顺带让"放大到超出窗口"成为可能。
*
* `image-fit: contain` 让缩放到 1.0 时画面完整可见且不变形;放大后
* `image-fit: fill` 才会跟着缩放,否则画面尺寸固定在 contain 算出的尺寸上,
* 放大看起来毫无反应。
*/
component VideoPane inherits Rectangle {
// 画面
in property <image> frame;
// 没有画面时显示的提示(例如槽位空着)
in property <string> empty-text;
/*
background: #000000;
// 关键:不 clip 的话放大后的画面会溢出到界面其它地方去。
clip: true;
// 缩放与平移。zoom = 1.0 是"完整可见"。
in-out property <float> zoom: 1.0;
in-out property <length> offset-x: 0px;
in-out property <length> offset-y: 0px;
// 放大到几倍为止。再多也看不清了,只是把像素拉大。
in property <float> max-zoom: 8.0;
in property <float> step: 1.25;
// 拖动时的起点,由 TouchArea 记录。
private property <length> drag-start-x: 0px;
private property <length> drag-start-y: 0px;
private property <length> drag-origin-x: 0px;
private property <length> drag-origin-y: 0px;
public function reset-view() {
root.zoom = 1.0;
root.offset-x = 0px;
root.offset-y = 0px;
}
Image {
source: root.frame;
// contain:zoom=1 时完整可见且不变形。
// fill:放大后跟着容器缩放——只用 contain 的话放大不会有任何视觉变化。
image-fit: root.zoom <= 1.0 ? contain : fill;
width: parent.width * root.zoom;
height: parent.height * root.zoom;
x: (parent.width - self.width) / 2 + root.offset-x;
y: (parent.height - self.height) / 2 + root.offset-y;
horizontal-alignment: center;
vertical-alignment: center;
}
// 没有画面时的占位。裁判端两块板子通常只到一块,空槽位要能说清原因,
// 而不是留一块黑。
//
// `absolute` 布局:它不参与父级的尺寸需求计算。之前它不是,于是"已连接,
// 等待画面…"这行字的长度决定了这一格要多少宽度,裁判端两格就永远不等宽
// (实测 55% / 45%)——文字长短本不该影响画面大小。
if root.empty-text != "": Text {
text: root.empty-text;
color: #5a5a64;
font-size: 14px;
horizontal-alignment: center;
vertical-alignment: center;
// 占满整个窗格,文字居中,但不参与布局。
x: 0;
y: parent.height / 2 - self.height / 2;
width: parent.width;
}
// 缩放指示:只在放大时出现,否则一直占着位置。
//
// 同样是绝对定位(给出 x/y),否则它一出现就会改变这一格的尺寸需求,把两路
// 不等宽的问题重新引进来——而且是在用户放大之后才出现,最难排查。
if root.zoom > 1.05: Rectangle {
background: #000000aa;
border-radius: 4px;
width: 92px;
height: 26px;
x: parent.width - self.width - 10px;
y: 10px;
Text {
text: "x" + root.zoom;
color: #d0d0d8;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// reset-view 按钮:只在放大/平移后出现,给一个回到全景的出口。
if root.zoom > 1.05 || root.offset-x != 0px || root.offset-y != 0px: Rectangle {
background: #000000aa;
border-radius: 4px;
width: 76px;
height: 26px;
x: 10px;
y: 10px;
TouchArea {
clicked => {
root.reset-view();
}
}
Text {
text: "复位";
color: #d0d0d8;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// 滚轮与拖动都在最上层。
TouchArea {
scroll-event(event) => {
// delta-y 为正表示向上滚(放大)。
if event.delta-y > 0px {
root.zoom = Math.min(root.zoom * root.step, root.max-zoom);
} else if event.delta-y < 0px {
root.zoom = Math.max(root.zoom / root.step, 1.0);
}
// 缩回 1.0 时顺便归位:此时画面完整可见,平移没有意义,
// 留着会让下一次放大从偏移位置开始,看起来像"跳了一下"。
if root.zoom <= 1.0 {
root.offset-x = 0px;
root.offset-y = 0px;
}
return EventResult.accept;
}
// 拖动平移。
//
// 用 `pointer-event` 而不是 `pressed` / `moved`:TouchArea 的 `pressed` 是
// **属性**(可以读 `self.pressed`)而不是回调,写成 `pressed => {...}` 编译
// 不过。按下(Down)时记起点和当前偏移,移动(Move)时按增量更新。
pointer-event(event) => {
if event.kind == PointerEventKind.down {
root.drag-start-x = self.mouse-x;
root.drag-start-y = self.mouse-y;
root.drag-origin-x = root.offset-x;
root.drag-origin-y = root.offset-y;
} else if event.kind == PointerEventKind.move && self.pressed {
// 只在放大后生效——zoom=1 时画面完整可见,没有可移动的空间。
if root.zoom > 1.0 {
root.offset-x = root.drag-origin-x
+ (self.mouse-x - root.drag-start-x);
root.offset-y = root.drag-origin-y
+ (self.mouse-y - root.drag-start-y);
}
}
return EventResult.accept;
}
double-clicked => {
root.reset-view();
}
// 放大后用抓取光标提示可拖动。
mouse-cursor: root.zoom > 1.0 ? move : default;
}
}
/* 一路画面的下方信息条。两种身份共用。
*
* 技术读数(fps / shown / dropped)在队伍端 release 下为空字符串,`visible`
* 让整块消失而不是留一条空行。 */
component StatusBar inherits Rectangle {
in property <CameraStats> stats;
in property <TeamHealth> health;
// 血格子比状态栏里的字宽,裁判端并排时用
in property <length> cell-width: 34px;
in property <length> cell-height: 12px;
// 队伍端那一条更高一些,因为它独占一行血量
in property <bool> wide: false;
background: #1a1a20;
height: wide ? 72px : 78px;
HorizontalLayout {
padding: 12px;
spacing: 16px;
// 左:队伍名 + 血量格子
VerticalLayout {
padding: 0;
spacing: 5px;
horizontal-stretch: 1;
Text {
text: root.health.team-name;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
}
HorizontalLayout {
padding: 0;
spacing: 5px;
for hp[index] in root.health.hps: Rectangle {
width: root.cell-width;
height: root.cell-height;
border-width: 1px;
border-color: root.health.available ? #4a4a55 : #2f2f38;
background: root.health.available ? #6cc96c : #17171c;
}
}
}
// 右:状态 + 技术读数
//
// VerticalLayout 没有 horizontal-alignment 属性(右对齐要落在里面的 Text
// 上)。给它 preferred-width 而不是 stretch,让它按内容宽度占位。
VerticalLayout {
padding: 0;
spacing: 3px;
preferred-width: root.wide ? 340px : 300px;
Text {
text: root.stats.status-text
+ (root.stats.fps != "" ? " " + root.stats.fps + " fps" : "")
+ (root.stats.resolution != "" ? " " + root.stats.resolution : "");
color: root.stats.online ? #e8e8ec : #e0a060;
font-size: 13px;
horizontal-alignment: right;
}
// 技术读数整行在 release 下消失,不留空行。
if root.stats.frames-shown != "": Text {
text: "shown " + root.stats.frames-shown
+ (root.stats.frames-dropped != ""
? " dropped " + root.stats.frames-dropped
+ " (" + root.stats.loss-percent + ")"
: "")
+ (root.stats.gap-ms != "" ? " gap " + root.stats.gap-ms : "")
+ (root.stats.frame-bytes != "" ? " " + root.stats.frame-bytes : "");
color: root.stats.frames-dropped == "0" ? #7e7e89 : #e0a060;
font-size: 12px;
horizontal-alignment: right;
}
}
}
}
/* ---------------------------------------------------------------- 队伍端
*
* 队伍终端:一块画面 + 自己那一边的血量。
*
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
@@ -53,8 +308,13 @@ export struct CameraChoice {
* 情况。 */
export component TeamApp inherits Window {
title: "cam-client — 队伍";
preferred-width: 1000px;
preferred-height: 720px;
// 初始尺寸只作为"用户什么都不做时"的兜底;Rust 侧在 show() 之前按屏幕工作区
// 覆盖它,所以这里的数字只是无屏幕信息时的退路。
preferred-width: 1280px;
preferred-height: 800px;
// min-* 保证拖到很小时仍然可用:两路并排时没有下限,空的那格会被压没。
min-width: 900px;
min-height: 600px;
in property <image> frame;
in property <CameraStats> stats;
@@ -64,101 +324,33 @@ export component TeamApp inherits Window {
in property <[CameraChoice]> choices;
in property <bool> choosing;
// 选中了列表里的第 N 项。
//
// 绑定在这里一次完成:Rust 侧收到回调就写配置文件,所以"选定"当场生效,而不是
// "下次启动才生效"。索引而不是 id,是为了让 Rust 侧回到自己那份列表里取完整
// 信息——.slint 里的模型只是给界面看的。
callback pick(int);
background: #101014;
// 绑定完成前只显示选择界面:没有画面可显示,显示一个空窗口更让人困惑。
if !root.choosing: VerticalLayout {
spacing: 0;
HorizontalLayout {
padding: 0;
spacing: 0;
Rectangle {
background: #0a0a0d;
// 画面占满其余全部空间。0 的 padding/spacing 是"贴边",让画面尽可能大——
// 视频终端里每一像素都可能是有用信息。
VideoPane {
frame: root.frame;
empty-text: root.stats.online ? "已连接,等待画面…" : "离线";
vertical-stretch: 1;
frame := Image {
source: root.frame;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 底部一条:左边自己这边的血量,右边这一路的统计。
Rectangle {
background: #1c1c22;
height: 72px;
HorizontalLayout {
padding: 14px;
spacing: 24px;
VerticalLayout {
padding: 0;
spacing: 2px;
horizontal-stretch: 1;
Text {
text: root.health.team-name;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
}
// 装甲板还没接上,所以是一排占位而不是四个数字。
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health.hps: Rectangle {
width: 34px;
height: 12px;
border-width: 1px;
border-color: root.health.available ? #4a4a55 : #33333c;
background: root.health.available ? #7ddc7d : #1a1a20;
}
}
}
VerticalLayout {
padding: 0;
spacing: 2px;
preferred-width: 320px;
Text {
text: root.stats.status-text + " " + root.stats.fps + " fps "
+ root.stats.resolution + " " + root.stats.frame-bytes;
color: root.stats.online ? #e8e8ec : #e0a060;
font-size: 13px;
}
Text {
text: "shown " + root.stats.frames-shown
+ " dropped " + root.stats.frames-dropped
+ " (" + root.stats.loss-percent + ")"
+ " gap " + root.stats.gap-ms;
color: root.stats.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
StatusBar {
stats: root.stats;
health: root.health;
wide: true;
}
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
// release 下这行也保留——它不泄露任何对手信息。
Rectangle {
background: #14141a;
height: 26px;
height: 24px;
Text {
text: root.health.placeholder;
@@ -173,7 +365,7 @@ export component TeamApp inherits Window {
// 选择界面
if root.choosing: VerticalLayout {
padding: 32px;
spacing: 16px;
spacing: 14px;
Text {
text: "选择要观看的摄像头";
@@ -189,7 +381,7 @@ export component TeamApp inherits Window {
wrap: word-wrap;
}
Rectangle { height: 8px; background: transparent; }
Rectangle { height: 6px; background: transparent; }
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
// 点击回调;TouchArea 才是带交互的元素。
@@ -252,16 +444,18 @@ export component TeamApp inherits Window {
}
}
// ---------------------------------------------------------------- 裁判端
/* 裁判终端:两块画面左右并排 + 双方血量。
/* ---------------------------------------------------------------- 裁判端
*
* 裁判终端:两块画面左右并排 + 双方血量。
*
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
* 并排对称。 */
export component RefereeApp inherits Window {
title: "cam-client — 裁判系统";
preferred-width: 1400px;
preferred-height: 800px;
preferred-width: 1600px;
preferred-height: 900px;
min-width: 1000px;
min-height: 640px;
in property <image> frame-a;
in property <image> frame-b;
@@ -270,139 +464,78 @@ export component RefereeApp inherits Window {
in property <TeamHealth> health-a;
in property <TeamHealth> health-b;
// 两路的缩放互相独立:滚轮只作用于鼠标所在的一路(见各自窗格上的 TouchArea)。
// 裁判要并排比出两边,所以默认都是 1.0,且不提供联动。
background: #101014;
VerticalLayout {
spacing: 0;
// 两路画面等宽并排,各占一半。
// 两路画面等宽并排。
//
// 之前只写 `horizontal-stretch: 1`,于是**内容多的一格会占更多宽度**:
// HorizontalLayout 先按内容的最大需求分配,剩下的才按 stretch 分。队伍一
// 那格写着"已连接,等待画面…",队伍二只有"未收到画面",于是左边多要了
// 十几个像素,右边就少十几个——实测 55% / 45%。
//
// 空的那一侧被压成一条缝是更极端的同一类问题:没有图 ⇒ 最小宽度接近 0。
//
// 解法是让两格的需求**只由画面决定**,与文字无关:
// preferred-width: 50% 声明"我想要一半",配合 stretch 1 精确平分
// min-width: 320px 兜底,保证任何一侧至少有一个能看画的宽度
// 文字的溢出由 VideoPane 的 clip 裁掉,不参与布局。
HorizontalLayout {
padding: 0;
spacing: 2px;
Rectangle {
background: #0a0a0d;
VideoPane {
frame: root.frame-a;
empty-text: root.stats-a.online ? "已连接,等待画面…" : "未收到画面";
vertical-stretch: 1;
horizontal-stretch: 1;
frame-a := Image {
source: root.frame-a;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
preferred-width: 480px;
min-width: 320px;
}
Rectangle {
background: #0a0a0d;
VideoPane {
frame: root.frame-b;
empty-text: root.stats-b.online ? "已连接,等待画面…" : "未收到画面";
vertical-stretch: 1;
horizontal-stretch: 1;
frame-b := Image {
source: root.frame-b;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
preferred-width: 480px;
min-width: 320px;
}
}
// 每路画面下方的队伍条:名字、血量、统计。
//
// 用与画面列完全相同的宽度策略,所以拖动窗口时文字块不会跟着画面块错位。
HorizontalLayout {
padding: 0;
spacing: 2px;
Rectangle {
background: #1c1c22;
height: 78px;
StatusBar {
stats: root.stats-a;
health: root.health-a;
horizontal-stretch: 1;
VerticalLayout {
padding: 10px;
spacing: 4px;
Text {
text: root.health-a.team-name + " " + root.stats-a.status-text
+ " " + root.stats-a.fps + " fps " + root.stats-a.resolution;
color: root.stats-a.online ? #e8e8ec : #e0a060;
font-size: 14px;
font-weight: 600;
preferred-width: 480px;
min-width: 320px;
}
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health-a.hps: Rectangle {
width: 40px;
height: 14px;
border-width: 1px;
border-color: root.health-a.available ? #4a4a55 : #33333c;
background: root.health-a.available ? #7ddc7d : #1a1a20;
}
}
Text {
text: "shown " + root.stats-a.frames-shown
+ " dropped " + root.stats-a.frames-dropped
+ " (" + root.stats-a.loss-percent + ")"
+ " gap " + root.stats-a.gap-ms;
color: root.stats-a.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
Rectangle {
background: #1c1c22;
height: 78px;
StatusBar {
stats: root.stats-b;
health: root.health-b;
horizontal-stretch: 1;
VerticalLayout {
padding: 10px;
spacing: 4px;
Text {
text: root.health-b.team-name + " " + root.stats-b.status-text
+ " " + root.stats-b.fps + " fps " + root.stats-b.resolution;
color: root.stats-b.online ? #e8e8ec : #e0a060;
font-size: 14px;
font-weight: 600;
}
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health-b.hps: Rectangle {
width: 40px;
height: 14px;
border-width: 1px;
border-color: root.health-b.available ? #4a4a55 : #33333c;
background: root.health-b.available ? #7ddc7d : #1a1a20;
}
}
Text {
text: "shown " + root.stats-b.frames-shown
+ " dropped " + root.stats-b.frames-dropped
+ " (" + root.stats-b.loss-percent + ")"
+ " gap " + root.stats-b.gap-ms;
color: root.stats-b.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
preferred-width: 480px;
min-width: 320px;
}
}
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
Rectangle {
background: #14141a;
height: 26px;
height: 24px;
Text {
text: root.health-a.placeholder;