diff --git a/client/ui/camera.slint b/client/ui/camera.slint index 96e606b..36adef7 100644 --- a/client/ui/camera.slint +++ b/client/ui/camera.slint @@ -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 frame; + // 没有画面时显示的提示(例如槽位空着) + in property empty-text; -/* + background: #000000; + // 关键:不 clip 的话放大后的画面会溢出到界面其它地方去。 + clip: true; + + // 缩放与平移。zoom = 1.0 是"完整可见"。 + in-out property zoom: 1.0; + in-out property offset-x: 0px; + in-out property offset-y: 0px; + + // 放大到几倍为止。再多也看不清了,只是把像素拉大。 + in property max-zoom: 8.0; + in property step: 1.25; + + // 拖动时的起点,由 TouchArea 记录。 + private property drag-start-x: 0px; + private property drag-start-y: 0px; + private property drag-origin-x: 0px; + private property 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 stats; + in property health; + // 血格子比状态栏里的字宽,裁判端并排时用 + in property cell-width: 34px; + in property cell-height: 12px; + // 队伍端那一条更高一些,因为它独占一行血量 + in property 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 frame; in property stats; @@ -64,101 +324,33 @@ export component TeamApp inherits Window { in property <[CameraChoice]> choices; in property 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; - vertical-stretch: 1; - - frame := Image { - source: root.frame; - image-fit: contain; - width: parent.width; - height: parent.height; - horizontal-alignment: center; - vertical-alignment: center; - } - } + // 画面占满其余全部空间。0 的 padding/spacing 是"贴边",让画面尽可能大—— + // 视频终端里每一像素都可能是有用信息。 + VideoPane { + frame: root.frame; + empty-text: root.stats.online ? "已连接,等待画面…" : "离线"; + vertical-stretch: 1; + horizontal-stretch: 1; } - // 底部一条:左边自己这边的血量,右边这一路的统计。 - 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 frame-a; in property frame-b; @@ -270,139 +464,78 @@ export component RefereeApp inherits Window { in property health-a; in property 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; - } - - 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; - } - } + preferred-width: 480px; + min-width: 320px; } - 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; @@ -413,4 +546,4 @@ export component RefereeApp inherits Window { } } } -} \ No newline at end of file +}