界面改用「精密仪器工作台」布局:顶栏压缩、视图切布局、标定与回放面板不再裁切

- What: 品牌条 40→28px 并加 实时监测/历史回放/标定 导航;导航改为切换主界面三套布局(实时=2D图像+曲线、回放=自动全程浏览、标定=标定面板 880px+曲线,2D 图像整体缩小);传感器选择(传感器 / 传感器配置 两个带 tooltip 的下拉 + 产品徽标)移到状态轨最左侧,记录模式移入设置区;新增 RecordStatusChip(记录中时长、丢帧、写入异常)与「同一时刻只有一个实心按钮」(新增 dangerOutline 描边危险样式);三轴读数移到曲线上方(16pt 等宽)、只保留最下方时间轴、网格淡化、全程浏览条可拖动;三轴配色统一为蓝/橙/绿并把语义色对比度审计扩展到 toolbar_bg;箭头页脚显示放大倍率与有效区域,深度模式才显示色条与范围;左侧面板改名「2D 图像」且固定显示图像宽高比;标定面板加纵向滚动区、九按钮分两行、六列表头 ResizeToContents;PlaybackDialog 补上从未调用的 retranslate;main() 在事件循环结束后 flush 日志并 os._exit,避免设备 SDK 原生线程把进程挂在解释器收尾。
- Why: 评审提出的 7 点:顶部占高与装饰过多、记录状态不突出、配置切换看不懂且藏得太深、切视图应换主界面布局、标定与回放面板文字被裁、关窗后命令行不退出、2D 图像无固定宽高比且名字不符。
- Verification: ui_check.py(430 项,新增 top area / instrument panels / clean shutdown 段,含中英×1280/1440 的轨不挤压检查)、test_ui_smoke.py(57 项,新增视图切换、标定不裁切、2D 宽高比、回放按钮文字、传感器选择器等用例)、drive_shared.py(146 项)、locale_studio.py(261 键齐全、zh==en、prefs 对齐)、reproduce.py、ui_snapshot.py 四张复核图。
- Not in this change: 录制文件格式、CSV/MP4 写入路径与记录通道数据流不变;三维面板仍是占位符;回放对话框(含离线深度重算)本身只修了按钮文字。
This commit is contained in:
ykcheungab
2026-09-13 18:54:31 +08:00
parent 0f4552cd4e
commit 35e86ec1f7
26 changed files with 2790 additions and 304 deletions
+335 -34
View File
@@ -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)
+19 -1
View File
@@ -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__":
+217 -40
View File
@@ -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)
+72 -12
View File
@@ -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"))
+45 -18
View File
@@ -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()
@@ -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())
+169 -44
View File
@@ -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()
@@ -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:
@@ -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; }"
)
@@ -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()
+247 -69
View File
@@ -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()