diff --git a/apps/studio/config/compound_eye.png b/apps/studio/config/compound_eye.png index d3977c6..03b879b 100644 Binary files a/apps/studio/config/compound_eye.png and b/apps/studio/config/compound_eye.png differ diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 6c36d4e..271dd44 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -7,7 +7,13 @@ }, "brand": { "name": "ORISYS", - "tagline": "DieDong Technology" + "tagline": "DieDong Technology", + "nav_live": "Live", + "nav_live_tip": "Current view: live monitoring. Click to leave history browsing.", + "nav_playback": "Replay", + "nav_playback_tip": "Open a recording: video, curves and one shared cursor", + "nav_calibration": "Calibration", + "nav_calibration_tip": "Show the calibration panel" }, "menu": { "file": "File", @@ -25,6 +31,7 @@ "calibration_panel": "Calibration panel", "finger_3d": "Finger 3D view", "show_fps": "Show FPS", + "settings": "Settings area", "plot_settings": "Plot settings…", "rotate_arrows": "Rotate arrows 90°", "run": "Run / Stop", @@ -72,19 +79,23 @@ "reset": "Reset", "units": "Units", "units_newton": "Newton", - "units_raw": "Raw" + "units_raw": "Raw", + "settings": "Settings", + "settings_tip": "Show or hide the source, camera, unit and product settings" }, "units": { "newton_short": "N", "raw_short": "raw" }, "sensor": { - "type": "Product", - "config": "Config", + "type": "Sensor", + "config": "Sensor config", + "type_tip": "Pick the product/sensor: box, mini or finger. It decides which config files and algorithm parameters exist.", + "config_tip": "The algorithm config file (config/*.json) this sensor uses. Switching the sensor switches the available configs with it.", "thumb_placeholder": "Product model" }, "panels": { - "arrow": "2D arrows", + "arrow": "2D image", "view_arrows_pure": "Arrows", "view_arrows_stitched": "Arrows / Captured", "view_depth": "Depth map", @@ -93,6 +104,12 @@ "arrow_empty_tip": "The deformation arrow image appears here while running or previewing", "depth_unavailable": "No depth data for this frame\n(not a depth of zero, and no silent fallback to the processed image)", "depth_scale": "Depth scale {low} – {high}", + "arrow_scale": "Display ×{factor}", + "arrow_scale_tip": "Display magnification of the relative displacement: the arrows are drawn from the algorithm output of the captured image, with the measured displacement multiplied by this factor and capped at 0.8 of the grid spacing (a capped arrow no longer shows its true factor).", + "arrow_pipeline": "Captured image → algorithm output", + "arrow_pipeline_tip": "Arrows are drawn over the algorithm output, not over the raw camera frame: points below the threshold get no arrow", + "arrow_area_masked": "Valid area", + "arrow_area_full": "Full frame", "depth_scale_tip": "Fixed for this session: the range is latched to the first frame that carried depth so frames stay comparable. Grey marks samples with no depth.", "plots": "Force Charts", "cal": "Calibration", @@ -104,6 +121,11 @@ "fn": "FN", "fx": "FX", "fy": "FY", + "fn_name": "Normal force", + "fx_name": "Shear X", + "fy_name": "Shear Y", + "window_readout": "{start} – {end} s / {total} s total", + "window_tip": "Drag the highlighted region to move the window: this strip is the timeline of the whole session", "no_data": "—", "mode_follow": "Following live", "mode_browse": "Browsing history (display paused; capture and recording continue)", @@ -188,7 +210,17 @@ "finished_with_problems": "Recording finished with problems: {detail}", "closing": "Closing the recording files…", "failed_state": "The recording failed (see the summary and the log)", - "alarm": "Recording problem: {detail}" + "alarm": "Recording problem: {detail}", + "state_idle": "No recording", + "state_recording": "Recording {duration}", + "state_closing": "Closing files…", + "state_error": "Write error ({count})", + "state_done": "Last recording: {frames} frames", + "dropped": "{count} dropped", + "strip_idle": "This session has not started a recording yet", + "strip_no_error": "none", + "strip_no_path": "not created yet", + "strip_tip": "Recording state: {state}\nFrames {frames} / segments {segments} / duration {duration}\nDropped {dropped} (data {dropped_data}, video {dropped_video}, gaps {dropped_gaps})\nWrite errors {errors}\nLast error: {detail}\nOutput directory {path}" }, "cal": { "weight": "Weight (N)", @@ -284,8 +316,18 @@ "ready": "Ready", "phase_idle": "Idle", "phase_connecting": "Connecting", - "phase_running": "Running", + "phase_running": "Acquiring", "phase_error": "Error", + "rail_camera": "Camera {id}", + "rail_video": "Video file", + "data_valid": "Data valid", + "data_invalid": "{count} invalid sample(s)", + "calibrated": "Calibrated", + "calibrated_all": "Calibrated · 3 axes", + "calibrated_axes": "Calibrated · {axes}", + "uncalibrated": "Uncalibrated", + "config_none": "no config", + "device_label": "config {config}", "camera_none": "No cameras found", "camera_found": "Found {count} camera(s): {names}", "preview_started": "Preview started (camera {camera_id})", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 9cee18d..e05a374 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -7,7 +7,13 @@ }, "brand": { "name": "ORISYS", - "tagline": "叠动科技" + "tagline": "叠动科技", + "nav_live": "实时监测", + "nav_live_tip": "当前视图:实时监测。点击可退出历史浏览。", + "nav_playback": "历史回放", + "nav_playback_tip": "打开一段录制:视频、曲线与同一游标", + "nav_calibration": "标定", + "nav_calibration_tip": "显示标定面板" }, "menu": { "file": "文件", @@ -25,6 +31,7 @@ "calibration_panel": "标定面板", "finger_3d": "手指 3D 视图", "show_fps": "显示 FPS", + "settings": "设置区", "plot_settings": "绘图设置…", "rotate_arrows": "旋转箭头图 90°", "run": "运行 / 停止", @@ -72,19 +79,23 @@ "reset": "重置", "units": "单位", "units_newton": "牛顿", - "units_raw": "原始" + "units_raw": "原始", + "settings": "设置", + "settings_tip": "展开或收起来源、摄像机、单位与产品配置" }, "units": { "newton_short": "N", "raw_short": "raw" }, "sensor": { - "type": "产品", - "config": "配置", + "type": "传感器", + "config": "传感器配置", + "type_tip": "选择产品/传感器:box、mini、finger。它决定可用的配置文件与算法参数。", + "config_tip": "该传感器使用的算法配置文件(config/*.json)。切换传感器会同时切换可选配置。", "thumb_placeholder": "产品型号" }, "panels": { - "arrow": "形变箭头", + "arrow": "2D 图像", "view_arrows_pure": "箭头", "view_arrows_stitched": "箭头/采集图像", "view_depth": "深度图", @@ -93,6 +104,12 @@ "arrow_empty_tip": "运行或预览后在此显示形变箭头图", "depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)", "depth_scale": "深度色标 {low} – {high}", + "arrow_scale": "放大 ×{factor}", + "arrow_scale_tip": "相对位移的显示放大倍率:箭头由原始采集图像经算法输出,长度按测量位移放大后绘制,上限为网格间距的 0.8 倍;触顶的箭头不再呈现真实倍率。", + "arrow_pipeline": "原始图像 → 算法输出", + "arrow_pipeline_tip": "箭头画在算法输出上,不是相机原始帧:阈值以下的区域不画箭头", + "arrow_area_masked": "有效区域", + "arrow_area_full": "全画幅", "depth_scale_tip": "本会话固定色标:色标范围锁定在首个有效深度帧,便于跨帧比较;灰色区域表示无深度数据", "plots": "力曲线图", "cal": "标定", @@ -104,6 +121,11 @@ "fn": "FN", "fx": "FX", "fy": "FY", + "fn_name": "法向力", + "fx_name": "切向力 X", + "fy_name": "切向力 Y", + "window_readout": "{start} – {end} s / 全程 {total} s", + "window_tip": "拖动高亮区域即可移动视窗;这是整段会话的时间轴", "no_data": "—", "mode_follow": "跟随实时", "mode_browse": "浏览历史(显示暂停,采集与记录继续)", @@ -188,7 +210,17 @@ "finished_with_problems": "记录完成,但存在问题: {detail}", "closing": "正在关闭记录文件…", "failed_state": "记录已失败(详见摘要与日志)", - "alarm": "记录异常: {detail}" + "alarm": "记录异常: {detail}", + "state_idle": "尚未记录", + "state_recording": "记录中 {duration}", + "state_closing": "正在写入…", + "state_error": "写入异常({count})", + "state_done": "上次记录 {frames} 帧", + "dropped": "丢帧 {count}", + "strip_idle": "本次会话还没有开始记录", + "strip_no_error": "无", + "strip_no_path": "尚未创建", + "strip_tip": "记录状态: {state}\n帧 {frames} / 分段 {segments} / 时长 {duration}\n丢帧 {dropped}(数据 {dropped_data}、视频 {dropped_video}、空洞 {dropped_gaps})\n写入错误 {errors}\n最近错误: {detail}\n输出目录 {path}" }, "cal": { "weight": "砝码重量 (N)", @@ -284,8 +316,18 @@ "ready": "就绪", "phase_idle": "空闲", "phase_connecting": "连接中", - "phase_running": "运行中", + "phase_running": "采集中", "phase_error": "出错", + "rail_camera": "摄像机 {id}", + "rail_video": "视频文件", + "data_valid": "数据有效", + "data_invalid": "无效样本 {count}", + "calibrated": "已标定", + "calibrated_all": "已标定 · 三轴", + "calibrated_axes": "已标定 · {axes}", + "uncalibrated": "未标定", + "config_none": "未选配置", + "device_label": "配置 {config}", "camera_none": "未发现摄像机", "camera_found": "发现 {count} 个摄像机: {names}", "preview_started": "预览已启动(摄像机 {camera_id})", diff --git a/apps/studio/config/preferences.default.json b/apps/studio/config/preferences.default.json index 3e2df60..53b05d9 100644 --- a/apps/studio/config/preferences.default.json +++ b/apps/studio/config/preferences.default.json @@ -19,5 +19,7 @@ "plot_shear_raw_max": 500000.0, "image_view_mode": "arrows_pure", "arrow_rotation_deg": 90, + "settings_expanded": false, + "view_preset": "custom", "splitter_sizes": [340, 480, 400] } diff --git a/apps/studio/docs/record-channel.md b/apps/studio/docs/record-channel.md index b2ddc7f..ebd8e7c 100644 --- a/apps/studio/docs/record-channel.md +++ b/apps/studio/docs/record-channel.md @@ -327,7 +327,36 @@ outputs/recordings/_session/ --- -## 9. 尚未完成(第三阶段及以后) +## 9. 界面上的记录状态条 + +录制过程对操作员可见的部分,集中在顶栏状态行右侧的一个记录状态条 +(`apps/studio/src/orisys_studio/ui/record_status.py`)。它回答按钮本身答不了的四个问题: + +| 显示 | 判据 | 颜色令牌 | +|---|---|---| +| `尚未记录` | 本会话从未开始记录(`recording.is_recording` 与 `last_report` 都为空) | `status_idle` | +| `记录中 mm:ss.d` | `recording.is_recording`;时长取自 `recorder.stats().duration_s` | `status_running` | +| `正在写入…` | `recording.report_pending`:写入线程还没关完文件(**不**当成已完成) | `status_connecting` | +| `上次记录 N 帧` | 已拿到终态报告且 `failed`/`write_errors` 为 0 | `status_idle` | +| `写入异常(N)` | `stats.failed` 或 `stats.write_errors > 0` | `status_error` | + +丢帧不是单独一行,而是追加在状态后面(`丢帧 N`),并把整条状态提到 `warning` 色; +N 是 `dropped_data + dropped_video + dropped_gaps + unrecorded_gaps` —— 只报前两个 +队列溢出计数会低估真实损失。tooltip 里分项列出帧数、分段、时长、四类丢帧、写入错误、 +最近一次错误与输出目录,因此状态条本身可以保持一行。 + +读数入口是 `StudioMainWindow._record_stats()`:它在 UI 线程上返回 +`(state, stats, directory)`,是**唯一**读记录通道的地方。`ExperimentRecorder.stats()` +返回加锁快照,不会被写入线程阻塞;`tools/ui_snapshot.py --scene recording` 就是替换这 +一个接缝来渲染「记录中」画面,既不写文件也不开设备。 + +刷新时机:`_on_run_tick`(16 ms 定时器)与每次状态跃迁(开始/停止记录、拿到终态报告、 +阶段变化)。控件只在文本或颜色令牌真的变化时才 `setText`/`setStyleSheet`,因此 60 Hz +下不会反复重排顶栏。 + +--- + +## 10. 尚未完成(第三阶段及以后) - PyOpenGL 三维显示(`mesh3d_panel.py` 目前仍是占位符,不是已有实现)。 - CSV 的「导出选中时间段」(整段导出已随录制落盘;区间导出待做)。 diff --git a/apps/studio/docs/shell-lifecycle.md b/apps/studio/docs/shell-lifecycle.md new file mode 100644 index 0000000..b5c27ab --- /dev/null +++ b/apps/studio/docs/shell-lifecycle.md @@ -0,0 +1,79 @@ +# Studio 外壳:视图与关闭流程 + +这份文档记录主窗口的两件"外壳"行为:三个顶层视图如何切换面板,以及窗口关闭后进程 +如何回到命令行。两者都不属于记录通道,但都会直接影响可用性。 + +--- + +## 1. 三个顶层视图 = 同一窗口的三套布局 + +品牌轨上的三个导航项不打开新窗口,而是切换主窗口的布局 +(`StudioMainWindow._apply_view_preset`,预设在 `VIEW_PRESETS` / `VIEW_SPLITTER_SIZES`): + +| 视图 | 面板 | 曲线模式 | 分割比例(3D, 2D, 曲线, 标定) | +|---|---|---|---| +| 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` | +| 历史回放 `replay` | 2D 图像 + 力曲线 | 进入全程浏览 | `0, 320, 1120, 0` | +| 标定 `calibration` | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` | +| 自由 `custom`(启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 | + +- **为什么标定视图给标定面板 880 px**:六列诊断表头加九个动作按钮,在 300–400 px 的 + 三栏布局里必然被挤压(原来的症状就是按钮文字被切成「排 除」「设为验证点」)。面板自身 + 还有纵向滚动区,窗口再矮也够得到「应用到标定」。 +- **为什么导航不再自动打开回放对话框**:回放对话框(视频 + 离线深度重算 + 同一游标)仍 + 从「文件」菜单打开;导航的职责是把"看历史"这件事在主界面里摆好——全程浏览 + 2D 图像, + 立刻可以拖时间轴。 +- **`custom` 不点亮任何导航项**:手工摆出来的布局不属于三个视图中的任何一个,点亮一个 + 会误报状态。 +- **`视图 → 标定面板` 与「标定」导航是同一个开关**:勾上进入标定视图,取消回到实时视图。 + 不允许出现"面板已隐藏但导航还亮着"。 + +回放对话框的三颗按钮曾经完全没有文字——`PlaybackDialog.retranslate()` 写好了却从没被 +调用过。现在它在 `__init__` 末尾调用一次(`test_the_playback_dialog_labels_its_buttons` +守住这一点)。 + +--- + +## 2. 关闭流程:窗口关掉后进程必须回到命令行 + +关闭顺序(`StudioMainWindow.closeEvent` → `main()`): + +1. `closeEvent`: + - 先摘掉记录通道的 sink,避免正在收尾的采集线程继续持有窗口; + - `_stop_run(reason="user_stop", wait=True)`:阻塞地释放设备; + - `_stop_recording(wait=True)`:写出 `session.json` 并关闭 MP4/CSV; + - `_persist_prefs()`:保存窗口几何、分割比例、视图与各项开关。 +2. `app.exec_()` 返回后,`main()` **立刻结束进程**: + +```python +code = int(app.exec_()) +logging.shutdown() +for stream in (sys.stdout, sys.stderr): + try: + stream.flush() + except Exception: + pass +os._exit(code) +``` + +**为什么用 `os._exit`**:相机 SDK 与 OpenCV 各自持有原生线程与句柄,正常返回会在它们之上 +再跑一遍解释器收尾(模块销毁、DLL 卸载),进程就卡在那里——症状正是"命令行不会完全退出"。 +所有需要落盘的东西在 `closeEvent` 与 `logging.shutdown()` 里已经写完,这里跳过的只是 +"已经没有东西可写的库"的析构。 + +`tools/ui_check.py` 的 `clean shutdown` 段在子进程里跑真实的 `main()`,用定时器关掉窗口 +并断言进程在 45 秒内以 0 返回(实测约 2 秒),所以这条路径有回归保护。 + +--- + +## 3. 复核方式 + +```powershell +$py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe" +$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src" +cd D:\orisys_ws\orisys-app\apps\studio + +& $py tools/ui_check.py # 含 top area / instrument panels / clean shutdown +& $py tools/ui_snapshot.py --demo --preset calibration --out outputs/_snap_calibration.png +& $py tools/ui_snapshot.py --demo --scene recording --out outputs/_snap_recording.png +``` diff --git a/apps/studio/docs/ui-palette.md b/apps/studio/docs/ui-palette.md index 06ab617..6b10657 100644 --- a/apps/studio/docs/ui-palette.md +++ b/apps/studio/docs/ui-palette.md @@ -25,6 +25,23 @@ orisys-app/shared/src/orisys_app_shared/theme/colors_light.json | `#D8FF00` | 柠檬绿 | `flow_ramp` 第 5 档(原值保留)、`plot_fy`(深色) | | `#D5D5D5` | 应用画布 | **未采用**,改为 `#E1E6EF`(见 §3.3) | +### 1.1 三轴分类色(蓝 / 橙 / 绿) + +界面改版把三个力通道统一成"蓝 = 法向、橙 = 切向 X、绿 = 切向 Y"。它们是 +**分类色**(三条曲线各自一个身份),与 §4 的箭头色带(顺序色带)是两件事,因此落在 +`plot_fn` / `plot_fx` / `plot_fy` 三个令牌上。这三个令牌同时被实时曲线、录制回放、 +标定面板与标定点详情对话框引用,换值即全局统一。 + +| 主题 | `plot_fn`(法向,蓝) | `plot_fx`(切向 X,橙) | `plot_fy`(切向 Y,绿) | +|---|---|---|---| +| 深色 | `#6FA8FF` | `#FF9A4D` | `#7CE0A0` | +| 浅色 | `#003893`(品牌藏青,见 §3.1) | `#B45309` | `#2E7D32` | + +浅色主题的 FN 保持品牌藏青 `#003893`(与 `accent` 同值),这样品牌蓝在三轴里仍是 +"主通道";橙色与绿色在浅底上取深色相,才够对比度。深色主题的霓虹洋红/柠檬绿被替换, +原因是它们是**色带**用色:作为分类色时洋红与橙的色相差偏小,而柠檬绿在浅底上不可用 +(§2.1)。替换后三个通道在灰度打印下也靠亮度分开,不再依赖单一色相。 + --- ## 2. 实测对比度(WCAG) @@ -55,21 +72,26 @@ orisys-app/shared/src/orisys_app_shared/theme/colors_light.json ### 2.2 落地后 浅色主题(节选):白字 on `accent` 10.57,白字 on `accent_hover` 5.52, -`plot_fn/fx/fy` 对 `plot_bg` 分别 10.11 / 6.00 / 4.89, +`plot_fn/fx/fy` 对 `plot_bg` 分别 10.11 / 4.80 / 4.90, `brand_bar_fg` on `brand_bar_bg` 12.94,`input_border` 对四种底均 ≥3.08。 深色主题(节选):白字 on `accent` 5.52,`accent` 对 `panel_bg` 3.09(贴线但仍达标), -`plot_fn/fx/fy` 分别 7.61 / 6.16 / 15.73,`focus_ring` 对 `input_bg` 6.51。 +`plot_fn/fx/fy` 对 `plot_bg` 分别 7.53 / 8.62 / 11.25,`focus_ring` 对 `input_bg` 6.51。 + +语义色(`danger` / `warning` / `success` / `status_*`)额外对 `toolbar_bg` 审计:它们会 +出现在顶栏状态行上(记录状态、数据有效性、阶段指示),实测浅色 4.65–6.66、深色 5.60–9.13, +全部 ≥4.5。在这一步之前它们只在 `panel_bg` / `card_bg` 上被检查,顶栏是一个未经审计的底色。 三通道可分辨性(色相差 / 相对亮度比): | 主题 | FN vs FX | FN vs FY | FX vs FY | |---|---|---|---| -| 浅色 | 97.0° / 1.68 | 135.5° / 2.07 | 127.5° / 1.23 | -| 深色 | 96.7° / 1.24 | 154.7° / 2.07 | 108.7° / 2.55 | +| 浅色 | 168.8° / 2.10 | 94.1° / 2.06 | 97.1° / 1.02 | +| 深色 | 169.7° / 1.14 | 74.7° / 1.49 | 115.6° / 1.31 | -规则是"色相差 ≥90° **或** 相对亮度比 ≥1.2",两主题均满足。颜色在任何情况下都不是 -唯一编码:每个曲线有文字标签,每张数值卡有同色通道徽标。 +规则是"色相差 ≥90° **或** 相对亮度比 ≥1.2",两主题均满足(深色 FN vs FY 靠 1.49 的亮度比 +过关,浅色 FX vs FY 靠 97.1° 的色相差过关)。颜色在任何情况下都不是唯一编码:每个曲线有 +文字标签,每张数值卡有同色通道徽标与通道名。 --- @@ -148,7 +170,76 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00 --- -## 6. 复核方式 +## 6. 顶栏:一件仪器只有一条读数线 + +顶部由两条细轨组成,合计在 1440×880 下不超过 78 px(`ui_check.py` 的 `top area` 段与 +`tests/test_ui_smoke.py::test_top_area_stays_compact` 都断言这一点): + +| 轨 | 高度 | 承载 | +|---|---|---| +| 品牌轨(`QFrame#brandBar`) | 28 px | logo + 字标 + 三个顶层视图(实时监测 / 历史回放 / 标定)+ 右侧产品名 | +| 状态轨(`ToolbarWidget` 的 rail) | ~30 px | **传感器 / 传感器配置**、阶段、采集来源、标定状态、数据有效性、记录状态、记录/运行/重置/设置 | +| 设置区(默认收起) | 0 / ~40 px | 来源、摄像机编号、视频文件、单位、**记录模式** | + +三点规则: + +1. **传感器选择在状态轨最左侧,且两个下拉都带标签**。`box / mini / finger` 是产品 + (传感器)类型,它决定后面可选的配置文件;原来只有一个「配置」下拉,看不出在切什么。 + 现在写成「传感器」+「传感器配置」两个带 tooltip 的下拉,产品型号另有一个徽标 + (`FINGER` 不得被截断成 `INGE`,所以徽标只有最小宽度)。 +2. **导航按钮在品牌底上取品牌前景色**。`navButton` 用 `brand_bar_fg_muted`,当前视图用 + `brand_bar_fg` + `navActive="true"`;两者都在 §2 的品牌底对比度审计里(7.67 / 12.94), + 不新增色值。 +3. **状态轨上的语义色一律算在 `toolbar_bg` 上审计**。记录状态(`status_idle` / + `status_running` / `status_connecting` / `status_error`)、丢帧提示(`warning`)与数据 + 有效性(`success` / `warning`)此前只检查 `panel_bg`,而它们实际画在顶栏底色上。 +4. **同一时刻只有一个实心按钮**(`ToolbarWidget._refresh_emphasis`): + + | 状态 | 实心 | 描边 | + |---|---|---| + | 空闲 | `运行`(`primaryButton`) | — | + | 连接中 | `取消连接`(`dangerButton`) | — | + | 采集中 | `开始记录`(`primaryButton`) | `停止`(`dangerOutline`) | + | 记录中 | `停止记录`(`dangerButton`) | `停止`(`dangerOutline`) | + + `dangerOutline` 是新增样式:透明底 + `danger` 文字与描边,hover/pressed 才填充 + `danger_fill`;`:checked`(即"采集正在跑")仍保持描边——否则一圈红边会在会话开始后 + 悄悄变成整块红色,那正是这次要修掉的观感。 + + 原反馈里"提高停止按钮优先级"的诉求没有丢:停止始终是红色、始终一键可达,只是不再 + 与当前主操作争夺同一时刻的唯一实心位置。 + +5. **状态轨上的事实不得被压缩**(`ui_check.rail_squeezed`)。中文、英文、1280 与 1440 + 四种组合下,轨上的按钮与下拉都必须显示完整文本;只有带 tooltip 的 `ShrinkableLabel` + 允许在窗口极窄时被裁切。这也是"记录模式"下拉被挪进设置区的原因:英文的 + `Start recording` + `Sensor config` 与它挤在同一行时,第一个被压缩的就是按钮文字。 + +--- + +## 7. 三个顶层视图 = 同一窗口的三套布局 + +导航不再打开对话框,而是切换主窗口的布局(`StudioMainWindow._apply_view_preset`): + +| 视图 | 面板 | 曲线模式 | 分割比例(3D, 2D, 曲线, 标定) | +|---|---|---|---| +| 实时监测 | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` | +| 历史回放 | 2D 图像 + 力曲线 | 进入全程浏览 | `0, 320, 1120, 0` | +| 标定 | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` | +| 自由(`custom`,启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 | + +- 标定视图给标定面板 880 px:六列诊断表头与九个动作按钮不再被挤压;标定面板自身还有 + 纵向滚动区,窗口再矮也够得到「应用到设备」。 +- 标定面板列宽改为 `ResizeToContents`:诊断列头宁可让表格横向滚动,也不截成 + 「FN 均」这种看不懂的缩写。 +- 2D 图像区域固定为所显示图像的宽高比(`AspectImageLabel`)。横向分割器**不会**向子控件 + 询问 `heightForWidth`,所以宽高比由 `ArrowPanel.resizeEvent` 主动施加;box 传感器的 + 画面旋转 90° 显示,因此其显示宽高比是 9:16 而不是 16:9。 +- `视图 → 标定面板` 与「标定」导航是同一个开关:勾上进入标定视图,取消回到实时视图, + 不会留下"面板已隐藏但导航还亮着"的矛盾状态。 + +--- + +## 8. 复核方式 ```powershell $py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe" @@ -158,4 +249,9 @@ cd D:\orisys_ws\orisys-app\apps\studio & $py tools/ui_check.py # 含 palette 规则段落 & $py tools/ui_snapshot.py --theme dark --lang zh_CN --demo --out outputs/_snap_dark.png & $py tools/ui_snapshot.py --theme light --lang en_US --sensor finger --panels cal,3d --size 1600x900 --demo --out outputs/_snap_light.png +& $py tools/ui_snapshot.py --theme dark --lang zh_CN --demo --scene recording --out outputs/_snap_recording.png +& $py tools/ui_snapshot.py --theme dark --lang zh_CN --demo --preset calibration --out outputs/_snap_calibration.png ``` + +视图切换与关闭流程另见 `apps/studio/docs/shell-lifecycle.md`。 + diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index dea8059..ac6de16 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -45,11 +45,12 @@ from orisys_app_shared.paths import logo_path, sensor_json_path from orisys_app_shared.prefs import save_preferences from orisys_app_shared.theme import build_qss, clear_theme_cache, color +from orisys_studio.ui import record_status as record_states from orisys_studio.ui.about_dialog import AboutDialog from orisys_studio.ui.arrow_panel import ArrowPanel from orisys_studio.ui.brand_bar import BrandBar from orisys_studio.ui.cal_panel import CalPanel -from orisys_studio.ui.force_plots import ForcePlotsPanel +from orisys_studio.ui.force_plots import CHANNEL_LABELS, ForcePlotsPanel from orisys_studio.ui.mesh3d_panel import Mesh3dPanel from orisys_studio.ui.plot_settings_dialog import ( DEFAULT_PLOT_FN_MAX, @@ -74,6 +75,18 @@ MIN_PANEL_WIDTHS = { "cal": 300, } SESSION_PHASES = ("idle", "connecting", "running", "error") +#: Top-level views. Each one is a working arrangement of the same panels, not a +#: separate window: switching views must not cost the operator a dialog. +VIEW_PRESETS = ("custom", "live", "replay", "calibration") +#: Splitter sizes per view, in panel order (3D, 2D, charts, calibration). +#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``). +VIEW_SPLITTER_SIZES = { + "live": (0, 360, 1080, 0), + "replay": (0, 320, 1120, 0), + "calibration": (0, 0, 560, 880), +} +#: The 3D panel only belongs to the live view, and only for a finger sensor. +VIEW_SHOWS_3D = ("custom", "live") #: After this long in ``connecting``, tell the user that Stop is the way out. CONNECT_SLOW_HINT_S = 8.0 @@ -105,6 +118,8 @@ class StudioMainWindow(QMainWindow): self._language = str(self.prefs.get("language") or "zh_CN") self._show_cal = bool(self.prefs.get("show_cal_panel", False)) self._layout_preset = str(self.prefs.get("layout_preset") or "default") + preset = str(self.prefs.get("view_preset") or "custom") + self._view_preset = preset if preset in VIEW_PRESETS else "custom" self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True)) self._show_fps = bool(self.prefs.get("show_fps", False)) self._session_phase = "idle" @@ -144,8 +159,17 @@ class StudioMainWindow(QMainWindow): self.recorder = self.recording.recorder self._session_counter = 0 self._playback = None + self._active_calibration_id = "" self._record_error_count = 0 self._dropped_reported = 0 + #: Last display-unit decision (``None`` before the first sample): the + #: status rail's calibration readout is derived from it, because an axis + #: without a newton curve is exactly an axis without calibration. + self._last_display_fallback: bool | None = None + self._last_display_available: tuple[bool, bool, bool] | None = None + #: Was the most recent readout valid? ``None`` before the first sample. + self._last_sample_valid: bool | None = None + self._latest_fps: float | None = None #: Last coefficients file written by the calibration panel (not applied). self._last_calibration_export = "" self._depth_range_value: tuple[float, float] | None = None @@ -162,25 +186,22 @@ class StudioMainWindow(QMainWindow): root.setSpacing(8) self.brand_bar = BrandBar(theme=self._theme) + self.brand_bar.live_requested.connect(self._on_live_requested) + self.brand_bar.playback_requested.connect(self._on_replay_requested) + self.brand_bar.calibration_requested.connect(self._on_calibration_requested) root.addWidget(self.brand_bar) - top = QHBoxLayout() - top.setSpacing(10) - self.toolbar = ToolbarWidget() + # The sensor picker leads the status rail: which product (box / mini / + # finger) and which config the window is configured for decides what + # everything else means, so it is not buried in a collapsed area. self.sensor_picker = SensorPickerWidget() - sensor_card = QFrame() - sensor_card.setObjectName("contentPanel") - sensor_card_layout = QVBoxLayout(sensor_card) - sensor_card_layout.setContentsMargins(0, 0, 0, 0) - sensor_card_layout.setSpacing(0) - sensor_card_layout.addWidget(self.sensor_picker) - top.addWidget(self.toolbar, 2) - top.addWidget(sensor_card) - root.addLayout(top) + self.toolbar = ToolbarWidget(theme=self._theme, sensor_picker=self.sensor_picker) + root.addWidget(self.toolbar) + self.status_chip = self.toolbar.status_chip self.content_splitter = QSplitter(Qt.Horizontal) self.mesh3d_panel = Mesh3dPanel() - self.arrow_panel = ArrowPanel() + self.arrow_panel = ArrowPanel(theme=self._theme) self.force_plots = ForcePlotsPanel(theme=self._theme) # The charts browse the whole session, not just their rolling buffer. self.force_plots.set_store(self.session_store) @@ -193,9 +214,11 @@ class StudioMainWindow(QMainWindow): root.addWidget(self.content_splitter, 1) self.setStatusBar(QStatusBar()) - self.status_chip = QLabel() - self.status_chip.setObjectName("statusChip") - self.statusBar().addPermanentWidget(self.status_chip) + # The phase/record state lives in the toolbar's rail; the status bar is + # for transient messages plus one permanent line of device identity. + self.device_label = QLabel() + self.device_label.setObjectName("panelMeta") + self.statusBar().addPermanentWidget(self.device_label) self.cal_panel.status_message.connect(self.statusBar().showMessage) self.cal_panel.calibration_exported.connect(self._on_calibration_exported) self.cal_panel.apply_requested.connect(self._on_calibration_apply) @@ -204,7 +227,9 @@ class StudioMainWindow(QMainWindow): self._wire_toolbar() self._wire_sensor_picker() self._restore_prefs_into_ui() - self._apply_panel_visibility() + # Visibility and the nav highlight come from the restored view; the + # stored splitter sizes still win at startup (``apply_sizes=False``). + self._apply_view_preset(self._view_preset, apply_sizes=False, persist=False) self._restore_splitter_sizes() self._update_run_enabled() self.cal_panel.bind_recorder(self.cal_recorder) @@ -213,6 +238,8 @@ class StudioMainWindow(QMainWindow): config_ref=self._resolve_config_ref(), ) self.retranslate() + self._refresh_status_rail() + self._refresh_record_state() self._restore_geometry() self._apply_default_size() @@ -311,6 +338,7 @@ class StudioMainWindow(QMainWindow): return self.recorder.log_event("record_started", f"mode={mode}") self.toolbar.set_record_status(Path(directory).name) + self._refresh_record_state() self.statusBar().showMessage(t("record.started", mode=t(f"record.mode_{mode}"))) logger.info("Recording started: %s (mode=%s)", directory, mode) @@ -328,6 +356,7 @@ class StudioMainWindow(QMainWindow): # Nothing was ever started: keep the widget state consistent anyway. self.toolbar.set_record_status("") self.toolbar.set_record_checked(False) + self._refresh_record_state() return self._publish_record_report(self.recording.stop(wait=wait)) @@ -335,6 +364,7 @@ class StudioMainWindow(QMainWindow): """Update the button and the summary from a recording report.""" self.toolbar.set_record_status("") self.toolbar.set_record_checked(False) + self._refresh_record_state() if report is None: return if not report.completed: @@ -496,6 +526,7 @@ class StudioMainWindow(QMainWindow): self.toolbar.record_toggled.connect(self._on_record_toggled) self.toolbar.reset_clicked.connect(self._on_reset) self.toolbar.units_changed.connect(self._on_units_changed) + self.toolbar.settings_toggled.connect(self._on_settings_toggled) def _wire_sensor_picker(self) -> None: self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed) @@ -513,8 +544,9 @@ class StudioMainWindow(QMainWindow): self.toolbar.set_video_path(str(self.prefs.get("video_path") or "")) self.toolbar.set_units(str(self.prefs.get("units") or "newton")) self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure")) - self.arrow_panel.set_fps_visible(self._show_fps) self.arrow_panel.set_rotation_deg(self.prefs.get("arrow_rotation_deg", 90)) + self.toolbar.set_settings_expanded(bool(self.prefs.get("settings_expanded", False))) + self._sync_settings_action() self._apply_plot_prefs_to_panel() sensor_type = str(self.prefs.get("sensor_type") or "box") @@ -608,6 +640,12 @@ class StudioMainWindow(QMainWindow): self.act_show_fps.toggled.connect(self._on_show_fps_toggled) self.view_menu.addAction(self.act_show_fps) + self.act_settings = QAction(self) + self.act_settings.setCheckable(True) + self.act_settings.setChecked(self.toolbar.settings_expanded()) + self.act_settings.toggled.connect(self._on_settings_action_toggled) + self.view_menu.addAction(self.act_settings) + self.act_plot_settings = QAction(self) self.act_plot_settings.triggered.connect(self._on_plot_settings) self.view_menu.addAction(self.act_plot_settings) @@ -695,10 +733,28 @@ class StudioMainWindow(QMainWindow): self._apply_arrow_rotation_for_sensor() def _apply_panel_visibility(self) -> None: + """One place decides which panels the current view shows. + + The view preset owns the 2D panel and the calibration panel; the 3D + panel additionally needs a finger sensor, its own toggle and a layout + preset that has not folded it away. ``_show_cal`` is kept in step so the + View menu, the preset and the panels cannot disagree. + """ is_finger = self.sensor_picker.sensor_type() == "finger" - show_3d = is_finger and bool(self._show_finger_3d) and self._layout_preset != "2d" + preset = self._view_preset + show_3d = ( + preset in VIEW_SHOWS_3D + and is_finger + and bool(self._show_finger_3d) + and self._layout_preset != "2d" + ) + show_arrow = preset != "calibration" + show_cal = self._show_cal if preset == "custom" else preset == "calibration" + self._show_cal = show_cal self.mesh3d_panel.setVisible(show_3d) - self.cal_panel.setVisible(self._show_cal) + self.arrow_panel.setVisible(show_arrow) + self.force_plots.setVisible(True) + self.cal_panel.setVisible(show_cal) self.mesh3d_panel.setMinimumWidth(MIN_PANEL_WIDTHS["mesh3d"]) self.arrow_panel.setMinimumWidth(MIN_PANEL_WIDTHS["arrow"]) self.force_plots.setMinimumWidth(MIN_PANEL_WIDTHS["plots"]) @@ -707,6 +763,9 @@ class StudioMainWindow(QMainWindow): self.act_finger_3d.blockSignals(True) self.act_finger_3d.setChecked(show_3d) self.act_finger_3d.blockSignals(False) + self.act_cal_panel.blockSignals(True) + self.act_cal_panel.setChecked(show_cal) + self.act_cal_panel.blockSignals(False) # The 2D preset is the reviewed layout: depth map and force charts side # by side, sharing one time axis, with calibration folded away. arrow_weight = 3 if self._layout_preset != "2d" else 5 @@ -716,11 +775,47 @@ class StudioMainWindow(QMainWindow): if widget is self.mesh3d_panel: self.content_splitter.setStretchFactor(i, 2 if show_3d else 0) elif widget is self.arrow_panel: - self.content_splitter.setStretchFactor(i, arrow_weight) + self.content_splitter.setStretchFactor(i, arrow_weight if show_arrow else 0) elif widget is self.force_plots: self.content_splitter.setStretchFactor(i, plots_weight) elif widget is self.cal_panel: - self.content_splitter.setStretchFactor(i, 3 if self._show_cal else 0) + self.content_splitter.setStretchFactor(i, 3 if show_cal else 0) + + # ------------------------------------------------------------ view presets + def _apply_view_preset( + self, preset: str, *, apply_sizes: bool = True, persist: bool = True + ) -> None: + """Show one working arrangement of the panels. + + The three top-level views are layouts of the *same* window: switching + between live measurement, history browsing and calibration must not + require opening a dialog, and each view gets the space its own content + needs (calibration especially: the six-column table and its action row + need width, not a 300 px column). + """ + preset = preset if preset in VIEW_PRESETS else "custom" + self._view_preset = preset + self.prefs["view_preset"] = preset + if preset == "calibration": + self._show_cal = True + elif preset in ("live", "replay"): + self._show_cal = False + self._apply_panel_visibility() + if apply_sizes and preset in VIEW_SPLITTER_SIZES: + self.content_splitter.setSizes(list(VIEW_SPLITTER_SIZES[preset])) + if preset == "replay": + # History browsing is the point of this view: open on the whole + # session so the overview strip is immediately draggable. + self.force_plots.show_all() + elif preset == "live": + self.force_plots.follow_latest() + self.brand_bar.set_active_view(preset if preset != "custom" else None) + self._sync_menu_checks() + if persist: + self._persist_prefs() + + def view_preset(self) -> str: + return self._view_preset def _on_layout_preset(self, preset: str) -> None: """Switch between the free layout and the reviewed 2D preset.""" @@ -769,6 +864,7 @@ class StudioMainWindow(QMainWindow): self.act_layout_2d.setText(t("layout.preset_2d_name")) self.act_finger_3d.setText(t("menu.finger_3d")) self.act_show_fps.setText(t("menu.show_fps")) + self.act_settings.setText(t("menu.settings")) self.act_plot_settings.setText(t("menu.plot_settings")) self.act_rotate_arrows.setText(t("menu.rotate_arrows")) self.act_run.setText(t("menu.run")) @@ -791,6 +887,8 @@ class StudioMainWindow(QMainWindow): self.cal_panel.retranslate() self.brand_bar.retranslate() self._refresh_status_chip() + self._refresh_status_rail() + self._refresh_record_state() if not self._preview_open and not self._running: self.statusBar().showMessage(t("status.ready")) self._update_run_enabled() @@ -807,12 +905,157 @@ class StudioMainWindow(QMainWindow): return phase = self._session_phase if self._session_phase in SESSION_PHASES else "idle" token = f"status_{phase}" - chip.setText(t(f"status.phase_{phase}")) + # One dot carries the colour; the text says what the state is, so the + # colour is never the only encoding. + chip.setText(f"● {t(f'status.phase_{phase}')}") chip.setStyleSheet( f"QLabel#statusChip {{ color: {color(self._theme, token)};" " background: transparent; font-weight: 600; padding: 0 10px 0 4px; }" ) + # ------------------------------------------------------ status rail state + def _refresh_status_rail(self) -> None: + """Phase, device, calibration, validity, FPS and device identity. + + The rail is the single place these facts are written; the bottom status + bar keeps only transient messages. + """ + if self.toolbar.source_mode() == "video": + summary = t("status.rail_video") + else: + summary = t("status.rail_camera", id=self.toolbar.camera_id()) + if self._show_fps and self._latest_fps is not None: + summary = f"{summary} · {self._latest_fps:.1f} FPS" + self.toolbar.set_sensor_summary(summary) + self.toolbar.set_calibration_summary(self._calibration_summary()) + if self._last_sample_valid is None: + self.toolbar.set_data_state("") + elif self._last_sample_valid: + self.toolbar.set_data_state(t("status.data_valid"), "success") + else: + self.toolbar.set_data_state( + t("status.data_invalid", count=self._record_error_count), "warning" + ) + self._refresh_device_label() + + def _calibration_summary(self) -> str: + """What the calibration state means for the three axes. + + Before the first sample the only evidence is whether the device + confirmed a calibration version. Afterwards the display decision is the + evidence: an axis without a newton curve is an axis without calibration, + and the rail must not claim three calibrated axes for a one-axis device. + """ + if self._last_display_fallback is None: + return ( + t("status.calibrated") + if self._active_calibration_id + else t("status.uncalibrated") + ) + if self._last_display_fallback or self._last_display_available is None: + return t("status.uncalibrated") + axes = self._last_display_available + if all(axes): + return t("status.calibrated_all") + names = [CHANNEL_LABELS[index] for index, ok in enumerate(axes) if ok] + if not names: + return t("status.uncalibrated") + return t("status.calibrated_axes", axes="+".join(names)) + + def _refresh_device_label(self) -> None: + label = getattr(self, "device_label", None) + if label is None: + return + config = self.sensor_picker.config_name() + config_name = Path(config).name if config else t("status.config_none") + label.setText(t("status.device_label", config=config_name)) + + # ---------------------------------------------------------- record state + def _record_stats(self): + """``(state, stats, directory)`` — the single read of the record channel. + + Kept as one seam so a preview/demo driver can inject a record state + without touching the recorder or the writer thread. + """ + recorder = self.recorder + raw_directory = getattr(recorder, "directory", None) + directory = Path(raw_directory).name if raw_directory else "" + if self.recording.is_recording: + return record_states.RECORDING, recorder.stats(), directory + if self.recording.report_pending: + return record_states.CLOSING, recorder.stats(), directory + report = self._record_last_report + if report is not None: + stats = report.stats + state = ( + record_states.ERROR + if (stats.failed or stats.write_errors) + else record_states.DONE + ) + return state, stats, directory + return record_states.IDLE, None, directory + + def _refresh_record_state(self) -> None: + """Push the recording's live numbers (duration, drops, write errors).""" + state, stats, directory = self._record_stats() + if stats is None: + self.toolbar.set_record_state(state, directory=directory) + return + self.toolbar.set_record_state( + state, + duration_s=stats.duration_s, + frames=stats.frames, + segments=stats.segments, + # An unrecorded gap is a lost row too: reporting only the two + # queue-overflow counters would understate the loss. + dropped=( + stats.dropped_data + + stats.dropped_video + + stats.dropped_gaps + + stats.unrecorded_gaps + ), + dropped_data=stats.dropped_data, + dropped_video=stats.dropped_video, + dropped_gaps=stats.dropped_gaps + stats.unrecorded_gaps, + errors=stats.write_errors, + detail=stats.last_error, + directory=directory, + ) + + # -------------------------------------------------------------- settings + def _sync_settings_action(self) -> None: + action = getattr(self, "act_settings", None) + if action is None: + return + expanded = self.toolbar.settings_expanded() + action.blockSignals(True) + action.setChecked(expanded) + action.blockSignals(False) + + def _set_settings_expanded(self, expanded: bool, *, persist: bool = True) -> None: + self.toolbar.set_settings_expanded(bool(expanded)) + self.prefs["settings_expanded"] = bool(expanded) + self._sync_settings_action() + if persist: + self._persist_prefs() + + def _on_settings_toggled(self, expanded: bool) -> None: + self._set_settings_expanded(expanded) + + def _on_settings_action_toggled(self, expanded: bool) -> None: + self._set_settings_expanded(expanded) + + # ------------------------------------------------------------------ nav + def _on_live_requested(self) -> None: + self._apply_view_preset("live") + + def _on_replay_requested(self) -> None: + self._apply_view_preset("replay") + + def _on_calibration_requested(self) -> None: + self._apply_view_preset("calibration") + + def _set_theme(self, theme: str) -> None: self._theme = theme self.prefs["theme"] = theme @@ -823,7 +1066,11 @@ class StudioMainWindow(QMainWindow): self.force_plots.set_theme(theme) self.cal_panel.set_theme(theme) self.brand_bar.set_theme(theme) + self.toolbar.set_theme(theme) + self.arrow_panel.set_theme(theme) self._refresh_status_chip() + self._refresh_status_rail() + self._refresh_record_state() self._sync_menu_checks() self._persist_prefs() @@ -936,9 +1183,21 @@ class StudioMainWindow(QMainWindow): self.force_plots.set_fixed_newton(self.toolbar.units() == "newton") def _on_cal_panel_toggled(self, visible: bool) -> None: - self._show_cal = bool(visible) - self.prefs["show_cal_panel"] = self._show_cal + """The View-menu toggle *is* the calibration view switch. + + Turning the panel on enters the calibration view (so it gets the width + its table needs); turning it off returns to the live view rather than + leaving a hidden panel behind a highlighted nav item. + """ + if visible: + self._apply_view_preset("calibration") + return + self._view_preset = "live" + self._show_cal = False + self.prefs["view_preset"] = "live" + self.prefs["show_cal_panel"] = False self._apply_panel_visibility() + self.brand_bar.set_active_view("live") self._persist_prefs() def _on_calibration_exported(self, path: str) -> None: @@ -1111,9 +1370,8 @@ class StudioMainWindow(QMainWindow): def _on_show_fps_toggled(self, visible: bool) -> None: self._show_fps = bool(visible) self.prefs["show_fps"] = self._show_fps - self.arrow_panel.set_fps_visible(self._show_fps) - if not self._show_fps: - self.arrow_panel.set_fps(None) + # FPS has one home now: the status rail. + self._refresh_status_rail() self._persist_prefs() def _on_sensor_type_changed(self, sensor_type: str) -> None: @@ -1132,6 +1390,22 @@ class StudioMainWindow(QMainWindow): enabled = self.sensor_picker.sensor_type() in ARROW_ROTATE_TYPES self.arrow_panel.set_rotation_enabled(enabled) self.act_rotate_arrows.setEnabled(enabled) + self._apply_arrow_legend() + + def _draw_style(self) -> str: + """Which arrow draw style the configured sensor uses.""" + return "finger" if self.sensor_picker.sensor_type() == "finger" else "box" + + def _apply_arrow_legend(self) -> None: + """The arrow legend describes the *configured* sensor, from the start. + + The magnification is a property of the draw style, not of the first + frame that happens to arrive, so a finger sensor must not advertise the + box magnification while it is still idle. + """ + from orisys_app_shared.acquisition.arrows import arrow_display_scale + + self.arrow_panel.set_arrow_scale(arrow_display_scale(self._draw_style())) def _on_rotate_arrows(self) -> None: if self.sensor_picker.sensor_type() not in ARROW_ROTATE_TYPES: @@ -1340,6 +1614,10 @@ class StudioMainWindow(QMainWindow): self._active_calibration_id = "" self._record_error_count = 0 self._dropped_reported = 0 + self._last_display_fallback = None + self._last_display_available = None + self._last_sample_valid = None + self._latest_fps = None self._depth_range_value = None self._last_depth_frame = None self.force_plots.set_store(self.session_store) @@ -1377,6 +1655,8 @@ class StudioMainWindow(QMainWindow): self._run_timer.start() # Keep the stop affordance consistent with the phase we just entered. self._update_run_enabled() + self._refresh_status_rail() + self._refresh_record_state() self.statusBar().showMessage(t("status.connecting")) logger.info( "Connecting type=%s source=%s config=%s", @@ -1406,8 +1686,12 @@ class StudioMainWindow(QMainWindow): keep_frame = reason in ("ended", "disconnected") if not keep_frame: self.arrow_panel.clear_preview(t("panels.arrow_empty")) - else: - self.arrow_panel.set_fps(None) + self._latest_fps = None + self._last_sample_valid = None + self._last_display_fallback = None + self._last_display_available = None + self._refresh_status_rail() + self._refresh_record_state() winding_down = self._session_active if reason == "ended": self.statusBar().showMessage(t("status.video_ended")) @@ -1525,6 +1809,9 @@ class StudioMainWindow(QMainWindow): ) if display.using_raw_fallback: self.statusBar().showMessage(t("status.units_raw_fallback")) + self._last_sample_valid = True + self._last_display_fallback = bool(display.using_raw_fallback) + self._last_display_available = tuple(bool(flag) for flag in display.available) self.force_plots.push( # Session-relative time, the same base the store and # ``forces.csv`` use. The wall clock must never reach the @@ -1537,6 +1824,7 @@ class StudioMainWindow(QMainWindow): available=display.available, ) else: + self._last_sample_valid = False self._report_invalid_sample(record.invalid_reason) # The calibration panel is a *view*: sampling is driven by the # record channel, so hiding the panel can no longer stall it. @@ -1556,8 +1844,13 @@ class StudioMainWindow(QMainWindow): mode = self.arrow_panel.view_mode() if is_new or mode != self._last_rendered_view_mode: self._render_run_image(packet) - if self._show_fps and is_new: - self.arrow_panel.set_fps(packet.fps) + if is_new: + self._latest_fps = getattr(packet, "fps", None) + # The rail carries the live telemetry (fps, validity, calibration) and + # the record strip its own numbers. Both compare before repainting, so + # this is safe on a 16 ms timer. + self._refresh_status_rail() + self._refresh_record_state() def _session_time(self, packet) -> float: """Session-relative acquisition time for the live view. @@ -1629,6 +1922,7 @@ class StudioMainWindow(QMainWindow): if packet is None: return from orisys_app_shared.acquisition.arrows import ( + arrow_display_scale, depth_map_to_bgr, draw_arrow_display, flow_colormap, @@ -1637,7 +1931,12 @@ class StudioMainWindow(QMainWindow): mode = self.arrow_panel.view_mode() self._last_rendered_view_mode = mode - draw_style = "finger" if self._active_sensor_type == "finger" else "box" + draw_style = self._draw_style() + # The legend is decided by the same inputs as the picture: the arrow + # magnification of this sensor's draw style, and whether this frame + # carries a valid-area mask. + self.arrow_panel.set_arrow_scale(arrow_display_scale(draw_style)) + self.arrow_panel.set_valid_area(getattr(packet, "valid_mask", None) is not None) colormap = flow_colormap(self._theme) if mode == "depth": self._last_depth_frame = packet.depth @@ -1706,6 +2005,8 @@ class StudioMainWindow(QMainWindow): self.prefs["show_cal_panel"] = bool(self._show_cal) self.prefs["show_finger_3d"] = bool(self._show_finger_3d) self.prefs["show_fps"] = bool(self._show_fps) + self.prefs["settings_expanded"] = self.toolbar.settings_expanded() + self.prefs["view_preset"] = self._view_preset self.prefs["sensor_type"] = self.sensor_picker.sensor_type() self.prefs["config_name"] = self.sensor_picker.config_name() save_preferences(self.prefs) diff --git a/apps/studio/src/orisys_studio/main.py b/apps/studio/src/orisys_studio/main.py index df4cef6..f7e00f2 100644 --- a/apps/studio/src/orisys_studio/main.py +++ b/apps/studio/src/orisys_studio/main.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import os import sys from PyQt5.QtWidgets import QApplication @@ -49,7 +50,24 @@ def main() -> int: window = StudioMainWindow(prefs=prefs) window.show() logger.info("Studio shell started (theme=%s, language=%s)", theme, language) - return int(app.exec_()) + code = int(app.exec_()) + + # Leave the process immediately. + # + # The device SDK and OpenCV keep *native* threads and handles of their own; + # a normal return would run interpreter shutdown on top of them, and the + # process then sat there after the window closed ("the console never came + # back"). Everything that must be durable was already flushed by + # ``StudioMainWindow.closeEvent`` and by the log handlers below, so the only + # thing skipped here is teardown of libraries that no longer have anything + # to write. + logging.shutdown() + for stream in (sys.stdout, sys.stderr): + try: + stream.flush() + except Exception: # noqa: BLE001 - a closed stream must not block exit + pass + os._exit(code) if __name__ == "__main__": diff --git a/apps/studio/src/orisys_studio/ui/arrow_panel.py b/apps/studio/src/orisys_studio/ui/arrow_panel.py index d1e875b..d65cf7b 100644 --- a/apps/studio/src/orisys_studio/ui/arrow_panel.py +++ b/apps/studio/src/orisys_studio/ui/arrow_panel.py @@ -2,8 +2,8 @@ from __future__ import annotations -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QImage, QPixmap, QTransform +from PyQt5.QtCore import QSize, Qt, pyqtSignal +from PyQt5.QtGui import QImage, QPainter, QPixmap, QTransform from PyQt5.QtWidgets import ( QComboBox, QFrame, @@ -14,9 +14,23 @@ from PyQt5.QtWidgets import ( ) from orisys_app_shared.i18n import t +from orisys_app_shared.theme import color + +from orisys_studio.ui.fit_text import ShrinkableLabel VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth") _ROTATION_STEPS = (0, 90, 180, 270) +_COLORBAR_WIDTH = 120 +_COLORBAR_HEIGHT = 10 +#: Sensor output is 1920x1080; used until the first frame states its own shape. +DEFAULT_FRAME_ASPECT = 16.0 / 9.0 +_MIN_ASPECT = 0.2 +_MAX_ASPECT = 5.0 +#: Header + footer + margins, i.e. what the image must leave room for. +_IMAGE_CHROME_HEIGHT = 96 +#: Smallest usable image area. Kept as a constant because ``setFixedHeight`` +#: overwrites ``minimumHeight``, so the floor cannot be read back from the label. +_IMAGE_MIN_HEIGHT = 120 def _normalize_rotation_deg(value: object, default: int = 90) -> int: @@ -29,50 +43,147 @@ def _normalize_rotation_deg(value: object, default: int = 90) -> int: return deg -class ArrowPanel(QFrame): - view_mode_changed = pyqtSignal(str) +class AspectImageLabel(QLabel): + """Image area whose aspect ratio follows the frame it displays. + + A free-growing label turned a 16:9 frame into a tall letterbox with wide + empty bands. Here the height is derived from the width and the frame's own + aspect, so the 2D view always has the shape of the picture it shows. + """ def __init__(self, parent=None) -> None: + super().__init__(parent) + self._aspect = DEFAULT_FRAME_ASPECT + policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + policy.setHeightForWidth(True) + self.setSizePolicy(policy) + self.setMinimumSize(200, _IMAGE_MIN_HEIGHT) + + def aspect(self) -> float: + return self._aspect + + def set_aspect(self, aspect: float) -> None: + try: + value = float(aspect) + except (TypeError, ValueError): + return + if not (_MIN_ASPECT <= value <= _MAX_ASPECT): + return + if abs(value - self._aspect) < 1e-6: + return + self._aspect = value + self.updateGeometry() + + def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt naming + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt naming + return max(1, int(round(int(width) / self._aspect))) + + +class ColorBarLabel(QLabel): + """The depth view's colour strip, painted from the theme's flow ramp. + + It is generated from the *same* lookup table ``depth_map_to_bgr`` uses, so + the legend can never disagree with the picture it explains. + """ + + def __init__(self, parent=None, *, theme: str = "dark") -> None: + super().__init__(parent) + self._theme = str(theme) + self.setObjectName("colorBar") + self.setFixedHeight(_COLORBAR_HEIGHT) + self.setMinimumWidth(_COLORBAR_WIDTH) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.setVisible(False) + + def set_theme(self, theme: str) -> None: + self._theme = str(theme) + self.update() + + def paintEvent(self, event) -> None: # noqa: N802 - Qt naming + from orisys_app_shared.acquisition.arrows import flow_colormap + + # (256, 1, 3) BGR -> one RGB scanline. The bytes object stays alive for + # the whole call, which is what QImage's borrowed buffer needs. + rgb = flow_colormap(self._theme)[:, 0, ::-1].copy().tobytes() + image = QImage(rgb, 256, 1, 256 * 3, QImage.Format_RGB888) + painter = QPainter(self) + painter.drawImage(self.rect(), image) + painter.end() + + +class ArrowPanel(QFrame): + view_mode_changed = pyqtSignal(str) + + def __init__(self, parent=None, *, theme: str = "dark") -> None: super().__init__(parent) self.setObjectName("contentPanel") + self._theme = str(theme) layout = QVBoxLayout(self) header = QHBoxLayout() self.title = QLabel() self.title.setObjectName("panelTitle") - self.fps_label = QLabel() - self.fps_label.setObjectName("panelMeta") - self.fps_label.setVisible(False) - # Shows the fixed depth colour-scale range, so two frames can be - # compared instead of the scale silently re-fitting every frame. - self.depth_scale_label = QLabel() - self.depth_scale_label.setObjectName("panelMeta") - self.depth_scale_label.setVisible(False) - self._fps_visible = False - self._fps_value: float | None = None self._rotation_deg = 90 self._rotation_enabled = True self._source_image: QImage | None = None + self._depth_scale_text = "" + self._arrow_scale = 2.0 + self._valid_area = False self.view_combo = QComboBox() self.view_combo.setMinimumWidth(140) self.view_combo.currentIndexChanged.connect(self._on_view_index_changed) header.addWidget(self.title) header.addStretch(1) - header.addWidget(self.depth_scale_label) - header.addWidget(self.fps_label) header.addWidget(self.view_combo) layout.addLayout(header) - self.image_label = QLabel() + self.image_label = AspectImageLabel() self.image_label.setAlignment(Qt.AlignCenter) - self.image_label.setMinimumSize(240, 180) - self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.image_label.setScaledContents(False) self.image_label.setWordWrap(True) - layout.addWidget(self.image_label, 1) + # The image keeps the frame's shape and is centred in whatever height is + # left, instead of stretching to fill a panel with a different aspect. + layout.addStretch(1) + layout.addWidget(self.image_label) + layout.addStretch(1) + + layout.addLayout(self._build_footer()) + self._placeholder = "" self._populate_view_combo() self.retranslate() + self._refresh_footer_visibility() + self._sync_aspect() + + def _build_footer(self) -> QHBoxLayout: + """What the current view means, in one line under the canvas.""" + footer = QHBoxLayout() + footer.setSpacing(10) + + def annotation() -> QLabel: + label = ShrinkableLabel() + label.setObjectName("hintText") + return label + + self.scale_label = annotation() + self.pipeline_label = annotation() + self.area_label = annotation() + # Shows the fixed depth colour-scale range, so two frames can be + # compared instead of the scale silently re-fitting every frame. + self.depth_scale_label = QLabel() + self.depth_scale_label.setObjectName("panelMeta") + self.depth_scale_label.setVisible(False) + self.colorbar = ColorBarLabel(theme=self._theme) + + footer.addWidget(self.scale_label) + footer.addWidget(self.pipeline_label) + footer.addWidget(self.area_label) + footer.addStretch(1) + footer.addWidget(self.colorbar) + footer.addWidget(self.depth_scale_label) + return footer def view_mode(self) -> str: data = self.view_combo.currentData() @@ -88,12 +199,14 @@ class ArrowPanel(QFrame): self.view_combo.blockSignals(True) self.view_combo.setCurrentIndex(idx) self.view_combo.blockSignals(False) + self._refresh_footer_visibility() def rotation_deg(self) -> int: return self._rotation_deg def set_rotation_deg(self, deg: object) -> None: self._rotation_deg = _normalize_rotation_deg(deg) + self._sync_aspect() self._repaint_source() def set_rotation_enabled(self, enabled: bool) -> None: @@ -101,39 +214,79 @@ class ArrowPanel(QFrame): if wanted == self._rotation_enabled: return self._rotation_enabled = wanted + self._sync_aspect() self._repaint_source() + def _sync_aspect(self) -> None: + """Re-derive the image area's shape from the frame (or from the default). + + Rotating the picture swaps which side is long, so the area has to follow + the rotation as well as the frame. + """ + image = self._source_image + if image is not None and not image.isNull(): + self._set_display_aspect(image.width(), image.height()) + return + self.image_label.set_aspect(DEFAULT_FRAME_ASPECT) + if self._effective_rotation_deg() % 180: + self.image_label.set_aspect(1.0 / DEFAULT_FRAME_ASPECT) + self._sync_image_height() + + def _set_display_aspect(self, frame_w: int, frame_h: int) -> None: + if frame_w <= 0 or frame_h <= 0: + return + aspect = frame_w / frame_h + if self._effective_rotation_deg() % 180: + aspect = 1.0 / aspect + self.image_label.set_aspect(aspect) + self._sync_image_height() + def rotate_90_ccw(self) -> int: if not self._rotation_enabled: return self._rotation_deg self.set_rotation_deg(self._rotation_deg + 90) return self._rotation_deg - def set_fps_visible(self, visible: bool) -> None: - self._fps_visible = bool(visible) - self._refresh_fps_label() + def set_arrow_scale(self, factor: float) -> None: + """Show how much the drawn arrows are magnified relative to displacement.""" + try: + self._arrow_scale = float(factor) + except (TypeError, ValueError): + self._arrow_scale = 1.0 + self._refresh_scale_label() - def set_fps(self, fps: float | None) -> None: - self._fps_value = None if fps is None else float(fps) - self._refresh_fps_label() + def set_valid_area(self, present: bool) -> None: + """Say whether the flow grid is masked to the configured valid area.""" + self._valid_area = bool(present) + self.area_label.setText( + t("panels.arrow_area_masked") if self._valid_area else t("panels.arrow_area_full") + ) + + def set_theme(self, theme: str) -> None: + self._theme = str(theme) + self.colorbar.set_theme(self._theme) def set_depth_scale(self, text: str | None) -> None: """Show the fixed depth colour-scale range (``None`` hides it).""" - if not text: - self.depth_scale_label.clear() - self.depth_scale_label.setVisible(False) - return - self.depth_scale_label.setText(str(text)) + self._depth_scale_text = str(text or "") + self.depth_scale_label.setText(self._depth_scale_text) self.depth_scale_label.setToolTip(t("panels.depth_scale_tip")) - self.depth_scale_label.setVisible(True) + self._refresh_footer_visibility() - def _refresh_fps_label(self) -> None: - if not self._fps_visible or self._fps_value is None: - self.fps_label.clear() - self.fps_label.setVisible(False) - return - self.fps_label.setText(f"{self._fps_value:.1f} FPS") - self.fps_label.setVisible(True) + def _refresh_scale_label(self) -> None: + self.scale_label.setText( + t("panels.arrow_scale", factor=f"{self._arrow_scale:g}") + ) + self.scale_label.setToolTip(t("panels.arrow_scale_tip")) + + def _refresh_footer_visibility(self) -> None: + """Arrow annotations and the depth legend are mutually exclusive.""" + depth = self.view_mode() == "depth" + self.scale_label.setVisible(not depth) + self.pipeline_label.setVisible(not depth) + self.area_label.setVisible(not depth) + self.colorbar.setVisible(depth) + self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text)) def _populate_view_combo(self) -> None: current = self.view_combo.currentData() @@ -147,6 +300,7 @@ class ArrowPanel(QFrame): self.view_combo.blockSignals(False) def _on_view_index_changed(self, _index: int) -> None: + self._refresh_footer_visibility() self.view_mode_changed.emit(self.view_mode()) def retranslate(self) -> None: @@ -161,6 +315,10 @@ class ArrowPanel(QFrame): self.view_combo.setItemText(i, label) self.view_combo.setToolTip(t("panels.view_mode_tip")) self.image_label.setToolTip(t("panels.arrow_empty_tip")) + self.pipeline_label.setText(t("panels.arrow_pipeline")) + self.pipeline_label.setToolTip(t("panels.arrow_pipeline_tip")) + self._refresh_scale_label() + self.set_valid_area(self._valid_area) if not self.image_label.pixmap() or self.image_label.pixmap().isNull(): self._placeholder = t("panels.arrow_empty") self.image_label.setText(self._placeholder) @@ -170,6 +328,24 @@ class ArrowPanel(QFrame): return 0 return self._rotation_deg + def resizeEvent(self, event) -> None: # noqa: N802 - Qt naming + super().resizeEvent(event) + self._sync_image_height() + + def _sync_image_height(self) -> None: + """Give the image area the height its own aspect ratio asks for. + + A horizontal splitter hands its children the full available height and + never asks them for ``heightForWidth``, so the aspect ratio has to be + applied here or the 16:9 frame stretches into a tall letterbox. + """ + label = self.image_label + wanted = label.heightForWidth(max(1, label.width())) + room = max(_IMAGE_MIN_HEIGHT, self.height() - _IMAGE_CHROME_HEIGHT) + height = max(_IMAGE_MIN_HEIGHT, min(wanted, room)) + if label.maximumHeight() != height: + label.setFixedHeight(height) + def _repaint_source(self) -> None: if self._source_image is None or self._source_image.isNull(): return @@ -177,6 +353,8 @@ class ArrowPanel(QFrame): def _paint_image(self, image: QImage) -> None: pix = QPixmap.fromImage(image) + # The area takes the shape of the *displayed* picture. + self._set_display_aspect(image.width(), image.height()) deg = self._effective_rotation_deg() if deg: # QTransform.rotate is clockwise on screen pixmaps; negate for CCW. @@ -202,4 +380,3 @@ class ArrowPanel(QFrame): text = message if message is not None else t("panels.arrow_empty") self._placeholder = text self.image_label.setText(text) - self.set_fps(None) diff --git a/apps/studio/src/orisys_studio/ui/brand_bar.py b/apps/studio/src/orisys_studio/ui/brand_bar.py index e7af339..a430730 100644 --- a/apps/studio/src/orisys_studio/ui/brand_bar.py +++ b/apps/studio/src/orisys_studio/ui/brand_bar.py @@ -1,8 +1,12 @@ """Brand bar shown at the top of the studio shell. -A slim deep-navy rail carrying the corporate logo lockup and the product name. -Logo colours are re-tinted at runtime so a single asset works on the brand -background in both themes; all other colours come from the theme tokens. +A slim deep-navy rail carrying the corporate logo lockup, the product name and +the three top-level views (live / replay / calibration). Logo colours are +re-tinted at runtime so a single asset works on the brand background in both +themes; all other colours come from the theme tokens. + +The rail is deliberately thin (``BAR_HEIGHT``): the instrument's own state lives +in the status rail below it, not here. """ from __future__ import annotations @@ -10,9 +14,9 @@ from __future__ import annotations import logging from pathlib import Path -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QColor, QPainter, QPixmap -from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout from orisys_app_shared.i18n import t from orisys_app_shared.paths import logo_topbar_path @@ -20,9 +24,9 @@ from orisys_app_shared.theme import color logger = logging.getLogger(__name__) -LOGO_SIZE = 22 -BAR_HEIGHT = 40 -DIVIDER_HEIGHT = 18 +LOGO_SIZE = 16 +BAR_HEIGHT = 28 +DIVIDER_HEIGHT = 14 def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap: @@ -48,7 +52,14 @@ def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap class BrandBar(QFrame): - """Thin brand rail: logo lockup on the left, product name on the right.""" + """Thin brand rail: lockup and top-level view switcher, nothing else.""" + + #: The user asked for the live view. + live_requested = pyqtSignal() + #: The user asked for the history/replay view. + playback_requested = pyqtSignal() + #: The user asked for the calibration view. + calibration_requested = pyqtSignal() def __init__(self, parent=None, *, theme: str = "dark") -> None: super().__init__(parent) @@ -57,8 +68,8 @@ class BrandBar(QFrame): self.setFixedHeight(BAR_HEIGHT) layout = QHBoxLayout(self) - layout.setContentsMargins(14, 0, 14, 0) - layout.setSpacing(10) + layout.setContentsMargins(10, 0, 10, 0) + layout.setSpacing(8) self.logo = QLabel() self.logo.setObjectName("brandLogo") @@ -66,6 +77,8 @@ class BrandBar(QFrame): self.logo.setAlignment(Qt.AlignCenter) layout.addWidget(self.logo) + # The lockup keeps its two labels (the product's tagline stays available + # as a tooltip), but the rail is a single line now. lockup = QVBoxLayout() lockup.setContentsMargins(0, 0, 0, 0) lockup.setSpacing(0) @@ -73,10 +86,24 @@ class BrandBar(QFrame): self.name.setObjectName("brandName") self.tagline = QLabel() self.tagline.setObjectName("brandTagline") + self.tagline.setVisible(False) lockup.addWidget(self.name) lockup.addWidget(self.tagline) layout.addLayout(lockup) + self.btn_live = self._nav_button() + self.btn_playback = self._nav_button() + self.btn_calibration = self._nav_button() + self.btn_live.clicked.connect(lambda _checked=False: self.live_requested.emit()) + self.btn_playback.clicked.connect(lambda _checked=False: self.playback_requested.emit()) + self.btn_calibration.clicked.connect( + lambda _checked=False: self.calibration_requested.emit() + ) + layout.addSpacing(4) + layout.addWidget(self.btn_live) + layout.addWidget(self.btn_playback) + layout.addWidget(self.btn_calibration) + layout.addStretch(1) self.divider = QFrame() @@ -91,6 +118,15 @@ class BrandBar(QFrame): self.set_theme(theme) self.retranslate() + self.set_active_view("live") + + def _nav_button(self) -> QPushButton: + button = QPushButton() + button.setObjectName("navButton") + button.setCursor(Qt.PointingHandCursor) + button.setFlat(True) + button.setCheckable(True) + return button def _refresh_logo(self) -> None: pixmap = tinted_pixmap( @@ -104,6 +140,24 @@ class BrandBar(QFrame): self.logo.setPixmap(pixmap) self.logo.show() + def set_active_view(self, view: str | None) -> None: + """Mark which top-level view is on screen. + + ``None`` leaves all three unmarked: a hand-arranged layout is not one of + the three views, and pretending otherwise would misreport the state. + """ + active = str(view or "") + for button, name in ( + (self.btn_live, "live"), + (self.btn_playback, "replay"), + (self.btn_calibration, "calibration"), + ): + is_active = name == active + button.setChecked(is_active) + button.setProperty("navActive", "true" if is_active else "false") + button.style().unpolish(button) + button.style().polish(button) + def set_theme(self, theme: str) -> None: self._theme = str(theme) self._refresh_logo() @@ -112,4 +166,10 @@ class BrandBar(QFrame): self.name.setText(t("brand.name")) self.tagline.setText(t("brand.tagline")) self.product.setText(t("app.title")) - self.name.setToolTip(t("app.title")) + self.name.setToolTip(f"{t('app.title')} — {t('brand.tagline')}") + self.btn_live.setText(t("brand.nav_live")) + self.btn_live.setToolTip(t("brand.nav_live_tip")) + self.btn_playback.setText(t("brand.nav_playback")) + self.btn_playback.setToolTip(t("brand.nav_playback_tip")) + self.btn_calibration.setText(t("brand.nav_calibration")) + self.btn_calibration.setToolTip(t("brand.nav_calibration_tip")) diff --git a/apps/studio/src/orisys_studio/ui/cal_panel.py b/apps/studio/src/orisys_studio/ui/cal_panel.py index 53b3164..da5cc3f 100644 --- a/apps/studio/src/orisys_studio/ui/cal_panel.py +++ b/apps/studio/src/orisys_studio/ui/cal_panel.py @@ -20,10 +20,12 @@ from PyQt5.QtWidgets import ( QProgressBar, QPushButton, QRadioButton, + QScrollArea, QSizePolicy, QTableWidget, QTableWidgetItem, QVBoxLayout, + QWidget, ) from orisys_app_shared.force.calibration_io import ( @@ -47,7 +49,6 @@ CHANNEL_LABELS = ("fn", "fx", "fy") #: Diagnostic columns, in display order: reference load, the three axis means, #: a stability measure and the point's state. DIAGNOSTIC_COLUMNS = ("load", "fn", "fx", "fy", "stability", "state") -DIAGNOSTIC_COMPACT_COLUMNS = (0, 4, 5) def _mono_font() -> str: @@ -88,7 +89,23 @@ class CalPanel(QFrame): self._mono = _mono_font() self._mono_font = QFont(self._mono, 10) - root = QVBoxLayout(self) + # Everything below the title scrolls: this panel has more content than + # a short window can show (a six-column table, the fit summary, the SN + # field and nine actions), and a clipped button is a lost function. + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll.setFrameShape(QFrame.NoFrame) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + content = QWidget() + self.scroll.setWidget(content) + outer.addWidget(self.scroll) + + root = QVBoxLayout(content) + root.setContentsMargins(9, 9, 9, 9) root.setSpacing(8) title_row = QHBoxLayout() @@ -171,11 +188,14 @@ class CalPanel(QFrame): self.table.setAlternatingRowColors(True) self.table.setShowGrid(False) header = self.table.horizontalHeader() - header.setSectionResizeMode(QHeaderView.Stretch) - for column in DIAGNOSTIC_COMPACT_COLUMNS: - header.setSectionResizeMode(column, QHeaderView.ResizeToContents) + # Every column takes the width its own header and cells need: eliding a + # diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow + # panel scrolls instead. + header.setSectionResizeMode(QHeaderView.ResizeToContents) + header.setMinimumSectionSize(64) self.table.verticalHeader().setVisible(False) self.table.verticalHeader().setDefaultSectionSize(26) + self.table.setMinimumHeight(150) self.table.itemSelectionChanged.connect(self._on_row_selected) self.table.doubleClicked.connect(self._on_row_activated) root.addWidget(self.table, 1) @@ -194,8 +214,6 @@ class CalPanel(QFrame): sn_row.addWidget(self.sn_edit, 1) root.addLayout(sn_row) - actions = QHBoxLayout() - actions.setSpacing(6) self.btn_delete = QPushButton() self.btn_clear_channel = QPushButton() self.btn_clear_all = QPushButton() @@ -227,17 +245,26 @@ class CalPanel(QFrame): self.btn_save_record.clicked.connect(self._on_save_record) self.btn_export.clicked.connect(self._on_export) self.btn_apply.clicked.connect(self._on_apply) - actions.addWidget(self.btn_toggle_excluded) - actions.addWidget(self.btn_toggle_validation) - actions.addWidget(self.btn_delete) - actions.addWidget(self.btn_clear_channel) - actions.addWidget(self.btn_clear_all) - actions.addStretch(1) - actions.addWidget(self.btn_open_record) - actions.addWidget(self.btn_save_record) - actions.addWidget(self.btn_export) - actions.addWidget(self.btn_apply) - root.addLayout(actions) + # Two rows, not nine buttons in a row: in one line every label was + # squeezed until it was unreadable ("排 除 / 设 为 验 证 点"). + selection_actions = QHBoxLayout() + selection_actions.setSpacing(6) + selection_actions.addWidget(self.btn_toggle_excluded) + selection_actions.addWidget(self.btn_toggle_validation) + selection_actions.addWidget(self.btn_delete) + selection_actions.addWidget(self.btn_clear_channel) + selection_actions.addWidget(self.btn_clear_all) + selection_actions.addStretch(1) + root.addLayout(selection_actions) + + file_actions = QHBoxLayout() + file_actions.setSpacing(6) + file_actions.addStretch(1) + file_actions.addWidget(self.btn_open_record) + file_actions.addWidget(self.btn_save_record) + file_actions.addWidget(self.btn_export) + file_actions.addWidget(self.btn_apply) + root.addLayout(file_actions) self._refresh_enabled() self._refresh_table() diff --git a/apps/studio/src/orisys_studio/ui/fit_text.py b/apps/studio/src/orisys_studio/ui/fit_text.py new file mode 100644 index 0000000..9250be5 --- /dev/null +++ b/apps/studio/src/orisys_studio/ui/fit_text.py @@ -0,0 +1,26 @@ +"""Labels that may be squeezed below their text width. + +A ``QLabel`` reports its text width as a *minimum*, so one long annotation can +decide the minimum width of a whole panel (or of the top rail) and push its +neighbours out of view. These labels keep their size hint — they ask for the +space their text needs — but allow the layout to shrink them, at which point Qt +clips the text and the tooltip remains the way to read it. +""" + +from __future__ import annotations + +from PyQt5.QtCore import QSize +from PyQt5.QtWidgets import QLabel, QSizePolicy + + +class ShrinkableLabel(QLabel): + """A label whose minimum width is 0 (the text is clipped when squeezed).""" + + def __init__(self, text: str = "", parent=None) -> None: + super().__init__(text, parent) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + self.setMinimumWidth(0) + + def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming + hint = super().minimumSizeHint() + return QSize(0, hint.height()) diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index c80d84b..afafbdf 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -6,7 +6,7 @@ import time import numpy as np from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QColor, QFont, QFontDatabase +from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics from PyQt5.QtWidgets import ( QFrame, QHBoxLayout, @@ -34,6 +34,22 @@ CHANNEL_LABELS = ("FN", "FX", "FY") CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy") #: How often the whole-session overview strip is rebuilt while following live. OVERVIEW_REFRESH_S = 1.0 +#: Grid opacity. The curves are the data; a full-strength grid competed with +#: them on a dark background. +GRID_ALPHA = 0.10 +OVERVIEW_GRID_ALPHA = 0.08 +#: Opacity of the draggable window marker over the overview strip. +WINDOW_BRUSH_ALPHA = 70 +#: Point size of the axis readings. They are the number an operator reads from +#: across the bench, so they are not the body-text size. +VALUE_FONT_PT = 16 + + +def _window_brush(theme: str): + """Translucent accent wash for the overview's draggable window.""" + accent = QColor(color(theme, "accent")) + accent.setAlpha(WINDOW_BRUSH_ALPHA) + return accent def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]: @@ -155,6 +171,29 @@ class ForcePlotsPanel(QFrame): header.addWidget(self.unit_badge) root.addLayout(header) + # The three axis readouts sit *above* the curves: they are what an + # operator reads at a glance, and the numbers must not be the last thing + # under a stack of plots. + self.value_group = QFrame() + self.value_group.setObjectName("valueGroup") + values_row = QHBoxLayout(self.value_group) + values_row.setContentsMargins(2, 0, 2, 0) + values_row.setSpacing(14) + self.value_cards: list[QFrame] = [] + self.value_labels: list[QLabel] = [] + self.channel_badges: list[QLabel] = [] + self.channel_names: list[QLabel] = [] + self.value_units: list[QLabel] = [] + for label in CHANNEL_LABELS: + card, badge, name_label, value_label, unit_label = self._build_value_card(label) + values_row.addWidget(card, 1) + self.value_cards.append(card) + self.channel_badges.append(badge) + self.channel_names.append(name_label) + self.value_labels.append(value_label) + self.value_units.append(unit_label) + root.addWidget(self.value_group) + self.plot_frame = QFrame() self.plot_frame.setObjectName("cardPanel") plot_layout = QVBoxLayout(self.plot_frame) @@ -170,14 +209,17 @@ class ForcePlotsPanel(QFrame): pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2) for i, _name in enumerate(CHANNEL_LABELS): plot = self.plot_widget.addPlot(row=i, col=0) - plot.showGrid(x=True, y=True, alpha=0.25) + plot.showGrid(x=True, y=True, alpha=GRID_ALPHA) plot.setMouseEnabled(x=False, y=False) if i > 0: plot.setXLink(self.plots[0]) curve = plot.plot(pen=pen) # One shared time cursor across all three channels. cursor = pg.InfiniteLine( - pos=0.0, angle=90, movable=False, pen=pg.mkPen("#888888", width=1) + pos=0.0, + angle=90, + movable=False, + pen=pg.mkPen(color(theme, "text_muted"), width=1), ) plot.addItem(cursor, ignoreBounds=True) plot.getViewBox().sigXRangeChanged.connect(self._on_x_range_changed) @@ -188,80 +230,99 @@ class ForcePlotsPanel(QFrame): # Whole-session overview: the full envelope plus the current window. self.overview = self.plot_widget.addPlot(row=len(CHANNEL_LABELS), col=0) self.overview.setMouseEnabled(x=False, y=False) - self.overview.showGrid(x=True, y=False, alpha=0.2) + self.overview.showGrid(x=True, y=False, alpha=OVERVIEW_GRID_ALPHA) self.overview.setMaximumHeight(46) self.overview.getAxis("left").setStyle(showValues=False) - self.overview_curve = self.overview.plot(pen=pg.mkPen("#8A93A6", width=1)) + self.overview_curve = self.overview.plot( + pen=pg.mkPen(color(theme, "text_muted"), width=1) + ) + # Dragging the region *is* the timeline control: it moves the visible + # window instead of a separate slider the charts would have to follow. self.overview_region = pg.LinearRegionItem( - values=(0.0, 1.0), movable=False, brush=(90, 110, 190, 60) + values=(0.0, 1.0), movable=True, brush=_window_brush(theme) ) self.overview_region.setZValue(-10) + self.overview_region.sigRegionChanged.connect(self._on_overview_region_changed) self.overview.addItem(self.overview_region) self.overview_cursor = pg.InfiniteLine( - pos=0.0, angle=90, movable=False, pen=pg.mkPen("#888888", width=1) + pos=0.0, + angle=90, + movable=False, + pen=pg.mkPen(color(theme, "text_muted"), width=1), ) self.overview.addItem(self.overview_cursor, ignoreBounds=True) for row in range(len(CHANNEL_LABELS)): self.plot_widget.ci.layout.setRowStretchFactor(row, 3) self.plot_widget.ci.layout.setRowStretchFactor(len(CHANNEL_LABELS), 1) + # The overview strip's own caption and readout, plus the selection text. + browse_row = QHBoxLayout() + browse_row.setSpacing(8) + self.window_caption = QLabel() + self.window_caption.setObjectName("panelTitle") + self.window_label = QLabel() + self.window_label.setObjectName("panelMeta") self.stats_label = QLabel() self.stats_label.setObjectName("panelMeta") self.stats_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - root.addWidget(self.stats_label) - - values_row = QHBoxLayout() - values_row.setSpacing(6) - self.value_cards: list[QFrame] = [] - self.value_labels: list[QLabel] = [] - self.channel_badges: list[QLabel] = [] - self.value_units: list[QLabel] = [] - for label in CHANNEL_LABELS: - card, badge, value_label, unit_label = self._build_value_card(label) - values_row.addWidget(card, 1) - self.value_cards.append(card) - self.channel_badges.append(badge) - self.value_labels.append(value_label) - self.value_units.append(unit_label) - root.addLayout(values_row) + browse_row.addWidget(self.window_caption) + browse_row.addWidget(self.window_label) + browse_row.addStretch(1) + browse_row.addWidget(self.stats_label) + root.addLayout(browse_row) self.set_theme(theme) self._interactive(False) self._apply_axis_mode() self.retranslate() - def _build_value_card(self, channel: str) -> tuple[QFrame, QLabel, QLabel, QLabel]: + def _build_value_card( + self, channel: str + ) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel]: + """One axis readout: channel badge + name over a large monospace value. + + The value is deliberately borderless and large — three boxed cards side + by side were part of what made the panel look fragmented. + """ card = QFrame() - card.setObjectName("cardPanel") + card.setObjectName("valueGroup") layout = QVBoxLayout(card) - layout.setContentsMargins(7, 4, 7, 4) - layout.setSpacing(1) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) top = QHBoxLayout() top.setSpacing(6) badge = QLabel(channel) badge.setObjectName("unitBadge") top.addWidget(badge) + name_label = QLabel() + name_label.setObjectName("hintText") + top.addWidget(name_label) top.addStretch(1) - unit_label = QLabel() - unit_label.setObjectName("hintText") - top.addWidget(unit_label) + layout.addLayout(top) + value_row = QHBoxLayout() + value_row.setSpacing(5) value_label = QLabel() value_label.setObjectName("valueCard") - value_label.setFixedHeight(20) - value_label.setContentsMargins(6, 0, 6, 0) value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - value_label.setFont(_mono_font(9, bold=True)) - - layout.addLayout(top) - layout.addWidget(value_label) - return card, badge, value_label, unit_label + value_label.setFont(_mono_font(16, bold=True)) + # Never a fixed height: the assertion that a value is not clipped is + # derived from the font it is actually drawn with. + value_label.setMinimumHeight(value_label.fontMetrics().height() + 4) + unit_label = QLabel() + unit_label.setObjectName("hintText") + value_row.addWidget(value_label) + value_row.addWidget(unit_label) + value_row.addStretch(1) + layout.addLayout(value_row) + return card, badge, name_label, value_label, unit_label def set_theme(self, theme: str) -> None: self._theme = theme bg = color(theme, "plot_bg") text = color(theme, "text_primary") + muted = color(theme, "text_muted") self.plot_widget.setBackground(bg) axis_pen = pg.mkPen(text) for i, plot in enumerate(self.plots): @@ -273,9 +334,17 @@ class ForcePlotsPanel(QFrame): axis = plot.getAxis(axis_name) axis.setPen(axis_pen) axis.setTextPen(axis_pen) - plot.showGrid(x=True, y=True, alpha=0.25) + plot.showGrid(x=True, y=True, alpha=GRID_ALPHA) + # One shared time axis: only the bottom plot names the seconds, so + # three stacked charts stop repeating the same ruler three times. + plot.getAxis("bottom").setStyle(showValues=i == len(self.plots) - 1) # Color each channel axis label to match its curve pen. plot.setLabel("left", CHANNEL_LABELS[i], color=color(theme, CHANNEL_TOKENS[i])) + self.overview.showGrid(x=True, y=False, alpha=OVERVIEW_GRID_ALPHA) + self.overview_curve.setPen(pg.mkPen(muted, width=1)) + self.overview_region.setBrush(_window_brush(theme)) + for cursor in (*self.cursors, self.overview_cursor): + cursor.setPen(pg.mkPen(muted, width=1)) pens = tuple(color(theme, token) for token in CHANNEL_TOKENS) for curve, pen in zip(self.curves, pens): curve.setPen(pg.mkPen(pen, width=2)) @@ -285,10 +354,20 @@ class ForcePlotsPanel(QFrame): f" background-color: {color(theme, 'card_bg')};" f" border: 1px solid {color(theme, 'card_border')}; }}" ) + # The app stylesheet sets a global widget font, and a matching stylesheet + # rule always wins over ``QWidget.setFont``. The readings therefore have + # to carry their monospace face *in the stylesheet*, or they silently + # fall back to the UI font and lose digit alignment. + mono = _mono_font(VALUE_FONT_PT, bold=True) + min_height = QFontMetrics(mono).height() + 4 for value_label, token in zip(self.value_labels, CHANNEL_TOKENS): + value_label.setFont(mono) value_label.setStyleSheet( - f"QLabel#valueCard {{ color: {color(theme, token)}; }}" + f"QLabel#valueCard {{ color: {color(theme, token)};" + f' font-family: "{mono.family()}"; font-size: {VALUE_FONT_PT}pt;' + " font-weight: 700; background: transparent; border: none; }" ) + value_label.setMinimumHeight(min_height) self._render_value_cards() def set_window_sec(self, sec: float) -> None: @@ -606,6 +685,7 @@ class ForcePlotsPanel(QFrame): first, last = self._source.extent() if last <= first: self.overview_curve.setData([], []) + self._refresh_window_label() return # The overview is a whole-session read, so only rebuild it when it can # have changed (window/unit/session) rather than on every pan. @@ -617,16 +697,56 @@ class ForcePlotsPanel(QFrame): finite = np.isfinite(column) self.overview_curve.setData(window.times[finite], column[finite]) self.overview.setXRange(first, last, padding=0.0) - if self._mode == "browse": - self.overview_region.setRegion(self._window) - self.overview_region.show() - else: - self.overview_region.hide() + self._syncing = True + try: + if self._mode == "browse": + self.overview_region.setRegion(self._window) + self.overview_region.show() + else: + self.overview_region.hide() + finally: + self._syncing = False self.overview.setYRange( float(np.nanmin(column[finite])) if finite.any() else 0.0, float(np.nanmax(column[finite])) if finite.any() else 1.0, padding=0.05, ) + self._refresh_window_label() + + def _refresh_window_label(self) -> None: + """The overview strip's readout: where the window is inside the session.""" + first, last = self._source.extent() + if last <= first: + self.window_label.setText("") + return + if self._mode == "browse": + start, end = self._window + else: + start, end = 0.0, min(last, self.history.window_sec) + self.window_label.setText( + t( + "chart.window_readout", + start=f"{start:.1f}", + end=f"{end:.1f}", + total=f"{last - first:.1f}", + ) + ) + + def _on_overview_region_changed(self) -> None: + """Dragging the overview marker moves the visible window. + + The region is the timeline control, so a drag is a pan: it enters + history browsing exactly like dragging the charts themselves. + """ + if self._syncing or self._store is None: + return + start, end = (float(v) for v in self.overview_region.getRegion()) + if not (end > start): + return + first, last = self._source.extent() + if last <= first: + return + self.set_window(start, end) def _overview_points(self) -> int: return 1200 @@ -752,11 +872,16 @@ class ForcePlotsPanel(QFrame): self.title.setText(t("panels.plots")) for badge, label in zip(self.channel_badges, CHANNEL_LABELS): badge.setText(label) + for name_label, key in zip(self.channel_names, ("fn", "fx", "fy")): + name_label.setText(t(f"chart.{key}_name")) self.btn_latest.setText(t("chart.return_latest")) self.btn_latest.setToolTip(t("chart.return_latest_tip")) self.btn_overview.setText(t("chart.overview")) self.btn_overview.setToolTip(t("chart.overview_tip")) self.btn_box_zoom.setText(t("chart.box_zoom")) self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip")) + self.window_caption.setText(t("chart.overview")) + self.window_caption.setToolTip(t("chart.window_tip")) self._refresh_mode_label() + self._refresh_window_label() self._render_value_cards() diff --git a/apps/studio/src/orisys_studio/ui/playback_dialog.py b/apps/studio/src/orisys_studio/ui/playback_dialog.py index 3756715..b770bdc 100644 --- a/apps/studio/src/orisys_studio/ui/playback_dialog.py +++ b/apps/studio/src/orisys_studio/ui/playback_dialog.py @@ -165,6 +165,9 @@ class PlaybackDialog(QDialog): if directory is not None: self.open_recording(Path(directory)) + # Labels last: without this the three buttons stayed empty, because the + # dialog never retranslated after being built. + self.retranslate() # ------------------------------------------------------------------ opening def _on_open(self) -> None: diff --git a/apps/studio/src/orisys_studio/ui/record_status.py b/apps/studio/src/orisys_studio/ui/record_status.py new file mode 100644 index 0000000..409c709 --- /dev/null +++ b/apps/studio/src/orisys_studio/ui/record_status.py @@ -0,0 +1,204 @@ +"""Record status strip: one truthful, always-visible readout of the record channel. + +The strip answers four questions the record button alone cannot: + +* is this session *acquiring* or also *recording* (the phase rail says the first); +* for how long has the recording been running; +* did the writer drop anything; +* did a write fail. + +All of that comes from :class:`~orisys_app_shared.record.experiment.RecordStats`, +which the recorder exposes as a locked snapshot, so the UI can read it on its own +timer without touching the writer thread. +""" + +from __future__ import annotations + +from PyQt5.QtWidgets import QLabel + +from orisys_app_shared.i18n import t +from orisys_app_shared.theme import color + +#: The recording channel is idle, never started in this session. +IDLE = "idle" +#: A recording is running; the elapsed time ticks every UI frame. +RECORDING = "recording" +#: The writer is still closing its files — never present that as finished. +CLOSING = "closing" +#: A write failed, or frames were rejected after a failure. +ERROR = "error" +#: A finished recording (of this session) is being summarised. +DONE = "done" + +STATES = (IDLE, RECORDING, CLOSING, ERROR, DONE) + +#: Which theme token colours the chip in each state. +_STATE_TOKENS = { + IDLE: "status_idle", + RECORDING: "status_running", + CLOSING: "status_connecting", + ERROR: "status_error", + DONE: "status_idle", +} + +_STATE_KEYS = { + IDLE: "record.state_idle", + RECORDING: "record.state_recording", + CLOSING: "record.state_closing", + ERROR: "record.state_error", + DONE: "record.state_done", +} + + +def format_elapsed(seconds: object) -> str: + """``mm:ss.d`` for a duration in seconds; never negative, never raising.""" + try: + value = max(0.0, float(seconds)) # type: ignore[arg-type] + except (TypeError, ValueError): + value = 0.0 + minutes, rest = divmod(value, 60.0) + return f"{int(minutes):02d}:{rest:04.1f}" + + +class RecordStatusChip(QLabel): + """Compact record-state chip (text + colour), stable under 60 Hz updates.""" + + def __init__(self, parent=None, *, theme: str = "dark") -> None: + super().__init__(parent) + self._theme = str(theme) + self._state = IDLE + self._duration_s = 0.0 + self._frames = 0 + self._segments = 0 + self._dropped = 0 + self._dropped_data = 0 + self._dropped_video = 0 + self._dropped_gaps = 0 + self._errors = 0 + self._detail = "" + self._directory = "" + self._token = "" + self._tooltip_cache = "" + self.setObjectName("recordChip") + self._refresh() + + # ------------------------------------------------------------------ API + def state(self) -> str: + return self._state + + def stats(self) -> dict: + """The raw numbers behind the current text (used by tooltips/tests).""" + return { + "state": self._state, + "duration_s": self._duration_s, + "frames": self._frames, + "segments": self._segments, + "dropped": self._dropped, + "dropped_data": self._dropped_data, + "dropped_video": self._dropped_video, + "dropped_gaps": self._dropped_gaps, + "errors": self._errors, + "detail": self._detail, + "directory": self._directory, + } + + def set_state( + self, + state: str, + *, + duration_s: float = 0.0, + frames: int = 0, + segments: int = 0, + dropped: int = 0, + dropped_data: int = 0, + dropped_video: int = 0, + dropped_gaps: int = 0, + errors: int = 0, + detail: str = "", + directory: str = "", + ) -> None: + """Update every number the strip can show, then repaint if it changed.""" + self._state = state if state in STATES else IDLE + self._duration_s = float(duration_s or 0.0) + self._frames = int(frames or 0) + self._segments = int(segments or 0) + self._dropped = int(dropped or 0) + self._dropped_data = int(dropped_data or 0) + self._dropped_video = int(dropped_video or 0) + self._dropped_gaps = int(dropped_gaps or 0) + self._errors = int(errors or 0) + self._detail = str(detail or "") + self._directory = str(directory or "") + self._refresh() + + def set_theme(self, theme: str) -> None: + self._theme = str(theme) + self._refresh() + + def set_directory(self, directory: str) -> None: + """Where the recording is being written (shown in the tooltip).""" + self._directory = str(directory or "") + self._refresh() + + def retranslate(self) -> None: + self._refresh() + + # ------------------------------------------------------------- internals + def _text_for_state(self) -> str: + if self._state == RECORDING: + return t("record.state_recording", duration=format_elapsed(self._duration_s)) + if self._state == CLOSING: + return t("record.state_closing") + if self._state == ERROR: + return t("record.state_error", count=self._errors) + if self._state == DONE: + return t("record.state_done", frames=self._frames) + return t("record.state_idle") + + def _tooltip(self) -> str: + if self._state == IDLE: + return t("record.strip_idle") + return t( + "record.strip_tip", + state=self._text_for_state(), + frames=self._frames, + segments=self._segments, + duration=format_elapsed(self._duration_s), + dropped=self._dropped, + dropped_data=self._dropped_data, + dropped_video=self._dropped_video, + dropped_gaps=self._dropped_gaps, + errors=self._errors, + detail=self._detail or t("record.strip_no_error"), + path=self._directory or t("record.strip_no_path"), + ) + + def _leading_token(self) -> str: + # Anything dropped is worth warning about even while the state is green; + # a write failure outranks it. + if self._state == ERROR: + return _STATE_TOKENS[ERROR] + if self._dropped > 0: + return "warning" + return _STATE_TOKENS[self._state] + + def _refresh(self) -> None: + text = self._text_for_state() + if self._dropped > 0: + text = f"{text} · {t('record.dropped', count=self._dropped)}" + # ``setText`` on every frame would re-layout the rail 60 times a second. + if text != self.text(): + self.setText(text) + tooltip = self._tooltip() + if tooltip != self._tooltip_cache: + self._tooltip_cache = tooltip + self.setToolTip(tooltip) + token = self._leading_token() + if token != self._token: + # Re-parsing a stylesheet per frame is measurable; the token only + # changes when the state (or the theme) does. + self._token = token + self.setStyleSheet( + f"QLabel#recordChip {{ color: {color(self._theme, token)};" + " background: transparent; }" + ) diff --git a/apps/studio/src/orisys_studio/ui/sensor_picker.py b/apps/studio/src/orisys_studio/ui/sensor_picker.py index b781c0d..5433c44 100644 --- a/apps/studio/src/orisys_studio/ui/sensor_picker.py +++ b/apps/studio/src/orisys_studio/ui/sensor_picker.py @@ -1,25 +1,31 @@ -"""Sensor type + config picker with product badge.""" +"""Sensor type + config picker. + +One row, because it lives at the left of the status rail: a badge naming the +selected product, then the product/sensor choice and the config file that +belongs to it. The order matters — picking ``box`` / ``mini`` / ``finger`` is +what decides which configs exist, so the two sit next to each other with their +own labels and tooltips instead of a bare "配置" combo. +""" from __future__ import annotations from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtWidgets import ( QComboBox, - QFormLayout, - QFrame, QHBoxLayout, QLabel, QSizePolicy, - QVBoxLayout, QWidget, ) from orisys_app_shared.i18n import t from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types -THUMB_SIZE = 56 CONTROL_HEIGHT = 30 -MAX_CONTROL_WIDTH = 220 +#: Both combos stay narrow: this row shares one line with the session state. +TYPE_WIDTH = 76 +CONFIG_WIDTH = 120 +BADGE_WIDTH = 46 class SensorPickerWidget(QWidget): @@ -29,34 +35,27 @@ class SensorPickerWidget(QWidget): def __init__(self, parent=None) -> None: super().__init__(parent) layout = QHBoxLayout(self) - layout.setContentsMargins(8, 6, 8, 6) - layout.setSpacing(10) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) - self.thumb = QFrame() - self.thumb.setObjectName("cardPanel") - self.thumb.setFixedSize(THUMB_SIZE, THUMB_SIZE) - thumb_layout = QVBoxLayout(self.thumb) - thumb_layout.setContentsMargins(2, 2, 2, 2) - thumb_layout.setSpacing(0) + #: The product badge that used to sit above the combos. Kept as an + #: attribute: it states which sensor the whole window is configured for. self.thumb_label = QLabel() self.thumb_label.setObjectName("unitBadge") self.thumb_label.setAlignment(Qt.AlignCenter) - self.thumb_label.setWordWrap(False) - thumb_layout.addWidget(self.thumb_label) - - form = QFormLayout() - form.setContentsMargins(0, 0, 0, 0) - form.setHorizontalSpacing(8) - form.setVerticalSpacing(6) - form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) - form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) + # A minimum, not a fixed width: "FINGER" must not be truncated to "INGE". + self.thumb_label.setMinimumWidth(BADGE_WIDTH) + self.thumb_label.setProperty("rail", "true") + # The old name, kept so callers/tests that hide or tooltip the product + # badge keep working. + self.thumb = self.thumb_label self.type_label = QLabel() self.type_label.setObjectName("fieldLabel") self.type_combo = QComboBox() - self.type_combo.setMinimumHeight(CONTROL_HEIGHT) - self.type_combo.setMaximumWidth(MAX_CONTROL_WIDTH) - self.type_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.type_combo.setMinimumWidth(TYPE_WIDTH) + self.type_combo.setProperty("rail", "true") + self.type_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) for name in list_sensor_types(): self.type_combo.addItem(name, name) self.type_combo.currentIndexChanged.connect(self._on_type_index_changed) @@ -64,16 +63,17 @@ class SensorPickerWidget(QWidget): self.config_label = QLabel() self.config_label.setObjectName("fieldLabel") self.config_combo = QComboBox() - self.config_combo.setMinimumHeight(CONTROL_HEIGHT) - self.config_combo.setMaximumWidth(MAX_CONTROL_WIDTH) - self.config_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.config_combo.setMinimumWidth(CONFIG_WIDTH) + self.config_combo.setProperty("rail", "true") + self.config_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.config_combo.currentIndexChanged.connect(self._on_config_index_changed) - form.addRow(self.type_label, self.type_combo) - form.addRow(self.config_label, self.config_combo) + layout.addWidget(self.thumb_label) + layout.addWidget(self.type_label) + layout.addWidget(self.type_combo) + layout.addWidget(self.config_label) + layout.addWidget(self.config_combo, 1) - layout.addWidget(self.thumb) - layout.addLayout(form, 1) self._reload_configs() self._refresh_thumb() self.retranslate() @@ -141,5 +141,9 @@ class SensorPickerWidget(QWidget): def retranslate(self) -> None: self.type_label.setText(t("sensor.type")) self.config_label.setText(t("sensor.config")) - self.thumb.setToolTip(t("sensor.thumb_placeholder")) + self.type_label.setToolTip(t("sensor.type_tip")) + self.config_label.setToolTip(t("sensor.config_tip")) + self.type_combo.setToolTip(t("sensor.type_tip")) + self.config_combo.setToolTip(t("sensor.config_tip")) + self.thumb_label.setToolTip(t("sensor.thumb_placeholder")) self._refresh_thumb() diff --git a/apps/studio/src/orisys_studio/ui/toolbar.py b/apps/studio/src/orisys_studio/ui/toolbar.py index 1abdd37..9c2aba5 100644 --- a/apps/studio/src/orisys_studio/ui/toolbar.py +++ b/apps/studio/src/orisys_studio/ui/toolbar.py @@ -1,4 +1,14 @@ -"""Top toolbar: source, camera/video, run/reset, record, units.""" +"""Top of the studio shell: status rail and a collapsible settings area. + +The rail is what an operator watches while a session runs — phase, device, +calibration, data validity and the record channel — plus the three actions that +must never be more than one click away (record, run/stop, reset). + +Everything that is *configuration* rather than state — source, camera number, +video file, units and the sensor/config pickers — lives in the settings area, +collapsed by default. Both live inside one scroll area so a narrow window +scrolls sideways instead of squeezing the controls into an unreadable row. +""" from __future__ import annotations @@ -21,6 +31,10 @@ from PyQt5.QtWidgets import ( ) from orisys_app_shared.i18n import t +from orisys_app_shared.theme import color + +from orisys_studio.ui.fit_text import ShrinkableLabel +from orisys_studio.ui.record_status import RecordStatusChip CONTROL_HEIGHT = 30 BUTTON_HEIGHT = 32 @@ -38,6 +52,17 @@ def _separator() -> QFrame: return line +def _rail(widget: QWidget) -> QWidget: + """Mark a control as belonging to the thin status rail. + + The rail's height is the app's first visual weight statement: without this + property every button would keep the dialog padding and the rail would grow + to twice its content. + """ + widget.setProperty("rail", "true") + return widget + + def _group(*widgets: QWidget) -> QWidget: box = QWidget() layout = QHBoxLayout(box) @@ -58,28 +83,127 @@ class ToolbarWidget(QWidget): reset_clicked = pyqtSignal() units_changed = pyqtSignal(str) record_toggled = pyqtSignal(bool) + #: The settings area was expanded or collapsed by the user. + settings_toggled = pyqtSignal(bool) - def __init__(self, parent=None) -> None: + def __init__( + self, + parent=None, + *, + theme: str = "dark", + sensor_picker: QWidget | None = None, + ) -> None: super().__init__(parent) + self._theme = str(theme) self._run_tooltip_reason = "" self._run_available = True self._run_phase = "idle" + self._settings_expanded = False + outer = QVBoxLayout(self) - outer.setContentsMargins(0, 0, 0, 0) - outer.setSpacing(0) + outer.setContentsMargins(2, 2, 2, 2) + outer.setSpacing(4) - self._scroll = QScrollArea() - self._scroll.setWidgetResizable(True) - self._scroll.setFrameShape(QFrame.NoFrame) - self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - outer.addWidget(self._scroll) + # The rail is always visible and never scrolls: it is one instrument + # line, and a scroll area around it would reserve a scrollbar's height + # (30 px in the Fusion metrics) even when nothing scrolls. + outer.addLayout(self._build_rail(sensor_picker)) - content = QWidget() - self._scroll.setWidget(content) - layout = QHBoxLayout(content) - layout.setContentsMargins(2, 2, 2, 2) - layout.setSpacing(10) + # Only the settings area scrolls; it is the part that can outgrow a + # narrow window. + self._settings_scroll = QScrollArea() + self._settings_scroll.setWidgetResizable(True) + self._settings_scroll.setFrameShape(QFrame.NoFrame) + self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.settings_page = self._build_settings() + self._settings_scroll.setWidget(self.settings_page) + outer.addWidget(self._settings_scroll) + + self._populate_source_combo() + self._populate_units_combo() + self._populate_record_modes() + self.retranslate() + self._apply_source_mode_ui() + self.set_settings_expanded(False) + + # --------------------------------------------------------------- building + def _build_rail(self, sensor_picker: QWidget | None) -> QHBoxLayout: + """Sensor choice, phase, device, calibration, validity, record, actions. + + The sensor picker leads the line: which product and config the window is + configured for is the first thing to read, and it is what everything + else (units, plots, calibration) depends on. + """ + rail = QHBoxLayout() + rail.setSpacing(8) + + if sensor_picker is not None: + rail.addWidget(sensor_picker) + rail.addWidget(_separator()) + + self.status_chip = QLabel() + self.status_chip.setObjectName("statusChip") + self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred) + self.sensor_summary = ShrinkableLabel() + self.sensor_summary.setObjectName("panelMeta") + self.calibration_label = ShrinkableLabel() + self.calibration_label.setObjectName("panelMeta") + self.data_state = ShrinkableLabel() + self.data_state.setObjectName("panelMeta") + self.data_state.setVisible(False) + self.record_chip = RecordStatusChip(theme=self._theme) + + self.btn_record = QPushButton() + self.btn_record.setObjectName("primaryButton") + self.btn_record.setMinimumHeight(BUTTON_HEIGHT) + self.btn_record.setMinimumWidth(96) + self.btn_record.setCheckable(True) + self.btn_record.setEnabled(False) + self.btn_record.toggled.connect(self._on_record_toggled) + + self.btn_run = QPushButton() + self.btn_run.setObjectName("primaryButton") + self.btn_run.setMinimumHeight(BUTTON_HEIGHT) + self.btn_run.setMinimumWidth(96) + self.btn_run.setCheckable(True) + self.btn_run.toggled.connect(self._on_run_toggled) + + self.btn_reset = QPushButton() + self.btn_reset.setMinimumHeight(BUTTON_HEIGHT) + self.btn_reset.setEnabled(False) + self.btn_reset.clicked.connect(self.reset_clicked.emit) + + self.btn_settings = QPushButton() + self.btn_settings.setObjectName("ghostButton") + self.btn_settings.setProperty("compact", "true") + self.btn_settings.setMinimumHeight(BUTTON_HEIGHT) + self.btn_settings.setCheckable(True) + self.btn_settings.toggled.connect(self._on_settings_toggled) + + rail.addWidget(self.status_chip) + rail.addWidget(_separator()) + rail.addWidget(self.sensor_summary) + rail.addWidget(self.calibration_label) + rail.addWidget(self.data_state) + rail.addStretch(1) + rail.addWidget(self.record_chip) + rail.addWidget(_rail(self.btn_record)) + rail.addWidget(_rail(self.btn_run)) + rail.addWidget(_rail(self.btn_reset)) + rail.addWidget(_rail(self.btn_settings)) + return rail + + def _build_settings(self) -> QWidget: + """Source, camera/video, search, preview and units. + + The sensor picker is *not* here: it leads the status rail, because the + product choice is session state, not a setting to dig for. + """ + self.settings_page = QWidget() + page = QVBoxLayout(self.settings_page) + page.setContentsMargins(0, 2, 0, 0) + page.setSpacing(6) # --- source group ------------------------------------------------- self.source_label = QLabel() @@ -132,18 +256,6 @@ class ToolbarWidget(QWidget): self.btn_preview.setCheckable(True) self.btn_preview.toggled.connect(self._on_preview_toggled) - self.btn_run = QPushButton() - self.btn_run.setObjectName("primaryButton") - self.btn_run.setMinimumHeight(BUTTON_HEIGHT) - self.btn_run.setMinimumWidth(96) - self.btn_run.setCheckable(True) - self.btn_run.toggled.connect(self._on_run_toggled) - - self.btn_reset = QPushButton() - self.btn_reset.setMinimumHeight(BUTTON_HEIGHT) - self.btn_reset.setEnabled(False) - self.btn_reset.clicked.connect(self.reset_clicked.emit) - # --- units group -------------------------------------------------- self.units_label = QLabel() self.units_label.setObjectName("fieldLabel") @@ -152,46 +264,75 @@ class ToolbarWidget(QWidget): self.units_combo.setMinimumHeight(CONTROL_HEIGHT) self.units_combo.currentIndexChanged.connect(self._on_units_index_changed) - # --- record group ------------------------------------------------- + # --- record mode group -------------------------------------------- + # "What will be recorded" is a per-recording setting, not live state: + # it lives here so the rail's labels stay readable at 1280 in both + # languages (the record *state* is on the rail, next to the button). self.record_label = QLabel() self.record_label.setObjectName("fieldLabel") self.record_mode_combo = QComboBox() self.record_mode_combo.setMinimumWidth(120) self.record_mode_combo.setMinimumHeight(CONTROL_HEIGHT) - self.btn_record = QPushButton() - self.btn_record.setObjectName("ghostButton") - self.btn_record.setMinimumHeight(BUTTON_HEIGHT) - self.btn_record.setMinimumWidth(96) - self.btn_record.setCheckable(True) - self.btn_record.setEnabled(False) - self.btn_record.toggled.connect(self._on_record_toggled) - self.record_status = QLabel() - self.record_status.setObjectName("panelMeta") - self.record_status.setVisible(False) - layout.addWidget( + top_row = QHBoxLayout() + top_row.setSpacing(10) + top_row.addWidget( _group(self.source_label, self.source_combo, self.source_stack) ) - layout.addWidget(_separator()) - layout.addWidget( - _group(self.btn_search, self.btn_preview, self.btn_run, self.btn_reset) - ) - layout.addWidget(_separator()) - layout.addWidget(_group(self.units_label, self.units_combo)) - layout.addWidget(_separator()) - layout.addWidget( - _group(self.record_label, self.record_mode_combo, self.btn_record, - self.record_status) - ) - layout.addStretch(1) + top_row.addWidget(_separator()) + top_row.addWidget(_group(self.btn_search, self.btn_preview)) + top_row.addWidget(_separator()) + top_row.addWidget(_group(self.units_label, self.units_combo)) + top_row.addWidget(_separator()) + top_row.addWidget(_group(self.record_label, self.record_mode_combo)) + top_row.addStretch(1) + page.addLayout(top_row) - self._populate_source_combo() - self._populate_units_combo() - self._populate_record_modes() - self.retranslate() - self._apply_source_mode_ui() + return self.settings_page # ------------------------------------------------------------------ API + def settings_expanded(self) -> bool: + return bool(self._settings_expanded) + + def set_settings_expanded(self, expanded: bool) -> None: + self._settings_expanded = bool(expanded) + self.btn_settings.blockSignals(True) + self.btn_settings.setChecked(self._settings_expanded) + self.btn_settings.blockSignals(False) + self._settings_scroll.setVisible(self._settings_expanded) + self._apply_settings_button_style() + + def set_theme(self, theme: str) -> None: + self._theme = str(theme) + self.record_chip.set_theme(self._theme) + + def set_sensor_summary(self, text: str) -> None: + self.sensor_summary.setText(str(text or "")) + # The rail may squeeze this label; the tooltip always carries it whole. + self.sensor_summary.setToolTip(self.sensor_summary.text()) + self.sensor_summary.setVisible(bool(text)) + + def set_calibration_summary(self, text: str) -> None: + self.calibration_label.setText(str(text or "")) + self.calibration_label.setToolTip(self.calibration_label.text()) + self.calibration_label.setVisible(bool(text)) + + def set_data_state(self, text: str, token: str = "text_secondary") -> None: + """Show/clear the validity readout, coloured by a theme token.""" + text = str(text or "") + self.data_state.setText(text) + self.data_state.setToolTip(text) + self.data_state.setVisible(bool(text)) + if text: + self.data_state.setStyleSheet( + f"QLabel#panelMeta {{ color: {color(self._theme, token)};" + " background: transparent; }" + ) + + def set_record_state(self, *args, **kwargs) -> None: + """Delegate the record strip to :class:`RecordStatusChip`.""" + self.record_chip.set_state(*args, **kwargs) + def source_mode(self) -> str: data = self.source_combo.currentData() return str(data or "camera") @@ -337,27 +478,36 @@ class ToolbarWidget(QWidget): self._refresh_record_label() def set_record_status(self, text: str) -> None: - """Show elapsed time / output location next to the record control.""" - self.record_status.setText(str(text or "")) - self.record_status.setVisible(bool(text)) + """Publish where the recording is being written (shown in the tooltip).""" + self.record_chip.set_directory(str(text or "")) def _on_record_toggled(self, checked: bool) -> None: self._refresh_record_label() self.record_mode_combo.setEnabled(not checked) self.record_toggled.emit(bool(checked)) + def _on_settings_toggled(self, checked: bool) -> None: + self._settings_expanded = bool(checked) + self._settings_scroll.setVisible(self._settings_expanded) + self._apply_settings_button_style() + self.settings_toggled.emit(self._settings_expanded) + + def _apply_settings_button_style(self) -> None: + self.btn_settings.setProperty( + "settingsOpen", "true" if self._settings_expanded else "false" + ) + self.btn_settings.style().unpolish(self.btn_settings) + self.btn_settings.style().polish(self.btn_settings) + def _refresh_record_label(self) -> None: if self.btn_record.isChecked(): self.btn_record.setText(t("record.stop_label")) - self.btn_record.setObjectName("dangerButton") else: self.btn_record.setText(t("record.start_label")) - self.btn_record.setObjectName("ghostButton") - self.btn_record.style().unpolish(self.btn_record) - self.btn_record.style().polish(self.btn_record) self.btn_record.setToolTip( t("record.stop_tip") if self.btn_record.isChecked() else t("record.start_tip") ) + self._refresh_emphasis() def _apply_source_mode_ui(self) -> None: video = self.source_mode() == "video" @@ -400,13 +550,39 @@ class ToolbarWidget(QWidget): self.btn_run.setText(t("toolbar.stop_label")) else: self.btn_run.setText(t("toolbar.run_label")) - self.btn_run.setObjectName( - "dangerButton" if self.btn_run.isChecked() else "primaryButton" - ) - self.btn_run.style().unpolish(self.btn_run) - self.btn_run.style().polish(self.btn_run) + self._refresh_emphasis() self._apply_run_tooltip() + def _refresh_emphasis(self) -> None: + """Exactly one *filled* button at a time. + + The complaint this rule answers is a red Stop dominating a top area in + which nothing was happening. Stop stays red and one click away, but only + the action of the moment is filled: + + * not recording yet → the record action is the filled primary; + * recording → stopping the recording is the filled danger; + * connecting → cancel is the filled danger (it is the way out of a hang). + """ + recording = self.btn_record.isChecked() + # The phase counts as running even if the button has not been repainted + # yet: the rail must describe the session, not the widget's lag. + running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running") + if recording: + self.btn_record.setObjectName("dangerButton") + self.btn_run.setObjectName("dangerOutline") + else: + self.btn_record.setObjectName("primaryButton") + if running and self._run_phase != "connecting": + self.btn_run.setObjectName("dangerOutline") + elif running: + self.btn_run.setObjectName("dangerButton") + else: + self.btn_run.setObjectName("primaryButton") + for button in (self.btn_record, self.btn_run): + button.style().unpolish(button) + button.style().polish(button) + def _apply_run_tooltip(self) -> None: if self.btn_run.isChecked(): if self._run_phase == "connecting": @@ -439,9 +615,11 @@ class ToolbarWidget(QWidget): self.units_label.setText(t("toolbar.units")) self.units_combo.setItemText(0, t("toolbar.units_newton")) self.units_combo.setItemText(1, t("toolbar.units_raw")) - self.record_label.setText(t("record.label")) for index, (value, key) in enumerate(RECORD_MODES): if index < self.record_mode_combo.count(): self.record_mode_combo.setItemText(index, t(key)) self.record_mode_combo.setToolTip(t("record.mode_tip")) + self.btn_settings.setText(t("toolbar.settings")) + self.btn_settings.setToolTip(t("toolbar.settings_tip")) + self.record_chip.retranslate() self._refresh_record_label() diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 7f45573..db4355c 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -62,6 +62,11 @@ def test_children_stay_inside_their_parent(make_window, size): window = _show(make_window(sensor_type="finger", show_cal_panel=True), size) oversize = [] for child in visible_descendants(window): + if ui_check._inside_scroll_area(child): + # A scroll area's content is *meant* to exceed its viewport: the + # calibration panel and the settings area scroll instead of + # squeezing their controls into an unreadable width. + continue parent = child.parentWidget() if parent is None or not parent.isVisible(): continue @@ -1145,4 +1150,333 @@ def test_recording_leaves_unavailable_values_empty(make_window, record_root): _finish(window, stuck) +# ------------------------------------------------------- top rail / settings + + +def test_top_area_stays_compact(make_window): + """The rail is read at a glance; it is not a place to stack configuration.""" + from orisys_app_shared.i18n import t + + window = _show(make_window(), (1440, 880)) + collapsed = window.brand_bar.height() + window.toolbar.height() + assert collapsed <= 78, f"the collapsed top area is {collapsed}px tall" + assert window.brand_bar.height() <= 30 + assert not window.toolbar.settings_expanded() + assert window.brand_bar.btn_live.text() and window.brand_bar.btn_playback.text() + assert window.toolbar.status_chip.text().strip() + assert window.toolbar.record_chip.text() == t("record.state_idle") + + window.toolbar.btn_settings.setChecked(True) + from PyQt5.QtWidgets import QApplication + + QApplication.processEvents() + assert window.toolbar.settings_expanded() + assert window.toolbar.source_combo.isVisible() + assert window.sensor_picker.isVisible() + expanded = window.brand_bar.height() + window.toolbar.height() + assert expanded > collapsed, "opening the settings area changed nothing" + assert window.act_settings.isChecked(), "the View menu does not follow the toggle" + assert window.prefs["settings_expanded"] is True + + window.toolbar.btn_settings.setChecked(False) + QApplication.processEvents() + assert not window.toolbar.settings_expanded() + + +@pytest.mark.parametrize("size", RESOLUTIONS) +def test_settings_area_stays_inside_its_container(make_window, size): + """Opening the settings area must not push a widget past its parent. + + The settings area is the one part of the top rail that may scroll: its own + content is exempt, everything else must fit. + """ + from PyQt5.QtWidgets import QApplication + + window = _show(make_window(sensor_type="finger", show_cal_panel=True), size) + window._set_settings_expanded(True) + for _ in range(3): + QApplication.processEvents() + + oversize = [] + for child in visible_descendants(window): + if ui_check._inside_scroll_area(child): + continue + parent = child.parentWidget() + if parent is None or not parent.isVisible(): + continue + geometry = child.geometry() + if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1: + oversize.append( + f"{type(child).__name__} {geometry.width()}x{geometry.height()}" + f" > parent {parent.width()}x{parent.height()}" + ) + assert not oversize, oversize + + +def test_idle_run_is_the_only_filled_action(make_window): + window = _show(make_window(), (1440, 880)) + assert window.toolbar.btn_run.objectName() == "primaryButton" + assert not window.toolbar.btn_record.isEnabled(), "recording without a session" + + +def test_connecting_cancel_stays_filled(make_window): + """Cancel is the way out of a hung device open, so it keeps the weight.""" + from PyQt5.QtWidgets import QApplication + + window = _show(make_window(), (1440, 880)) + stuck = ui_check._StuckWorker() + window._ensure_workers = lambda: None + window._box_worker = stuck + window._finger_worker = stuck + window.toolbar.btn_run.setChecked(True) + QApplication.processEvents() + assert window._session_phase == "connecting" + assert window.toolbar.btn_run.objectName() == "dangerButton" + window.toolbar.btn_run.setChecked(False) + stuck.release() + + +def test_exactly_one_filled_action_while_acquiring(make_window): + """Driven through the real entry point: Run, then the first packet.""" + window = _show(make_window(), (1440, 880)) + stuck = ui_check._StuckWorker() + window._ensure_workers = lambda: None + window._box_worker = stuck + window._finger_worker = stuck + window.toolbar.btn_run.setChecked(True) + window._run_timer.stop() + assert window._session_phase == "connecting" + assert window.toolbar.btn_run.objectName() == "dangerButton", ( + "Cancel lost its weight while a connect can hang" + ) + + stuck.emit(ui_check._fake_packet(1)) + window._on_run_tick() + assert window._session_phase == "running" + assert window.toolbar.btn_run.objectName() == "dangerOutline", ( + "Stop is still the filled action while a session merely acquires" + ) + assert window.toolbar.btn_record.objectName() == "primaryButton" + + window.toolbar.set_record_checked(True) + assert window.toolbar.btn_record.objectName() == "dangerButton" + assert window.toolbar.btn_run.objectName() == "dangerOutline", ( + "two filled actions share the rail while recording" + ) + + window.toolbar.set_record_checked(False) + window.toolbar.btn_run.setChecked(False) + stuck.release() + + +def test_record_strip_separates_acquiring_from_recording(make_window): + from orisys_app_shared.i18n import t + from orisys_app_shared.record.experiment import RecordStats + + from orisys_studio.ui import record_status + + window, stuck = _running_window(make_window) + chip = window.toolbar.record_chip + assert chip.text() == t("record.state_idle") + assert t("record.state_idle") != t("status.phase_running"), ( + "acquiring and recording share one label" + ) + + real_stats = window._record_stats + window._record_stats = lambda: ( + record_status.RECORDING, + RecordStats(duration_s=12.4, frames=372, segments=1, dropped_data=3), + "20240101T000000Z_session0001_deadbeef", + ) + window._refresh_record_state() + assert t("record.state_recording", duration="00:12.4") in chip.text() + assert t("record.dropped", count=3) in chip.text() + assert "deadbeef" in chip.toolTip() + # A missing placeholder would leave the raw template in place: ``t`` falls + # back to the key instead of raising, so the tooltip is asserted on content. + assert chip.toolTip() != t("record.strip_tip") + + window._record_stats = lambda: ( + record_status.ERROR, + RecordStats(frames=10, segments=1, failed=True, write_errors=2, last_error="disk full"), + "", + ) + window._refresh_record_state() + assert chip.text() == t("record.state_error", count=2) + assert "disk full" in chip.toolTip() + + window._record_stats = real_stats + _finish(window, stuck) + + +# --------------------------------------------------- curves / arrow legend + + +def test_axis_readouts_sit_above_the_charts(make_window): + window = _show(make_window(), (1440, 880)) + panel = window.force_plots + assert panel.value_group.y() < panel.plot_frame.y(), ( + "the axis readouts are not above the charts" + ) + for label in panel.value_labels: + assert label.text(), "an axis readout is empty" + assert label.height() >= label.fontMetrics().height(), "a readout is clipped" + assert label.fontMetrics().height() >= 20, "the readouts are not large digits" + assert [ + plot.getAxis("bottom").style.get("showValues") for plot in panel.plots + ] == [False, False, True], "the three charts do not share one bottom time axis" + + +def test_overview_region_is_the_timeline_control(make_window): + window, stuck = _running_window(make_window) + panel = window.force_plots + for index in range(120): + stuck.emit(ui_check._fake_packet(index + 100)) + window._on_run_tick() + assert window.session_store.count == 120 + first, last = window.session_store.extent() + assert last > first, "the session has no extent to browse" + assert panel.window_label.text(), "the overview strip has no readout" + + panel.show_all() + assert panel.mode() == "browse" + target = (first + (last - first) * 0.25, first + (last - first) * 0.5) + panel.overview_region.setRegion(target) + dragged = panel.window() + assert abs(dragged[0] - target[0]) < 0.05 and abs(dragged[1] - target[1]) < 0.05, ( + f"dragging the overview did not move the window: {dragged} vs {target}" + ) + assert panel.mode() == "browse" + + window.brand_bar.btn_live.click() + assert panel.mode() == "follow", "the live nav item did not leave history browsing" + _finish(window, stuck) + + +def test_arrow_legend_follows_the_view_mode(make_window): + from orisys_app_shared.acquisition.arrows import arrow_display_scale + + assert arrow_display_scale("box") == 2.0 + assert arrow_display_scale("finger") == 1.0 + + window = _show(make_window(), (1440, 880)) + panel = window.arrow_panel + assert panel.scale_label.isVisible() + assert "2" in panel.scale_label.text() + assert panel.pipeline_label.isVisible() and panel.pipeline_label.text() + assert panel.area_label.isVisible() and panel.area_label.text() + assert not panel.colorbar.isVisible(), "the depth colour bar is shown in arrows" + + panel.set_view_mode("depth") + panel.set_depth_scale("0.000 – 1.000") + assert panel.colorbar.isVisible(), "the depth view has no colour bar" + assert panel.depth_scale_label.isVisible() + assert not panel.scale_label.isVisible(), "the arrow magnification leaked into depth" + + panel.set_view_mode("arrows_pure") + assert not panel.colorbar.isVisible() + assert panel.scale_label.isVisible() + + +# ------------------------------------------------- top rail: sensor and views + + +def test_the_sensor_picker_leads_the_rail(make_window): + """Which product/config is selected must be readable, not buried.""" + from orisys_app_shared.i18n import t + + window = _show(make_window(), (1440, 880)) + picker = window.sensor_picker + assert picker.isVisible(), "the sensor picker is not on the status rail" + assert picker.type_label.text() == t("sensor.type") + assert picker.config_label.text() == t("sensor.config") + assert picker.thumb_label.text() == "BOX" + assert picker.type_label.toolTip() and picker.config_label.toolTip() + + # The two combos stay wired: switching the product reloads its configs. + picker.type_combo.setCurrentIndex(picker.type_combo.findData("finger")) + assert picker.sensor_type() == "finger" + assert picker.config_name() + + +def test_the_nav_items_switch_the_main_window_layout(make_window): + window, stuck = _running_window(make_window) + for index in range(60): + stuck.emit(ui_check._fake_packet(index + 100)) + window._on_run_tick() + assert window.session_store.count == 60 + + window.brand_bar.btn_calibration.click() + assert window.view_preset() == "calibration" + sizes = window.content_splitter.sizes() + assert window.cal_panel.isVisible() + assert not window.arrow_panel.isVisible(), "the calibration view keeps the 2D panel" + assert sizes[3] >= 500, f"the calibration panel is too narrow: {sizes}" + assert window.brand_bar.btn_calibration.property("navActive") == "true" + + window.brand_bar.btn_playback.click() + assert window.view_preset() == "replay" + assert window.arrow_panel.isVisible() and not window.cal_panel.isVisible() + assert window.force_plots.mode() == "browse", "the replay view did not open the session" + + window.brand_bar.btn_live.click() + assert window.view_preset() == "live" + sizes = window.content_splitter.sizes() + assert sizes[2] > sizes[1], f"the 2D panel is not the smaller one: {sizes}" + assert window.force_plots.mode() == "follow" + assert window.brand_bar.btn_live.property("navActive") == "true" + _finish(window, stuck) + + +def test_the_calibration_view_does_not_clip_its_controls(make_window): + from PyQt5.QtWidgets import QHeaderView + + window = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880)) + window.brand_bar.btn_calibration.click() + from PyQt5.QtWidgets import QApplication + + for _ in range(3): + QApplication.processEvents() + + cal = window.cal_panel + assert cal.scroll.isVisible(), "the calibration panel cannot scroll" + assert ( + cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeToContents + ), "the diagnostic columns can still be elided" + for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation): + assert button.height() >= button.fontMetrics().height(), button.text() + assert button.width() >= button.sizeHint().width(), ( + f"{button.text()!r} is clipped: {button.width()} < {button.sizeHint().width()}" + ) + + +def test_the_2d_area_keeps_the_output_aspect_ratio(make_window): + window = _show( + make_window(sensor_type="finger", show_finger_3d=False), (1440, 880) + ) + image = window.arrow_panel.image_label + assert abs(image.aspect() - 16.0 / 9.0) < 0.01 + assert abs(image.height() - image.width() / image.aspect()) <= 2, ( + f"the 2D area does not follow the output aspect: {image.width()}x{image.height()}" + ) + + +def test_the_playback_dialog_labels_its_buttons(make_window): + """The dialog's three controls had no text at all: it never retranslated.""" + from orisys_app_shared.i18n import t + + from orisys_studio.ui.playback_dialog import PlaybackDialog + + window = _show(make_window(), (1440, 880)) + dialog = PlaybackDialog(theme="dark", parent=window) + try: + assert dialog.open_button.text() == t("playback.open") + assert dialog.unit_button.text() + assert dialog.depth_button.text() == t("playback.recompute_depth") + assert dialog.depth_button.toolTip() == t("playback.recompute_depth_tip") + finally: + dialog.close() + + + diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index ddeceea..2e9c310 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -57,6 +57,33 @@ def _inside_scroll_area(widget) -> bool: return False +def rail_squeezed(window) -> list[str]: + """Status-rail widgets showing less than their own text. + + The rail shares one line with the session state, so every item that states a + fact (``摄像机 0``, ``开始记录``) must keep its full text; only the labels + that carry a tooltip are allowed to shrink. An elided button is how a + readable instrument turns back into a jumble. + """ + layout = window.toolbar.layout().itemAt(0).layout() + squeezed: list[str] = [] + for index in range(layout.count()): + widget = layout.itemAt(index).widget() + if widget is None or not widget.isVisible(): + continue + if getattr(widget, "minimumSizeHint", None) is None: + continue + if widget.minimumSizeHint().width() <= 0: + continue + if widget.width() < widget.sizeHint().width() - 1: + squeezed.append( + f"{type(widget).__name__}" + f"({getattr(widget, 'objectName', lambda: '')()}" + f" {widget.width()}<{widget.sizeHint().width()})" + ) + return squeezed + + def section(name: str) -> None: print(f"\n== {name} ==") @@ -295,15 +322,17 @@ TEXT_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("hint_text", ("card_bg", "panel_bg", "window_bg", "input_bg")), ("accent_text", ("accent", "accent_hover", "danger_fill", "danger_fill_hover")), ("danger_text", ("danger_fill", "danger_fill_hover")), - ("danger", ("card_bg", "panel_bg")), - ("danger_hover", ("card_bg", "panel_bg")), - ("warning", ("card_bg", "panel_bg")), - ("info", ("card_bg", "panel_bg")), - ("success", ("card_bg", "panel_bg")), - ("status_idle", ("panel_bg",)), - ("status_connecting", ("panel_bg",)), - ("status_running", ("panel_bg",)), - ("status_error", ("panel_bg",)), + # The status rail lives on the toolbar background, so every semantic colour + # it can show is audited there too — not only on the panel/card backgrounds. + ("danger", ("card_bg", "panel_bg", "toolbar_bg")), + ("danger_hover", ("card_bg", "panel_bg", "toolbar_bg")), + ("warning", ("card_bg", "panel_bg", "toolbar_bg")), + ("info", ("card_bg", "panel_bg", "toolbar_bg")), + ("success", ("card_bg", "panel_bg", "toolbar_bg")), + ("status_idle", ("panel_bg", "toolbar_bg")), + ("status_connecting", ("panel_bg", "toolbar_bg")), + ("status_running", ("panel_bg", "toolbar_bg")), + ("status_error", ("panel_bg", "toolbar_bg")), ("brand_bar_fg", ("brand_bar_bg",)), ("brand_bar_fg_muted", ("brand_bar_bg",)), ) @@ -807,6 +836,245 @@ def main() -> int: window.close() app.processEvents() + section("top area") + import orisys_studio.ui.record_status as _record_states + from orisys_app_shared.record.experiment import RecordStats as _RecordStats + + #: Brand rail + status rail, collapsed settings, must fit in one instrument + #: line: the previous layout spent this height on a 40 px brand bar and a + #: tall sensor card stacked above the charts. + top_area_limit = 78.0 + for theme in ("dark", "light"): + app.setStyleSheet(build_qss(theme)) + window = StudioMainWindow( + prefs={"language": "zh_CN", "theme": theme, "sensor_type": "box"} + ) + window.resize(1440, 880) + window.show() + for _ in range(3): + app.processEvents() + rail_height = window.brand_bar.height() + window.toolbar.height() + check( + rail_height <= top_area_limit, + f"{theme}: the collapsed top area is {rail_height}px (limit {top_area_limit})", + ) + check( + not window.toolbar.settings_expanded(), + f"{theme}: the settings area starts expanded", + ) + check(window.toolbar.status_chip.text().strip(), f"{theme}: the phase chip is empty") + check( + window.sensor_picker.isVisible(), + f"{theme}: the sensor picker is not on the status rail", + ) + check( + window.sensor_picker.type_label.text() == t("sensor.type") + and window.sensor_picker.config_label.text() == t("sensor.config"), + f"{theme}: the sensor picker labels are not the documented ones: " + f"{window.sensor_picker.type_label.text()!r} / " + f"{window.sensor_picker.config_label.text()!r}", + ) + check( + window.sensor_picker.thumb_label.text() == "BOX", + f"{theme}: the product badge does not name the sensor: " + f"{window.sensor_picker.thumb_label.text()!r}", + ) + check( + window.sensor_picker.config_label.toolTip() != "", + f"{theme}: the sensor config picker has no explanation", + ) + check( + window.toolbar.sensor_summary.text() != "", + f"{theme}: the rail does not name the capture source", + ) + check( + window.toolbar.calibration_label.text().strip(), + f"{theme}: the rail has no calibration state", + ) + check( + window.toolbar.record_chip.text() == t("record.state_idle"), + f"{theme}: the idle record state is " + f"{window.toolbar.record_chip.text()!r}", + ) + check( + t("status.phase_running") != t("record.state_idle"), + "acquiring and recording do not have distinct labels", + ) + check( + window.toolbar.record_chip.toolTip() != "", + "the record chip does not explain its state", + ) + check( + not rail_squeezed(window), + f"{theme}: the status rail squeezes {rail_squeezed(window)}", + ) + check( + "sensor.json" in window.device_label.text(), + f"{theme}: the status bar does not name the config file: " + f"{window.device_label.text()!r}", + ) + + # The settings area is one click away, and remembers that it was opened. + window.toolbar.btn_settings.setChecked(True) + for _ in range(3): + app.processEvents() + expanded_height = window.brand_bar.height() + window.toolbar.height() + check( + expanded_height > rail_height, + f"{theme}: opening the settings area changed nothing " + f"({expanded_height} vs {rail_height})", + ) + check( + window.toolbar.source_combo.isVisible(), + f"{theme}: the source control is unreachable in the settings area", + ) + check( + window.sensor_picker.isVisible(), + f"{theme}: the sensor picker is unreachable in the settings area", + ) + check( + window.prefs.get("settings_expanded") is True, + f"{theme}: the settings state was not persisted", + ) + check( + window.act_settings.isChecked(), + f"{theme}: the View menu does not follow the settings toggle", + ) + window.toolbar.btn_settings.setChecked(False) + for _ in range(3): + app.processEvents() + check( + not window.toolbar.settings_expanded(), + f"{theme}: the settings area could not be collapsed again", + ) + window.close() + app.processEvents() + + # English labels are the widest case ("Start recording", "Sensor config"), + # and the tightest window is 1280: the rail must still fit both. + set_locale("en_US") + app.setStyleSheet(build_qss("dark")) + english_window = StudioMainWindow( + prefs={"language": "en_US", "theme": "dark", "sensor_type": "finger"} + ) + english_window.resize(1280, 720) + english_window.show() + for _ in range(3): + app.processEvents() + check( + not rail_squeezed(english_window), + f"en_US at 1280x720: the status rail squeezes {rail_squeezed(english_window)}", + ) + check( + "FINGER" == english_window.sensor_picker.thumb_label.text(), + f"the product badge is truncated: " + f"{english_window.sensor_picker.thumb_label.text()!r}", + ) + english_window.close() + app.processEvents() + set_locale("zh_CN") + + # Exactly one *filled* action at a time: a red Stop dominating an otherwise + # idle top area was the original complaint. + app.setStyleSheet(build_qss("dark")) + window = StudioMainWindow( + prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"} + ) + window.resize(1440, 880) + window.show() + app.processEvents() + check( + window.toolbar.btn_run.objectName() == "primaryButton", + f"idle Run is not the filled primary: {window.toolbar.btn_run.objectName()!r}", + ) + check( + not window.toolbar.btn_record.isEnabled(), + "recording is offered without a session", + ) + + stuck = _StuckWorker() + window._ensure_workers = lambda: None + window._box_worker = stuck + window._finger_worker = stuck + window.toolbar.btn_run.setChecked(True) + app.processEvents() + check( + window.toolbar.btn_run.objectName() == "dangerButton", + "Cancel is not the filled action while a connect hangs", + ) + stuck.emit(_fake_packet(1)) + window._on_run_tick() + app.processEvents() + check(window._session_phase == "running", "the session never reached running") + check( + window.toolbar.btn_run.objectName() == "dangerOutline", + f"Stop competes with the record action: " + f"{window.toolbar.btn_run.objectName()!r}", + ) + check( + window.toolbar.btn_record.objectName() == "primaryButton", + "the record action is not the filled primary while acquiring", + ) + check( + window._calibration_summary() == t("status.calibrated_all"), + f"a fully calibrated packet is not reported as such: " + f"{window._calibration_summary()!r}", + ) + window.toolbar.set_record_checked(True) + app.processEvents() + check( + window.toolbar.btn_record.objectName() == "dangerButton", + "stopping the recording is not the filled danger", + ) + check( + window.toolbar.btn_run.objectName() == "dangerOutline", + "two filled actions share the rail while recording", + ) + + real_stats = window._record_stats + window._record_stats = lambda: ( + _record_states.RECORDING, + _RecordStats(duration_s=12.4, frames=372, segments=1, dropped_data=3), + "20240101T000000Z_session0001_deadbeef", + ) + window._refresh_record_state() + app.processEvents() + chip = window.toolbar.record_chip + check( + "12.4" in chip.text(), + f"the record strip does not show the duration: {chip.text()!r}", + ) + check( + t("record.dropped", count=3) in chip.text(), + f"dropped frames are not surfaced: {chip.text()!r}", + ) + check( + "3" in chip.toolTip() and "372" in chip.toolTip(), + f"the record tooltip has no detail: {chip.toolTip()!r}", + ) + window._record_stats = lambda: ( + _record_states.ERROR, + _RecordStats( + frames=10, segments=1, failed=True, write_errors=2, last_error="disk full" + ), + "", + ) + window._refresh_record_state() + check( + chip.text() == t("record.state_error", count=2), + f"a write failure is not reported: {chip.text()!r}", + ) + check( + "disk full" in chip.toolTip(), + f"the record tooltip hides the failure reason: {chip.toolTip()!r}", + ) + window._record_stats = real_stats + window.toolbar.set_record_checked(False) + window._stop_run(reason="user_stop") + stuck.release() + window.close() + app.processEvents() + section("stopping a hung connect") stuck = _StuckWorker() window = StudioMainWindow( @@ -979,6 +1247,301 @@ def main() -> int: app.processEvents() stuck.release() + section("instrument panels") + window = StudioMainWindow( + prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"} + ) + window.resize(1440, 880) + window.show() + app.processEvents() + + plots_panel = window.force_plots + check( + plots_panel.value_group.y() < plots_panel.plot_frame.y(), + "the axis readouts are not above the charts", + ) + check( + all(label.text() for label in plots_panel.value_labels), + "an axis readout is empty before any sample", + ) + for label in plots_panel.value_labels: + check( + label.height() >= label.fontMetrics().height(), + "an axis readout is clipped", + ) + check( + label.fontMetrics().height() >= 20, + f"the axis readouts are not large digits: " + f"{label.font().family()} {label.fontMetrics().height()}px", + ) + check( + [plot.getAxis("bottom").style.get("showValues") for plot in plots_panel.plots] + == [False, False, True], + "the three charts do not share one bottom time axis", + ) + + arrow = window.arrow_panel + check( + arrow.scale_label.isVisible() and arrow.scale_label.text(), + "the arrow view does not show its display magnification", + ) + check( + "2" in arrow.scale_label.text(), + f"the box magnification is not the documented 2x: {arrow.scale_label.text()!r}", + ) + check( + arrow.pipeline_label.isVisible() and arrow.pipeline_label.text(), + "the arrow view does not name its pipeline", + ) + check( + arrow.area_label.isVisible() and arrow.area_label.text(), + "the arrow view does not report the valid area", + ) + check( + not arrow.colorbar.isVisible(), + "the depth colour bar is shown in the arrow view", + ) + arrow.set_view_mode("depth") + arrow.set_depth_scale("深度色标 0.000 – 1.000") + for _ in range(2): + app.processEvents() + check(arrow.colorbar.isVisible(), "the depth colour bar is hidden in the depth view") + check( + not arrow.scale_label.isVisible(), + "the arrow magnification is still shown in the depth view", + ) + check( + arrow.depth_scale_label.isVisible() and arrow.depth_scale_label.text(), + "the depth range is not shown next to its colour bar", + ) + arrow.set_view_mode("arrows_pure") + for _ in range(2): + app.processEvents() + check( + not arrow.colorbar.isVisible() and arrow.scale_label.isVisible(), + "leaving the depth view did not restore the arrow legend", + ) + + # The overview strip is the timeline control: dragging it pans the window. + stuck = _StuckWorker() + window._ensure_workers = lambda: None + window._box_worker = stuck + window._finger_worker = stuck + window.toolbar.btn_run.setChecked(True) + app.processEvents() + for index in range(120): + stuck.emit(_fake_packet(index + 100)) + window._on_run_tick() + for _ in range(3): + app.processEvents() + check( + window.session_store.count == 120, + f"the session store missed samples: {window.session_store.count}", + ) + first, last = window.session_store.extent() + check(last > first, "the session store has no extent to browse") + check( + plots_panel.window_label.text() != "", + "the overview strip has no window readout", + ) + plots_panel.show_all() + app.processEvents() + check(plots_panel.mode() == "browse", "the overview button did not enter browsing") + target = (first + (last - first) * 0.25, first + (last - first) * 0.5) + plots_panel.overview_region.setRegion(target) + for _ in range(3): + app.processEvents() + dragged = plots_panel.window() + check( + abs(dragged[0] - target[0]) < 0.05 and abs(dragged[1] - target[1]) < 0.05, + f"dragging the overview did not move the window: {dragged} vs {target}", + ) + check( + plots_panel.mode() == "browse", + "dragging the overview left the charts in live mode", + ) + window.brand_bar.btn_live.click() + for _ in range(2): + app.processEvents() + check( + plots_panel.mode() == "follow", + "the brand rail's live item did not return to live mode", + ) + check( + window.brand_bar.btn_live.property("navActive") == "true", + "the live nav item is not marked as the current view", + ) + window.act_cal_panel.setChecked(False) + app.processEvents() + window.brand_bar.btn_calibration.click() + app.processEvents() + check( + window.cal_panel.isVisible(), + "the calibration nav item did not reveal the panel", + ) + window.brand_bar.btn_calibration.click() + for _ in range(3): + app.processEvents() + from PyQt5.QtWidgets import QHeaderView as _QHeaderView + + sizes = window.content_splitter.sizes() + check( + window._view_preset == "calibration", + f"the calibration nav item did not switch the view: {window._view_preset!r}", + ) + check( + window.cal_panel.isVisible() and not window.arrow_panel.isVisible(), + "the calibration view does not trade the 2D panel for the calibration panel", + ) + check( + sizes[3] >= 500, + f"the calibration panel is too narrow for its table: {sizes[3]}px", + ) + check( + window.cal_panel.table.horizontalHeader().sectionResizeMode(0) + == _QHeaderView.ResizeToContents, + "the calibration columns can still be elided instead of scrolled", + ) + check( + window.cal_panel.scroll.isVisible(), + "the calibration panel cannot scroll to its own controls", + ) + for button in ( + window.cal_panel.btn_apply, + window.cal_panel.btn_export, + window.cal_panel.btn_toggle_validation, + ): + check( + button.height() >= button.fontMetrics().height(), + f"a calibration button is cut off: {button.text()!r}", + ) + check( + button.width() >= button.sizeHint().width(), + f"a calibration button is clipped: {button.text()!r} " + f"({button.width()} < {button.sizeHint().width()})", + ) + + window.brand_bar.btn_playback.click() + for _ in range(3): + app.processEvents() + check( + window._view_preset == "replay", + f"the replay nav item did not switch the view: {window._view_preset!r}", + ) + check( + not window.cal_panel.isVisible() and window.arrow_panel.isVisible(), + "the replay view is not the 2D + charts arrangement", + ) + check( + window.force_plots.mode() == "browse", + "the replay view did not open on the whole session", + ) + + window.brand_bar.btn_live.click() + for _ in range(3): + app.processEvents() + sizes = window.content_splitter.sizes() + check( + window._view_preset == "live", + f"the live nav item did not switch the view: {window._view_preset!r}", + ) + check( + sizes[2] > sizes[1], + f"the charts are not given more room than the 2D panel: {sizes}", + ) + check( + window.force_plots.mode() == "follow", + "the live view did not resume following the latest samples", + ) + check( + window.brand_bar.btn_live.property("navActive") == "true", + "the live nav item is not marked as the current view", + ) + window._stop_run(reason="user_stop") + stuck.release() + for _ in range(3): + app.processEvents() + + # The 2D area keeps the shape of the image it shows, instead of stretching a + # 16:9 frame into whatever the panel happens to be. + finger_window = StudioMainWindow( + prefs={ + "language": "zh_CN", + "theme": "dark", + "sensor_type": "finger", + "show_finger_3d": False, + } + ) + finger_window.resize(1440, 880) + finger_window.show() + for _ in range(3): + app.processEvents() + image = finger_window.arrow_panel.image_label + check( + abs(image.aspect() - 16.0 / 9.0) < 0.01, + f"the 2D area is not 16:9 for a finger sensor: {image.aspect()}", + ) + check( + abs(image.height() - image.width() / image.aspect()) <= 2, + "the 2D image area does not follow the output image's aspect ratio: " + f"{image.width()}x{image.height()}", + ) + finger_window.close() + app.processEvents() + check( + window.toolbar.record_chip.text() == t("record.state_idle"), + f"the record strip did not fall back to idle: " + f"{window.toolbar.record_chip.text()!r}", + ) + window.close() + app.processEvents() + + section("clean shutdown") + # A camera SDK leaves native threads behind; the entry point must still come + # back to the console. This runs the real ``main()`` in a child process and + # closes the window from a timer, so a hang shows up as a timeout. + import subprocess as _subprocess + + script = ( + "import os, sys\n" + "os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\n" + "os.environ.setdefault('QT_QPA_FONTDIR', r'C:\\Windows\\Fonts')\n" + "from PyQt5.QtCore import QTimer\n" + "import orisys_studio.main as studio_main\n" + "show = studio_main.StudioMainWindow.show\n" + "def show_and_close(self):\n" + " show(self)\n" + " QTimer.singleShot(400, self.close)\n" + "studio_main.StudioMainWindow.show = show_and_close\n" + "raise SystemExit(studio_main.main())\n" + ) + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join( + [str(REPO_ROOT / "shared" / "src"), str(APP_ROOT / "src"), env.get("PYTHONPATH", "")] + ) + started = time.perf_counter() + try: + completed = _subprocess.run( + [sys.executable, "-c", script], + cwd=str(APP_ROOT), + env=env, + capture_output=True, + timeout=60, + ) + elapsed = time.perf_counter() - started + print(f" window closed and the process returned in {elapsed:.1f}s") + check( + completed.returncode == 0, + f"the app exited with {completed.returncode}: " + f"{completed.stderr[-400:]!r}", + ) + check( + elapsed < 45.0, + f"closing the window did not return to the console ({elapsed:.1f}s)", + ) + except _subprocess.TimeoutExpired: + check(False, "the app never exited after its window was closed") + section("experiment record") import shutil as _shutil diff --git a/apps/studio/tools/ui_snapshot.py b/apps/studio/tools/ui_snapshot.py index 4eed7d8..7f06b88 100644 --- a/apps/studio/tools/ui_snapshot.py +++ b/apps/studio/tools/ui_snapshot.py @@ -9,6 +9,7 @@ Examples python tools/ui_snapshot.py --theme dark --out out_dark.png python tools/ui_snapshot.py --theme light --sensor finger \ --panels cal,3d --demo --size 1600x900 --out out_light.png + python tools/ui_snapshot.py --demo --scene recording --out out_recording.png """ from __future__ import annotations @@ -34,6 +35,61 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: os.chdir(APP_ROOT) +def _run_scene(window, app, scene: str): + """Drive the window into a scene that would otherwise need hardware. + + ``recording`` mocks the record channel's stats through the same single seam + the app reads them from (``_record_stats``), so nothing is written to disk + and no device is opened: the render is of the real widgets, not of a mockup. + """ + from ui_check import _fake_packet, _StuckWorker + + stuck = _StuckWorker() + window._ensure_workers = lambda: None + window._box_worker = stuck + window._finger_worker = stuck + window.toolbar.btn_run.setChecked(True) + for _ in range(4): + app.processEvents() + if scene != "recording": + return + + # Real packets so the phase reaches ``running`` and the rail holds real + # values; the recording itself is the only mocked part. + for index in range(300): + stuck.emit(_fake_packet(index)) + window._on_run_tick() + for _ in range(4): + app.processEvents() + window._run_timer.stop() + + from orisys_app_shared.record.experiment import RecordStats + + from orisys_studio.ui import record_status + + directory = "20240101T101500Z_session0001_a1b2c3d4e5f6" + window.toolbar.set_record_checked(True) + window.toolbar.set_record_status(directory) + window._record_stats = lambda: ( + record_status.RECORDING, + RecordStats( + duration_s=12.4, + frames=372, + segments=1, + dropped_data=3, + bytes_written=48_000_000, + ), + directory, + ) + window._refresh_record_state() + # A visible window inside the session, like the mock's 60-65 s of 0-65 s. + first, last = window.session_store.extent() + if last > first: + window.force_plots.set_window(max(first, last - 6.0), last) + for _ in range(4): + app.processEvents() + + def _demo_data(window) -> None: """Fill the plots, the calibration table and the arrow canvas.""" plots = window.force_plots @@ -95,12 +151,22 @@ def main() -> int: ) parser.add_argument("--size", default="1440x880", help="WxH window size") parser.add_argument("--view", default="arrows_pure") + parser.add_argument( + "--preset", + choices=("custom", "live", "replay", "calibration"), + default="custom", + help="top-level view to render (the same layouts the brand rail switches)", + ) parser.add_argument("--demo", action="store_true", help="inject demo plot/table data") parser.add_argument( "--scene", - choices=("idle", "connecting"), + choices=("idle", "connecting", "recording"), default="idle", - help="connecting renders the Stop/cancel state of a hung device open", + help=( + "connecting renders the Stop/cancel state of a hung device open; " + "recording renders the record strip of a running recording " + "(mocked stats: no file is written, no device is opened)" + ), ) parser.add_argument("--out", required=True, help="output PNG path") args = parser.parse_args() @@ -130,27 +196,23 @@ def main() -> int: "show_finger_3d": "3d" in panels, "show_fps": True, "image_view_mode": args.view, + "view_preset": args.preset, } window = StudioMainWindow(prefs=prefs) window.resize(width, height) window.show() app.processEvents() - if args.demo: - _demo_data(window) - for _ in range(4): - app.processEvents() - if args.scene == "connecting": # A device open that never returns: the state the Stop button must # survive. ``ui_check._StuckWorker`` drives the same window state. - from ui_check import _StuckWorker + _run_scene(window, app, "connecting") - stuck = _StuckWorker() - window._ensure_workers = lambda: None - window._box_worker = stuck - window._finger_worker = stuck - window.toolbar.btn_run.setChecked(True) + if args.scene == "recording": + _run_scene(window, app, "recording") + + if args.demo: + _demo_data(window) for _ in range(4): app.processEvents() diff --git a/shared/src/orisys_app_shared/acquisition/arrows.py b/shared/src/orisys_app_shared/acquisition/arrows.py index 29617aa..b2380b7 100644 --- a/shared/src/orisys_app_shared/acquisition/arrows.py +++ b/shared/src/orisys_app_shared/acquisition/arrows.py @@ -96,6 +96,25 @@ def flow_colormap(theme: str = "dark") -> np.ndarray: return _lut_from_ramp(color(theme, "flow_ramp", FLOW_RAMP_FALLBACK)) +def arrow_display_scale(draw_style: str = "box") -> float: + """How much the drawn arrows amplify the measured displacement. + + ``orisys.util.draw_arrows`` divides the displacement by ``arrow_scale``, so + the effective display magnification is its reciprocal (box ``0.5`` -> ``2x``, + finger ``1.0`` -> ``1x``). The drawn length is additionally capped at + ``grid_spacing * 0.8`` pixels, so the returned factor describes the mapping + only while an arrow has not hit that cap — the UI says so in its tooltip. + """ + draw_kw = _FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW + try: + scale = float(draw_kw["arrow_scale"]) + except (KeyError, TypeError, ValueError): + return 1.0 + if scale <= 0: + return 1.0 + return 1.0 / scale + + def _ensure_bgr(img: np.ndarray) -> np.ndarray: if img.ndim == 2: return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) diff --git a/shared/src/orisys_app_shared/prefs/store.py b/shared/src/orisys_app_shared/prefs/store.py index 95bb363..bc31d34 100644 --- a/shared/src/orisys_app_shared/prefs/store.py +++ b/shared/src/orisys_app_shared/prefs/store.py @@ -32,6 +32,12 @@ _DEFAULTS: dict[str, Any] = { "image_view_mode": "arrows_pure", "arrow_rotation_deg": 90, "show_fps": False, + #: The top rail's settings area (source / camera / config / units) starts + #: collapsed: the rail is for state, not configuration. + "settings_expanded": False, + #: Top-level view: "custom" (a hand-arranged layout), "live", "replay" or + #: "calibration". Each view is a layout of the same window. + "view_preset": "custom", "splitter_sizes": [340, 480, 400], "window_default_size": [1440, 880], } diff --git a/shared/src/orisys_app_shared/theme/colors_dark.json b/shared/src/orisys_app_shared/theme/colors_dark.json index a7bb2f1..bb891b9 100644 --- a/shared/src/orisys_app_shared/theme/colors_dark.json +++ b/shared/src/orisys_app_shared/theme/colors_dark.json @@ -20,9 +20,9 @@ "scrollbar_handle_hover": "#6E7C9E", "plot_bg": "#11161F", "plot_grid": "#28303F", - "plot_fn": "#8AA6F2", - "plot_fx": "#F45CC0", - "plot_fy": "#D8FF00", + "plot_fn": "#6FA8FF", + "plot_fx": "#FF9A4D", + "plot_fy": "#7CE0A0", "text_primary": "#E6EAF2", "text_secondary": "#9AA4B8", "text_muted": "#838FA6", diff --git a/shared/src/orisys_app_shared/theme/colors_light.json b/shared/src/orisys_app_shared/theme/colors_light.json index 66d5389..6e42185 100644 --- a/shared/src/orisys_app_shared/theme/colors_light.json +++ b/shared/src/orisys_app_shared/theme/colors_light.json @@ -21,8 +21,8 @@ "plot_bg": "#F8FAFD", "plot_grid": "#DFE5EE", "plot_fn": "#003893", - "plot_fx": "#B0178C", - "plot_fy": "#4E7A00", + "plot_fx": "#B45309", + "plot_fy": "#2E7D32", "text_primary": "#141A24", "text_secondary": "#4A5566", "text_muted": "#5C6678", diff --git a/shared/src/orisys_app_shared/theme/qss.py b/shared/src/orisys_app_shared/theme/qss.py index bb9a1a9..2d8ab0f 100644 --- a/shared/src/orisys_app_shared/theme/qss.py +++ b/shared/src/orisys_app_shared/theme/qss.py @@ -175,6 +175,29 @@ QFrame#brandBar QFrame#sectionSeparator {{ max-width: 1px; min-width: 1px; }} +/* Top-rail navigation: flat items on the brand background, so the product + colour stays the only strong colour in the rail. */ +QFrame#brandBar QPushButton#navButton {{ + background-color: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: {c['brand_bar_fg_muted']}; + font-size: 11px; + font-weight: 500; + min-height: 18px; + padding: 1px 7px; +}} +QFrame#brandBar QPushButton#navButton:hover {{ + color: {c['brand_bar_fg']}; + border-color: {c['brand_bar_fg_muted']}; +}} +QFrame#brandBar QPushButton#navButton[navActive="true"] {{ + color: {c['brand_bar_fg']}; + font-weight: 600; +}} +QFrame#brandBar QPushButton#navButton:focus {{ + border-color: {c['brand_bar_fg']}; +}} /* ---------- Buttons ---------- */ QPushButton {{ background-color: {c['button_bg']}; @@ -249,10 +272,63 @@ QPushButton#ghostButton:hover {{ background-color: {c['button_hover']}; color: {c['text_primary']}; }} +/* The settings toggle advertises its own state: an open area must look open. */ +QPushButton#ghostButton[settingsOpen="true"] {{ + background-color: {c['button_hover']}; + color: {c['text_primary']}; + border-color: {c['accent']}; +}} +/* The "outlined danger" affordance: a red action that is *not* the one filled + button of the moment. Stop stays red and one click away without competing + with the primary record action. */ +QPushButton#dangerOutline {{ + background-color: transparent; + color: {c['danger']}; + border: 1px solid {c['danger']}; + font-weight: 600; + padding: 7px 18px; +}} +QPushButton#dangerOutline:hover {{ + background-color: {c['danger_fill']}; + color: {c['danger_text']}; + border-color: {c['danger_fill']}; +}} +QPushButton#dangerOutline:pressed {{ + background-color: {c['danger_fill_hover']}; + color: {c['danger_text']}; + border-color: {c['danger_fill']}; +}} +/* Checked is the *running* state of Stop, not a press: it stays outlined, or + the outline variant would fill in permanently the moment a session runs. */ +QPushButton#dangerOutline:checked {{ + background-color: transparent; + color: {c['danger']}; + border-color: {c['danger']}; +}} +QPushButton#dangerOutline:disabled {{ + color: {c['text_muted']}; + background-color: {c['window_bg']}; + border: 1px solid {c['separator']}; +}} QPushButton[compact="true"] {{ padding: 6px 9px; font-size: 12px; }} +/* Status-rail controls are one instrument line: they must not carry the + padding a dialog button needs, or the top of the window grows to twice the + height of the rail's own content. */ +QPushButton[rail="true"], +QPushButton#primaryButton[rail="true"], +QPushButton#dangerButton[rail="true"], +QPushButton#dangerOutline[rail="true"], +QPushButton#ghostButton[rail="true"] {{ + min-height: 18px; + padding: 2px 12px; +}} +QComboBox[rail="true"] {{ + min-height: 18px; + padding: 2px 8px; +}} QToolButton {{ background-color: transparent; color: {c['text_secondary']}; @@ -507,9 +583,8 @@ QLabel#unitBadge {{ letter-spacing: 0.4px; }} QLabel#valueCard {{ - background-color: {c['card_bg']}; - border: 1px solid {c['card_border']}; - border-radius: 8px; + background: transparent; + border: none; }} QLabel#statusChip {{ color: {c['status_idle']}; @@ -517,6 +592,14 @@ QLabel#statusChip {{ font-weight: 600; padding: 0 10px 0 4px; }} +/* Record state: colour is written per state by ``RecordStatusChip``; the base + rule only fixes the weight and the spacing. */ +QLabel#recordChip {{ + color: {c['status_idle']}; + background: transparent; + font-weight: 600; + padding: 0 8px 0 2px; +}} QLabel#aboutBody {{ color: {c['text_secondary']}; }} @@ -531,6 +614,12 @@ QFrame#cardPanel {{ border: 1px solid {c['card_border']}; border-radius: 8px; }} +/* The axis readouts are large numbers on the panel, not three boxed cards: + three borders side by side is exactly the "fragmented" look to avoid. */ +QFrame#valueGroup {{ + background: transparent; + border: none; +}} QFrame#sectionSeparator {{ background-color: {c['separator']}; border: none;