Files
ESP32S3_vt/client/ui/camera.slint
T
Horldsence cd8344c698 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` 单独写无法解析。
2026-10-05 21:28:32 +08:00

550 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
/* 一块画面的读数。
*
* 技术字段(fps / 丢帧 / 数据报数等)在队伍端 release 下**不产出**:Rust 侧
* `render_camera_stats(..., TEAM_DIAGNOSTICS)` 传 false 时这些字段是空字符串,
* 界面也不显示它们。裁判端始终显示。 */
export struct CameraStats {
// 在线状态
online: bool,
status-text: string,
// 实时测量
fps: string,
resolution: string,
frame-bytes: string,
// 累计
frames-shown: string,
frames-dropped: string,
loss-percent: string,
datagrams: string,
// 帧间隔,用于把"卡"和"帧率低"区分开
gap-ms: string,
}
/* 一边的血量(4 块装甲板)。
*
* `available` 为 false 时界面显示占位符而不是 0:0 是一个看起来像真值的假值,
* 现场看到 0 会以为对方已经没血了。 */
export struct TeamHealth {
team-name: string,
available: bool,
// 占位符文案
placeholder: string,
// 每块装甲板的血量;available 时有效
hps: [int],
}
/* 首次运行时的摄像头选择。
*
* 队伍端需要绑定一块板子(选定后不可更改),裁判端不需要。 */
export struct CameraChoice {
id-text: string,
addr-text: string,
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;
}
}
}
}
/* ---------------------------------------------------------------- 队伍端
*
* 队伍终端:一块画面 + 自己那一边的血量。
*
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
* 画面空间给视频,一边要均分),共用一套再用 visible 切换会让每处改动都要考虑两种
* 情况。 */
export component TeamApp inherits Window {
title: "cam-client — 队伍";
// 初始尺寸只作为"用户什么都不做时"的兜底;Rust 侧在 show() 之前按屏幕工作区
// 覆盖它,所以这里的数字只是无屏幕信息时的退路。
preferred-width: 1280px;
preferred-height: 800px;
// min-* 保证拖到很小时仍然可用:两路并排时没有下限,空的那格会被压没。
min-width: 900px;
min-height: 600px;
in property <image> frame;
in property <CameraStats> stats;
in property <TeamHealth> health;
// 选择绑定时显示,其余时候隐藏
in property <[CameraChoice]> choices;
in property <bool> choosing;
callback pick(int);
background: #101014;
if !root.choosing: VerticalLayout {
spacing: 0;
// 画面占满其余全部空间。0 的 padding/spacing 是"贴边",让画面尽可能大——
// 视频终端里每一像素都可能是有用信息。
VideoPane {
frame: root.frame;
empty-text: root.stats.online ? "已连接,等待画面…" : "离线";
vertical-stretch: 1;
horizontal-stretch: 1;
}
StatusBar {
stats: root.stats;
health: root.health;
wide: true;
}
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
// release 下这行也保留——它不泄露任何对手信息。
Rectangle {
background: #14141a;
height: 24px;
Text {
text: root.health.placeholder;
color: #6a6a75;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 选择界面
if root.choosing: VerticalLayout {
padding: 32px;
spacing: 14px;
Text {
text: "选择要观看的摄像头";
color: #e8e8ec;
font-size: 20px;
font-weight: 600;
}
Text {
text: "选定后不能更改。这里的选择会写入配置文件,重启后仍然生效。";
color: #8a8a95;
font-size: 13px;
wrap: word-wrap;
}
Rectangle { height: 6px; background: transparent; }
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
// 点击回调;TouchArea 才是带交互的元素。
for choice[index] in root.choices: Rectangle {
background: choice.online ? #1c1c22 : #16161b;
border-width: 1px;
border-color: #2a2a33;
height: 56px;
TouchArea {
clicked => {
root.pick(index);
}
}
HorizontalLayout {
padding: 14px;
spacing: 16px;
Text {
text: choice.id-text;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
horizontal-alignment: left;
}
Text {
text: choice.addr-text;
color: #8a8a95;
font-size: 12px;
vertical-alignment: center;
horizontal-alignment: left;
horizontal-stretch: 1;
}
Text {
text: choice.online ? "在线" : "离线";
color: choice.online ? #7ddc7d : #8a8a95;
font-size: 12px;
vertical-alignment: center;
}
}
}
// 一块都没发现时给出可操作的提示,而不是一个空列表。
if root.choices.length == 0: Rectangle {
background: #1a1418;
border-width: 1px;
border-color: #4a2a35;
height: 64px;
Text {
text: "还没有发现任何摄像头。请确认板子已上电、已连上这个网络,\n然后稍候——板子每 2 秒广播一次在线状态。";
color: #e0a060;
font-size: 13px;
wrap: word-wrap;
vertical-alignment: center;
}
}
}
}
/* ---------------------------------------------------------------- 裁判端
*
* 裁判终端:两块画面左右并排 + 双方血量。
*
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
* 并排对称。 */
export component RefereeApp inherits Window {
title: "cam-client — 裁判系统";
preferred-width: 1600px;
preferred-height: 900px;
min-width: 1000px;
min-height: 640px;
in property <image> frame-a;
in property <image> frame-b;
in property <CameraStats> stats-a;
in property <CameraStats> stats-b;
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;
VideoPane {
frame: root.frame-a;
empty-text: root.stats-a.online ? "已连接,等待画面…" : "未收到画面";
vertical-stretch: 1;
horizontal-stretch: 1;
preferred-width: 480px;
min-width: 320px;
}
VideoPane {
frame: root.frame-b;
empty-text: root.stats-b.online ? "已连接,等待画面…" : "未收到画面";
vertical-stretch: 1;
horizontal-stretch: 1;
preferred-width: 480px;
min-width: 320px;
}
}
// 每路画面下方的队伍条:名字、血量、统计。
//
// 用与画面列完全相同的宽度策略,所以拖动窗口时文字块不会跟着画面块错位。
HorizontalLayout {
padding: 0;
spacing: 2px;
StatusBar {
stats: root.stats-a;
health: root.health-a;
horizontal-stretch: 1;
preferred-width: 480px;
min-width: 320px;
}
StatusBar {
stats: root.stats-b;
health: root.health-b;
horizontal-stretch: 1;
preferred-width: 480px;
min-width: 320px;
}
}
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
Rectangle {
background: #14141a;
height: 24px;
Text {
text: root.health-a.placeholder;
color: #6a6a75;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}