diff --git a/.gitignore b/.gitignore index a81c655..f355e45 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,11 @@ venv/ **/preference.json apps/customer/preference.json +# Tooling scratch (local pip/pytest caches) +.piptmp/ +.pytestlib/ +.pytest_cache/ + # IDE / OS .idea/ .vscode/ diff --git a/README.md b/README.md index 2e8ed5e..791ec6e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,45 @@ $env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\custome python D:\repo\orisys-app\apps\customer\run.py ``` +### Local `orisys` SDK + +`orisys_app_shared.force` imports `orisys.calibration` at module scope, so the +customer app cannot start against an SDK build that predates that module +(the published 0.5.1 wheel does not include it). When working from a repo +checkout, install the SDK from source instead of from an index: + +```powershell +python -m pip install .\..\orisys-sdk # same checkout that built this app +``` + +If a pip build is blocked by the environment, the equivalent manual install is +to copy `orisys-sdk/src/orisys/**/*.py` over `site-packages/orisys/` and delete +the stale `__init__.cp310-win_amd64.pyd` (an extension module shadows the +package `__init__.py`, which hides sibling modules such as `calibration.py`). + +### Verifying UI changes + +Both tools render the real main window with the offscreen Qt platform — no +display, camera or sensor hardware required: + +```powershell +python .\apps\customer\tools\ui_check.py # layout/theme assertions +python .\apps\customer\tools\ui_snapshot.py --theme light --demo ` + --sensor finger --panels cal,3d --size 1600x900 --out shot.png +``` + +`ui_check.py` needs only the runtime dependencies. The `pytest` suite in +`shared/tests` and `apps/customer/tests` covers the same ground plus locale and +preference-schema parity: + +```powershell +pip install pytest +python -m pytest +``` + +On Windows the offscreen platform finds no fonts unless `QT_QPA_FONTDIR` points +at `C:\Windows\Fonts`; both tools set it automatically. + ## Branding Window / EXE icon: `shared/resources/branding/logo.png` diff --git a/apps/customer/config/compound_eye.png b/apps/customer/config/compound_eye.png index 2879966..7ea4e12 100644 Binary files a/apps/customer/config/compound_eye.png and b/apps/customer/config/compound_eye.png differ diff --git a/apps/customer/config/locales/en_US.json b/apps/customer/config/locales/en_US.json index cbc8fc7..c2a12c2 100644 --- a/apps/customer/config/locales/en_US.json +++ b/apps/customer/config/locales/en_US.json @@ -1,6 +1,6 @@ { "app": { - "title": "Orisys App", + "title": "Orisys Customer", "about_title": "About", "about_body": "Orisys customer desktop application\nVersion: {app_version}", "scaffold_hint": "Scaffold shell — acquisition coming later" @@ -21,6 +21,9 @@ "show_fps": "Show FPS", "plot_settings": "Plot settings…", "rotate_arrows": "Rotate arrows 90°", + "run": "Run / Stop", + "preview": "Preview / Stop preview", + "reset": "Reset tracker", "help": "Help", "about": "About" }, @@ -48,15 +51,24 @@ "preview_stop": "Stop preview", "run": "Run", "stop": "Stop", + "run_label": "▶ Run", + "stop_label": "■ Stop", + "run_unavailable": "Not available yet", + "run_disabled_video": "Select a video file first", + "run_disabled_sensor": "Run is not available for this sensor type", "reset": "Reset", "units": "Units", "units_newton": "Newton", "units_raw": "Raw" }, + "units": { + "newton_short": "N", + "raw_short": "raw" + }, "sensor": { "type": "Sensor type", "config": "Config", - "thumb_placeholder": "Thumbnail" + "thumb_placeholder": "Product model" }, "panels": { "arrow": "2D arrows", @@ -64,21 +76,34 @@ "view_arrows_stitched": "Arrows / Captured", "view_depth": "Depth map", "view_mode_tip": "Image display mode while running", + "arrow_empty": "No image yet\nPick a video or camera, then press Run", + "arrow_empty_tip": "The deformation arrow image appears here while running or previewing", "plots": "Force Charts", "cal": "Calibration", "mesh3d": "3D mesh", - "mesh3d_hint": "Finger 3D view placeholder — Open3D mesh coming later" + "mesh3d_empty": "Finger 3D view placeholder —\nOpen3D mesh coming later", + "mesh3d_empty_tip": "3D mesh preview for finger sensors" + }, + "chart": { + "fn": "FN", + "fx": "FX", + "fy": "FY", + "no_data": "—" }, "cal": { "weight": "Weight (N)", "duration": "Record duration", "record": "Record", + "recording_stop": "Stop", + "record_disabled": "Press Run to start acquisition before recording samples", + "record_cancelled": "Recording cancelled", + "empty_hint": "No samples for this channel yet: run, pick a channel and press Record.", "delete_row": "Delete row", "clear_channel": "Clear channel", "clear_all": "Clear all", - "export": "Export…", - "sn": "SN", + "export": "Export…", "sn": "SN", "sn_placeholder": "Serial number (optional)", + "live_raw_label": "Live raw", "live_raw": "Live raw FN={fn} FX={fx} FY={fy}", "col_weight": "Weight (N)", "col_raw": "Raw mean", @@ -95,6 +120,10 @@ }, "status": { "ready": "Ready", + "phase_idle": "Idle", + "phase_connecting": "Connecting", + "phase_running": "Running", + "phase_error": "Error", "camera_none": "No cameras found", "camera_found": "Found {count} camera(s): {names}", "preview_started": "Preview started (camera {camera_id})", diff --git a/apps/customer/config/locales/zh_CN.json b/apps/customer/config/locales/zh_CN.json index 414944d..ba70bbf 100644 --- a/apps/customer/config/locales/zh_CN.json +++ b/apps/customer/config/locales/zh_CN.json @@ -1,6 +1,6 @@ { "app": { - "title": "Orisys 应用", + "title": "Orisys 客户应用", "about_title": "关于", "about_body": "Orisys 客户端桌面应用\n版本: {app_version}", "scaffold_hint": "脚手架界面 — 采集功能稍后加入" @@ -21,6 +21,9 @@ "show_fps": "显示 FPS", "plot_settings": "绘图设置…", "rotate_arrows": "旋转箭头图 90°", + "run": "运行 / 停止", + "preview": "预览 / 停止预览", + "reset": "重置追踪器", "help": "帮助", "about": "关于" }, @@ -48,15 +51,24 @@ "preview_stop": "停止预览", "run": "运行", "stop": "停止", + "run_label": "▶ 运行", + "stop_label": "■ 停止", + "run_unavailable": "当前不可运行", + "run_disabled_video": "请先选择一个视频文件", + "run_disabled_sensor": "此传感器类型暂不支持运行", "reset": "重置", "units": "单位", "units_newton": "牛顿", "units_raw": "原始" }, + "units": { + "newton_short": "N", + "raw_short": "raw" + }, "sensor": { "type": "传感器类型", "config": "配置", - "thumb_placeholder": "缩略图" + "thumb_placeholder": "产品型号" }, "panels": { "arrow": "形变箭头", @@ -64,21 +76,35 @@ "view_arrows_stitched": "箭头/采集图像", "view_depth": "深度图", "view_mode_tip": "运行时的图像显示模式", + "arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」", + "arrow_empty_tip": "运行或预览后在此显示形变箭头图", "plots": "力曲线图", "cal": "标定", "mesh3d": "三维网格", - "mesh3d_hint": "手指三维视图占位 — Open3D 网格稍后加入" + "mesh3d_empty": "手指三维视图占位 —\nOpen3D 网格稍后加入", + "mesh3d_empty_tip": "手指传感器的三维网格预览" + }, + "chart": { + "fn": "FN", + "fx": "FX", + "fy": "FY", + "no_data": "—" }, "cal": { "weight": "砝码重量 (N)", "duration": "采集时长", "record": "记录", + "recording_stop": "停止", + "record_disabled": "请先点击「运行」开始采集,再记录标定样本", + "record_cancelled": "已取消本次采集", + "empty_hint": "当前通道尚无样本:运行后选择通道并点击「记录」。", "delete_row": "删除行", - "clear_channel": "清空当前通道", - "clear_all": "清空全部", + "clear_channel": "清空通道", + "clear_all": "全部清空", "export": "导出…", "sn": "序列号", "sn_placeholder": "序列号(可选)", + "live_raw_label": "实时原始值", "live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}", "col_weight": "重量 (N)", "col_raw": "原始均值", @@ -95,6 +121,10 @@ }, "status": { "ready": "就绪", + "phase_idle": "空闲", + "phase_connecting": "连接中", + "phase_running": "运行中", + "phase_error": "出错", "camera_none": "未发现摄像机", "camera_found": "发现 {count} 个摄像机: {names}", "preview_started": "预览已启动(摄像机 {camera_id})", diff --git a/apps/customer/config/preferences.default.json b/apps/customer/config/preferences.default.json index e8e45c0..dbb5f9c 100644 --- a/apps/customer/config/preferences.default.json +++ b/apps/customer/config/preferences.default.json @@ -2,11 +2,13 @@ "language": "zh_CN", "theme": "dark", "window_geometry": null, + "window_default_size": [1440, 880], "camera_id": "0", "source_mode": "camera", "video_path": "", "show_cal_panel": false, "show_finger_3d": true, + "show_fps": false, "sensor_type": "box", "config_name": "", "units": "newton", @@ -14,5 +16,6 @@ "plot_fn_max": 20.0, "plot_shear_max": 20.0, "image_view_mode": "arrows_pure", - "arrow_rotation_deg": 90 + "arrow_rotation_deg": 90, + "splitter_sizes": [340, 480, 400] } diff --git a/apps/customer/src/orisys_customer/app_window.py b/apps/customer/src/orisys_customer/app_window.py index ba698d9..0438053 100644 --- a/apps/customer/src/orisys_customer/app_window.py +++ b/apps/customer/src/orisys_customer/app_window.py @@ -16,7 +16,9 @@ from PyQt5.QtWidgets import ( QActionGroup, QApplication, QFileDialog, + QFrame, QHBoxLayout, + QLabel, QMainWindow, QMessageBox, QSplitter, @@ -30,7 +32,7 @@ from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.logging import report_error 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, color +from orisys_app_shared.theme import build_qss, clear_theme_cache, color from orisys_customer.ui.about_dialog import AboutDialog from orisys_customer.ui.arrow_panel import ArrowPanel @@ -50,6 +52,14 @@ logger = logging.getLogger(__name__) RUNNABLE_TYPES = frozenset({"box", "mini", "finger"}) ARROW_ROTATE_TYPES = frozenset({"box", "mini"}) +DEFAULT_SPLITTER_SIZES = (340, 480, 400) +MIN_PANEL_WIDTHS = { + "mesh3d": 240, + "arrow": 300, + "plots": 340, + "cal": 300, +} +SESSION_PHASES = ("idle", "connecting", "running", "error") def _bgr_to_qimage(frame: np.ndarray) -> QImage: @@ -109,31 +119,44 @@ class CustomerMainWindow(QMainWindow): root.setSpacing(8) top = QHBoxLayout() + top.setSpacing(10) self.toolbar = ToolbarWidget() 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(self.sensor_picker, 1) + top.addWidget(sensor_card) root.addLayout(top) self.content_splitter = QSplitter(Qt.Horizontal) self.mesh3d_panel = Mesh3dPanel() self.arrow_panel = ArrowPanel() self.force_plots = ForcePlotsPanel(theme=self._theme) - self.cal_panel = CalPanel() + self.cal_panel = CalPanel(theme=self._theme) self.content_splitter.addWidget(self.mesh3d_panel) self.content_splitter.addWidget(self.arrow_panel) self.content_splitter.addWidget(self.force_plots) self.content_splitter.addWidget(self.cal_panel) + self.content_splitter.setChildrenCollapsible(False) root.addWidget(self.content_splitter, 1) self.setStatusBar(QStatusBar()) + self.status_chip = QLabel() + self.status_chip.setObjectName("statusChip") + self.statusBar().addPermanentWidget(self.status_chip) self.cal_panel.status_message.connect(self.statusBar().showMessage) self.cal_panel.calibration_exported.connect(self._on_calibration_exported) + self._set_phase("idle") self._build_menus() self._wire_toolbar() self._wire_sensor_picker() self._restore_prefs_into_ui() self._apply_panel_visibility() + self._restore_splitter_sizes() self._update_run_enabled() self.cal_panel.set_meta_context( sensor_type=self.sensor_picker.sensor_type(), @@ -141,7 +164,7 @@ class CustomerMainWindow(QMainWindow): ) self.retranslate() self._restore_geometry() - self.resize(1280, 720) + self._apply_default_size() @property def _preview_open(self) -> bool: @@ -210,6 +233,34 @@ class CustomerMainWindow(QMainWindow): self.prefs["config_name"] = self.sensor_picker.config_name() self._apply_arrow_rotation_for_sensor() + def _apply_default_size(self) -> None: + """Give the first launch a usable size for the multi-panel layout.""" + default = self.prefs.get("window_default_size") or [1440, 880] + try: + width, height = int(default[0]), int(default[1]) + except (TypeError, ValueError, IndexError): + width, height = 1440, 880 + screen = QApplication.primaryScreen() + if screen is not None: + avail = screen.availableGeometry() + width = min(width, max(1024, avail.width() - 80)) + height = min(height, max(680, avail.height() - 80)) + if not self.prefs.get("window_geometry"): + self.resize(width, height) + + def _restore_splitter_sizes(self) -> None: + stored = self.prefs.get("splitter_sizes") + if isinstance(stored, list) and len(stored) == self.content_splitter.count(): + try: + self.content_splitter.setSizes([int(v) for v in stored]) + return + except (TypeError, ValueError): + pass + sizes = list(DEFAULT_SPLITTER_SIZES) + if not self._show_finger_3d or self.sensor_picker.sensor_type() != "finger": + sizes[0] = 0 + self.content_splitter.setSizes(sizes) + def _build_menus(self) -> None: menu_bar = self.menuBar() @@ -249,6 +300,22 @@ class CustomerMainWindow(QMainWindow): self.view_menu.addAction(self.act_rotate_arrows) self.view_menu.addSeparator() + self.act_run = QAction(self) + self.act_run.setShortcut("F5") + self.act_run.triggered.connect(self._on_run_shortcut) + self.view_menu.addAction(self.act_run) + + self.act_preview = QAction(self) + self.act_preview.setShortcut("F6") + self.act_preview.triggered.connect(self._on_preview_shortcut) + self.view_menu.addAction(self.act_preview) + + self.act_reset = QAction(self) + self.act_reset.setShortcut("Ctrl+R") + self.act_reset.triggered.connect(self._on_reset) + self.view_menu.addAction(self.act_reset) + self.view_menu.addSeparator() + self.theme_menu = self.view_menu.addMenu("") self.theme_group = QActionGroup(self) self.theme_group.setExclusive(True) @@ -304,6 +371,10 @@ class CustomerMainWindow(QMainWindow): show_3d = is_finger and bool(self._show_finger_3d) self.mesh3d_panel.setVisible(show_3d) self.cal_panel.setVisible(self._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"]) + self.cal_panel.setMinimumWidth(MIN_PANEL_WIDTHS["cal"]) self.act_finger_3d.setEnabled(is_finger) self.act_finger_3d.blockSignals(True) self.act_finger_3d.setChecked(show_3d) @@ -311,22 +382,28 @@ class CustomerMainWindow(QMainWindow): for i in range(self.content_splitter.count()): widget = self.content_splitter.widget(i) if widget is self.mesh3d_panel: - self.content_splitter.setStretchFactor(i, 3 if show_3d else 0) + self.content_splitter.setStretchFactor(i, 2 if show_3d else 0) elif widget is self.arrow_panel: self.content_splitter.setStretchFactor(i, 3) elif widget is self.force_plots: - self.content_splitter.setStretchFactor(i, 3) + self.content_splitter.setStretchFactor(i, 4) elif widget is self.cal_panel: - self.content_splitter.setStretchFactor(i, 2 if self._show_cal else 0) + self.content_splitter.setStretchFactor(i, 3 if self._show_cal else 0) def _update_run_enabled(self) -> None: sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES source_ok = True - if self.toolbar.source_mode() == "video" and not self.toolbar.video_path(): + reason = "" + if not sensor_ok: + reason = t("toolbar.run_disabled_sensor") + elif self.toolbar.source_mode() == "video" and not self.toolbar.video_path(): source_ok = False + reason = t("toolbar.run_disabled_video") + self.toolbar.set_run_tooltip_reason(reason) self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok)) if not self._running: - self.toolbar._refresh_run_label() + self.toolbar.refresh_labels() + self._sync_shortcut_actions() def retranslate(self) -> None: self.setWindowTitle(t("app.title")) @@ -339,6 +416,9 @@ class CustomerMainWindow(QMainWindow): self.act_show_fps.setText(t("menu.show_fps")) 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")) + self.act_preview.setText(t("menu.preview")) + self.act_reset.setText(t("menu.reset")) self.theme_menu.setTitle(t("menu.theme")) self.act_theme_dark.setText(t("menu.theme_dark")) self.act_theme_light.setText(t("menu.theme_light")) @@ -353,17 +433,38 @@ class CustomerMainWindow(QMainWindow): self.arrow_panel.retranslate() self.force_plots.retranslate() self.cal_panel.retranslate() + self._refresh_status_chip() if not self._preview_open and not self._running: self.statusBar().showMessage(t("status.ready")) self._update_run_enabled() + def _set_phase(self, phase: str) -> None: + """Track the acquisition phase and reflect it in the status chip.""" + self._session_phase = phase if phase in SESSION_PHASES else "idle" + self._refresh_status_chip() + + def _refresh_status_chip(self) -> None: + chip = getattr(self, "status_chip", None) + if chip is None: + 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}")) + chip.setStyleSheet( + f"QLabel#statusChip {{ color: {color(self._theme, token)};" + " background: transparent; font-weight: 600; padding: 0 10px 0 4px; }" + ) + def _set_theme(self, theme: str) -> None: self._theme = theme self.prefs["theme"] = theme + clear_theme_cache() app = QApplication.instance() if app is not None: app.setStyleSheet(build_qss(theme)) self.force_plots.set_theme(theme) + self.cal_panel.set_theme(theme) + self._refresh_status_chip() self._sync_menu_checks() self._persist_prefs() @@ -482,6 +583,24 @@ class CustomerMainWindow(QMainWindow): self.prefs["arrow_rotation_deg"] = deg self._persist_prefs() + # ----------------------------------------------------------- shortcuts + def _sync_shortcut_actions(self) -> None: + if getattr(self, "act_run", None) is None: + return + self.act_run.setEnabled(self.toolbar.btn_run.isEnabled()) + self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled()) + self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled()) + + def _on_run_shortcut(self) -> None: + if not self.toolbar.btn_run.isEnabled(): + return + self.toolbar.btn_run.setChecked(not self.toolbar.btn_run.isChecked()) + + def _on_preview_shortcut(self) -> None: + if not self.toolbar.btn_preview.isEnabled(): + return + self.toolbar.btn_preview.setChecked(not self.toolbar.btn_preview.isChecked()) + def _on_config_changed(self, config_name: str) -> None: self.prefs["config_name"] = str(config_name) self._persist_prefs() @@ -585,7 +704,7 @@ class CustomerMainWindow(QMainWindow): self._preview.close() if not self._running: self.toolbar.set_source_controls_enabled(True) - self.arrow_panel.clear_preview(t("app.scaffold_hint")) + self.arrow_panel.clear_preview(t("panels.arrow_empty")) self.toolbar.set_preview_checked(False) self._update_run_enabled() if announce: @@ -655,12 +774,11 @@ class CustomerMainWindow(QMainWindow): return self._running = True - self._session_phase = "connecting" + self._set_phase("connecting") self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref) self.cal_panel.set_running(True) self.toolbar.set_source_controls_enabled(False) - self.sensor_picker.type_combo.setEnabled(False) - self.sensor_picker.config_combo.setEnabled(False) + self.sensor_picker.set_picker_enabled(False) self.toolbar.set_reset_enabled(False) self._run_timer.start() self.statusBar().showMessage(t("status.connecting")) @@ -676,17 +794,16 @@ class CustomerMainWindow(QMainWindow): if self._active_worker is not None: self._active_worker.stop() self._running = False - self._session_phase = "idle" + self._set_phase("error" if reason == "failed" else "idle") self.cal_panel.set_running(False) self.toolbar.set_run_checked(False) self.toolbar.set_reset_enabled(False) self.toolbar.set_source_controls_enabled(True) - self.sensor_picker.type_combo.setEnabled(True) - self.sensor_picker.config_combo.setEnabled(True) + self.sensor_picker.set_picker_enabled(True) self._update_run_enabled() keep_frame = reason in ("ended", "disconnected") if not keep_frame: - self.arrow_panel.clear_preview(t("app.scaffold_hint")) + self.arrow_panel.clear_preview(t("panels.arrow_empty")) else: self.arrow_panel.set_fps(None) if reason == "ended": @@ -758,8 +875,9 @@ class CustomerMainWindow(QMainWindow): is_new = packet_key != self._last_plot_packet_key if self._session_phase == "connecting" and is_new: - self._session_phase = "running" + self._set_phase("running") self.toolbar.set_reset_enabled(True) + self._sync_shortcut_actions() self.statusBar().showMessage( t("status.run_started", source=self._run_source_label) ) @@ -847,6 +965,7 @@ class CustomerMainWindow(QMainWindow): def _persist_prefs(self) -> None: geo = self.saveGeometry().toHex().data().decode("ascii") self.prefs["window_geometry"] = geo + self.prefs["splitter_sizes"] = [int(v) for v in self.content_splitter.sizes()] self.prefs["theme"] = self._theme self.prefs["language"] = self._language self.prefs["camera_id"] = str(self.toolbar.camera_id()) diff --git a/apps/customer/src/orisys_customer/ui/arrow_panel.py b/apps/customer/src/orisys_customer/ui/arrow_panel.py index 4bb0a78..f5b1241 100644 --- a/apps/customer/src/orisys_customer/ui/arrow_panel.py +++ b/apps/customer/src/orisys_customer/ui/arrow_panel.py @@ -59,9 +59,10 @@ class ArrowPanel(QFrame): self.image_label = QLabel() self.image_label.setAlignment(Qt.AlignCenter) - self.image_label.setMinimumSize(320, 240) + 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) self._placeholder = "" self._populate_view_combo() @@ -143,8 +144,9 @@ class ArrowPanel(QFrame): if i < self.view_combo.count(): self.view_combo.setItemText(i, label) self.view_combo.setToolTip(t("panels.view_mode_tip")) + self.image_label.setToolTip(t("panels.arrow_empty_tip")) if not self.image_label.pixmap() or self.image_label.pixmap().isNull(): - self._placeholder = t("app.scaffold_hint") + self._placeholder = t("panels.arrow_empty") self.image_label.setText(self._placeholder) def _effective_rotation_deg(self) -> int: @@ -181,7 +183,7 @@ class ArrowPanel(QFrame): self._source_image = None self.image_label.clear() self.image_label.setPixmap(QPixmap()) - text = message if message is not None else t("app.scaffold_hint") + 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/customer/src/orisys_customer/ui/cal_panel.py b/apps/customer/src/orisys_customer/ui/cal_panel.py index f99a25e..3e78ab9 100644 --- a/apps/customer/src/orisys_customer/ui/cal_panel.py +++ b/apps/customer/src/orisys_customer/ui/cal_panel.py @@ -6,6 +6,7 @@ import math import time from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QFont, QFontDatabase from PyQt5.QtWidgets import ( QAbstractItemView, QButtonGroup, @@ -20,6 +21,7 @@ from PyQt5.QtWidgets import ( QProgressBar, QPushButton, QRadioButton, + QSizePolicy, QTableWidget, QTableWidgetItem, QVBoxLayout, @@ -34,15 +36,27 @@ from orisys_app_shared.force.calibration_io import ( ) from orisys_app_shared.i18n import t from orisys_app_shared.paths import calibration_export_dir, sensors_dir +from orisys_app_shared.theme import color + +CHANNEL_LABELS = ("fn", "fx", "fy") + + +def _mono_font() -> str: + families = set(QFontDatabase().families()) + for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"): + if name in families: + return name + return "monospace" class CalPanel(QFrame): status_message = pyqtSignal(str) calibration_exported = pyqtSignal(str) # path applied to running sensor - def __init__(self, parent=None) -> None: + def __init__(self, parent=None, *, theme: str = "dark") -> None: super().__init__(parent) self.setObjectName("contentPanel") + self._theme = theme self._store = CalSessionStore() self._channel: ChannelKey = "fn" self._running = False @@ -56,13 +70,24 @@ class CalPanel(QFrame): self._sensor_type = "box" self._config_ref: str | None = None self._record_duration_s = 7.0 + self._mono = _mono_font() + self._mono_font = QFont(self._mono, 10) root = QVBoxLayout(self) + root.setSpacing(8) + + title_row = QHBoxLayout() self.title = QLabel() self.title.setObjectName("panelTitle") - root.addWidget(self.title) + self.channel_badge = QLabel() + self.channel_badge.setObjectName("unitBadge") + title_row.addWidget(self.title) + title_row.addStretch(1) + title_row.addWidget(self.channel_badge) + root.addLayout(title_row) channel_row = QHBoxLayout() + channel_row.setSpacing(10) self.channel_group = QButtonGroup(self) self.radio_fn = QRadioButton("FN") self.radio_fx = QRadioButton("FX") @@ -76,13 +101,18 @@ class CalPanel(QFrame): root.addLayout(channel_row) form = QFormLayout() + form.setHorizontalSpacing(10) + form.setVerticalSpacing(6) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) self._weight_label = QLabel() + self._weight_label.setObjectName("fieldLabel") self.weight_spin = QDoubleSpinBox() self.weight_spin.setRange(0.0, 1e6) self.weight_spin.setDecimals(4) self.weight_spin.setSingleStep(0.1) self.weight_spin.setValue(0.0) self._duration_label = QLabel() + self._duration_label.setObjectName("fieldLabel") self.duration_spin = QDoubleSpinBox() self.duration_spin.setRange(5.0, 10.0) self.duration_spin.setDecimals(1) @@ -95,6 +125,7 @@ class CalPanel(QFrame): rec_row = QHBoxLayout() self.btn_record = QPushButton() + self.btn_record.setMinimumHeight(32) self.btn_record.clicked.connect(self._on_record_clicked) rec_row.addWidget(self.btn_record) rec_row.addStretch(1) @@ -108,18 +139,30 @@ class CalPanel(QFrame): self.live_label = QLabel() self.live_label.setWordWrap(True) + self.live_label.setFont(self._mono_font) root.addWidget(self.live_label) - self.table = QTableWidget(0, 5) + self.empty_hint = QLabel() + self.empty_hint.setObjectName("hintText") + self.empty_hint.setWordWrap(True) + root.addWidget(self.empty_hint) + + self.table = QTableWidget(0, 4) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.table.setAlternatingRowColors(True) + self.table.setShowGrid(False) + header = self.table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) self.table.verticalHeader().setVisible(False) + self.table.verticalHeader().setDefaultSectionSize(26) root.addWidget(self.table, 1) sn_row = QHBoxLayout() self._sn_label = QLabel() + self._sn_label.setObjectName("fieldLabel") self.sn_edit = QLineEdit() self.sn_edit.setPlaceholderText("") sn_row.addWidget(self._sn_label) @@ -127,10 +170,16 @@ class CalPanel(QFrame): root.addLayout(sn_row) actions = QHBoxLayout() + actions.setSpacing(6) self.btn_delete = QPushButton() self.btn_clear_channel = QPushButton() self.btn_clear_all = QPushButton() self.btn_export = QPushButton() + self.btn_export.setObjectName("primaryButton") + for button in (self.btn_delete, self.btn_clear_channel, self.btn_clear_all): + # The action row must survive a narrow three-panel layout. + button.setProperty("compact", "true") + button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.btn_delete.clicked.connect(self._on_delete_row) self.btn_clear_channel.clicked.connect(self._on_clear_channel) self.btn_clear_all.clicked.connect(self._on_clear_all) @@ -144,8 +193,14 @@ class CalPanel(QFrame): self._refresh_enabled() self._refresh_table() + self.set_theme(theme) self.retranslate() + # ------------------------------------------------------------------ API + def set_theme(self, theme: str) -> None: + self._theme = theme + self._render_live_label() + def set_running(self, running: bool) -> None: self._running = bool(running) if not self._running and self._recording: @@ -160,7 +215,7 @@ class CalPanel(QFrame): self._live_fn = float(fn) self._live_fx = float(fx) self._live_fy = float(fy) - self._update_live_label() + self._render_live_label() if not self._recording: return now = time.perf_counter() @@ -177,19 +232,21 @@ class CalPanel(QFrame): self.title.setText(t("panels.cal")) self._weight_label.setText(t("cal.weight")) self._duration_label.setText(t("cal.duration")) - self.btn_record.setText(t("cal.record")) + self.btn_record.setText( + t("cal.recording_stop") if self._recording else t("cal.record") + ) self.btn_delete.setText(t("cal.delete_row")) self.btn_clear_channel.setText(t("cal.clear_channel")) self.btn_clear_all.setText(t("cal.clear_all")) self.btn_export.setText(t("cal.export")) self._sn_label.setText(t("cal.sn")) self.sn_edit.setPlaceholderText(t("cal.sn_placeholder")) + self.empty_hint.setText(t("cal.empty_hint")) headers = ( t("cal.col_weight"), t("cal.col_raw"), t("cal.col_n"), t("cal.col_std"), - t("cal.col_actions"), ) for i, text in enumerate(headers): item = self.table.horizontalHeaderItem(i) @@ -198,9 +255,20 @@ class CalPanel(QFrame): self.table.setHorizontalHeaderItem(i, item) else: item.setText(text) - self._update_live_label() + self._refresh_channel_badge() + self._render_live_label() self._refresh_enabled() + # ------------------------------------------------------------- internals + def _refresh_channel_badge(self) -> None: + color_hex = color(self._theme, f"plot_{self._channel}") + self.channel_badge.setText(self._channel.upper()) + self.channel_badge.setStyleSheet( + f"QLabel#unitBadge {{ color: {color_hex};" + f" border: 1px solid {color(self._theme, 'card_border')};" + f" background-color: {color(self._theme, 'card_bg')}; }}" + ) + def _on_channel_button(self, button, checked: bool) -> None: if not checked: return @@ -211,35 +279,60 @@ class CalPanel(QFrame): if self._recording: self._cancel_recording() self._channel = key # type: ignore[assignment] + self._refresh_channel_badge() self._refresh_table() def _refresh_enabled(self) -> None: can_record = self._running and not self._recording self.btn_record.setEnabled(can_record) + self.btn_record.setObjectName("dangerButton" if self._recording else "primaryButton") + self.btn_record.style().unpolish(self.btn_record) + self.btn_record.style().polish(self.btn_record) self.weight_spin.setEnabled(not self._recording) self.duration_spin.setEnabled(not self._recording) for radio in (self.radio_fn, self.radio_fx, self.radio_fy): radio.setEnabled(not self._recording) self.btn_export.setEnabled(not self._recording) + if self._recording: + self.progress.setVisible(True) + record_hint = t("cal.record_disabled") if not self._running else "" + if not self._recording: + self.btn_record.setToolTip(record_hint) - def _update_live_label(self) -> None: - self.live_label.setText( - t( - "cal.live_raw", - fn=f"{self._live_fn:.3f}", - fx=f"{self._live_fx:.3f}", - fy=f"{self._live_fy:.3f}", + def _render_live_label(self) -> None: + colors = { + "fn": color(self._theme, "plot_fn"), + "fx": color(self._theme, "plot_fx"), + "fy": color(self._theme, "plot_fy"), + } + parts = [] + for key, value in ( + ("fn", self._live_fn), + ("fx", self._live_fx), + ("fy", self._live_fy), + ): + parts.append( + f"{key.upper()}" + f" {value:.3f}" ) + self.live_label.setText( + f"" + f"{t('cal.live_raw_label')}  " + "   ".join(parts) ) def _on_record_clicked(self) -> None: - if not self._running or self._recording: + if self._recording: + self._cancel_recording() + self.status_message.emit(t("cal.record_cancelled")) + return + if not self._running: return self._recording = True self._record_samples = [] self._record_duration_s = float(self.duration_spin.value()) self._record_started = time.perf_counter() self._record_deadline = self._record_started + self._record_duration_s + self.progress.setVisible(True) self.progress.setValue(0) self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s") self._refresh_enabled() @@ -257,6 +350,7 @@ class CalPanel(QFrame): self._recording = False self._record_samples = [] self.progress.setValue(1000) + self.progress.setFormat("") self._refresh_enabled() if len(samples) < 2: self.status_message.emit(t("cal.record_failed_few")) @@ -285,12 +379,16 @@ class CalPanel(QFrame): f"{sample.raw_mean:.4f}", str(sample.n), f"{sample.std:.4f}", - "", ) for c, text in enumerate(values): item = QTableWidgetItem(text) item.setFlags(item.flags() & ~Qt.ItemIsEditable) + if c != 2: + item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter)) + else: + item.setTextAlignment(int(Qt.AlignCenter)) self.table.setItem(r, c, item) + self.empty_hint.setVisible(not rows) def _on_delete_row(self) -> None: row = self.table.currentRow() diff --git a/apps/customer/src/orisys_customer/ui/force_plots.py b/apps/customer/src/orisys_customer/ui/force_plots.py index 766ed13..3292145 100644 --- a/apps/customer/src/orisys_customer/ui/force_plots.py +++ b/apps/customer/src/orisys_customer/ui/force_plots.py @@ -4,7 +4,7 @@ from __future__ import annotations import numpy as np from PyQt5.QtCore import Qt -from PyQt5.QtGui import QColor +from PyQt5.QtGui import QColor, QFont, QFontDatabase from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ @@ -19,6 +19,12 @@ except ImportError as exc: # pragma: no cover raise ImportError("pyqtgraph is required for force charts") from exc +CHANNEL_KEYS = ("fn", "fx", "fy") +CHANNEL_LABELS = ("FN", "FX", "FY") +# Theme token that colors each channel curve / label, per index. +CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy") + + def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]: if hi <= lo: return [(lo, f"{lo:g}")] @@ -26,6 +32,30 @@ def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]] return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)] +def format_force(value: float, unit: str) -> str: + """Render one force reading for the value cards.""" + if unit == "N": + return f"{value:.3f}" + return f"{value:.1f}" + + +def unit_display(unit: str) -> str: + return t("units.newton_short") if unit == "N" else t("units.raw_short") + + +def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont: + """Pick the first available monospace face for stable digit alignment.""" + families = set(QFontDatabase().families()) + chosen = "monospace" + for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"): + if name in families: + chosen = name + break + font = QFont(chosen, point_size) + font.setBold(bold) + return font + + class ForcePlotsPanel(QFrame): def __init__(self, parent=None, *, theme: str = "dark") -> None: super().__init__(parent) @@ -35,6 +65,7 @@ class ForcePlotsPanel(QFrame): self._shear_max = 20.0 self._fixed_newton = True self._unit = "raw" + self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0) self.history = MetricHistory( channels=3, window_sec=4.0, @@ -45,45 +76,96 @@ class ForcePlotsPanel(QFrame): ) root = QVBoxLayout(self) + root.setContentsMargins(10, 8, 10, 10) + root.setSpacing(6) + + header = QHBoxLayout() + header.setSpacing(6) self.title = QLabel() self.title.setObjectName("panelTitle") - root.addWidget(self.title) + header.addWidget(self.title) + header.addStretch(1) + self.unit_badge = QLabel() + self.unit_badge.setObjectName("unitBadge") + header.addWidget(self.unit_badge) + root.addLayout(header) + self.plot_frame = QFrame() + self.plot_frame.setObjectName("cardPanel") + plot_layout = QVBoxLayout(self.plot_frame) + plot_layout.setContentsMargins(4, 4, 4, 4) self.plot_widget = pg.GraphicsLayoutWidget() + self.plot_widget.ci.setSpacing(2) + plot_layout.addWidget(self.plot_widget) + root.addWidget(self.plot_frame, 1) + self.plots = [] self.curves = [] - names = ("FN", "FX", "FY") - for i, name in enumerate(names): + 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.setLabel("left", name) plot.setMouseEnabled(x=False, y=False) if i > 0: plot.setXLink(self.plots[0]) - curve = plot.plot(pen=pg.mkPen("#8ab4f8", width=2)) + curve = plot.plot(pen=pen) self.plots.append(plot) self.curves.append(curve) - root.addWidget(self.plot_widget, 1) values_row = QHBoxLayout() - self.value_labels = [] - for _ in names: - lab = QLabel("—") - lab.setAlignment(Qt.AlignCenter) - values_row.addWidget(lab) - self.value_labels.append(lab) + 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) + self.set_theme(theme) self._apply_axis_mode() self.retranslate() + def _build_value_card(self, channel: str) -> tuple[QFrame, QLabel, QLabel, QLabel]: + card = QFrame() + card.setObjectName("cardPanel") + layout = QVBoxLayout(card) + layout.setContentsMargins(7, 4, 7, 4) + layout.setSpacing(1) + + top = QHBoxLayout() + top.setSpacing(6) + badge = QLabel(channel) + badge.setObjectName("unitBadge") + top.addWidget(badge) + top.addStretch(1) + unit_label = QLabel() + unit_label.setObjectName("hintText") + top.addWidget(unit_label) + + 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 + def set_theme(self, theme: str) -> None: self._theme = theme - bg = color(theme, "plot_bg", "#121316") - text = color(theme, "text_primary", "#e8eaed") + bg = color(theme, "plot_bg") + text = color(theme, "text_primary") self.plot_widget.setBackground(bg) axis_pen = pg.mkPen(text) - for plot in self.plots: + for i, plot in enumerate(self.plots): try: plot.getViewBox().setBackgroundColor(QColor(bg)) except Exception: @@ -93,16 +175,22 @@ class ForcePlotsPanel(QFrame): axis.setPen(axis_pen) axis.setTextPen(axis_pen) plot.showGrid(x=True, y=True, alpha=0.25) - label = plot.getAxis("left").labelText or "" - if label: - plot.setLabel("left", label, color=text) - pens = ( - color(theme, "plot_fn", "#8ab4f8"), - color(theme, "plot_fx", "#34a853"), - color(theme, "plot_fy", "#fbbc04"), - ) + # Color each channel axis label to match its curve pen. + plot.setLabel("left", CHANNEL_LABELS[i], color=color(theme, CHANNEL_TOKENS[i])) + 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)) + for badge, token in zip(self.channel_badges, CHANNEL_TOKENS): + badge.setStyleSheet( + f"QLabel#unitBadge {{ color: {color(theme, token)};" + f" background-color: {color(theme, 'card_bg')};" + f" border: 1px solid {color(theme, 'card_border')}; }}" + ) + for value_label, token in zip(self.value_labels, CHANNEL_TOKENS): + value_label.setStyleSheet( + f"QLabel#valueCard {{ color: {color(theme, token)}; }}" + ) + self._render_value_cards() def set_window_sec(self, sec: float) -> None: self.history.set_window_sec(sec) @@ -121,8 +209,9 @@ class ForcePlotsPanel(QFrame): def reset(self) -> None: self.history.reset() self._clear_curves() - for lab in self.value_labels: - lab.setText("—") + self._last_values = (0.0, 0.0, 0.0) + self._unit = "raw" + self._render_value_cards() self._apply_axis_mode() def push( @@ -135,10 +224,11 @@ class ForcePlotsPanel(QFrame): unit: str = "raw", ) -> None: self._unit = unit + self._last_values = (float(fn), float(fx), float(fy)) use_fixed = self._uses_fixed_y() plot_fn = max(0.0, float(fn)) if use_fixed else float(fn) self.history.push(timestamp, plot_fn, float(fx), float(fy)) - self._update_value_labels(fn, fx, fy) + self._render_value_cards() state = self.history.state() if state is None: return @@ -182,11 +272,17 @@ class ForcePlotsPanel(QFrame): for curve in self.curves: curve.setData([], []) - def _update_value_labels(self, fn: float, fx: float, fy: float) -> None: - unit = self._unit - fmt = "{name}: {value:.4f} {unit}" if unit == "N" else "{name}: {value:.2f} {unit}" - for lab, name, value in zip(self.value_labels, ("FN", "FX", "FY"), (fn, fx, fy)): - lab.setText(fmt.format(name=name, value=value, unit=unit)) + def _render_value_cards(self) -> None: + fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}" + for value_label, unit_label, value in zip( + self.value_labels, self.value_units, self._last_values + ): + value_label.setText(fmt.format(value=value)) + unit_label.setText(unit_display(self._unit)) + self.unit_badge.setText(unit_display(self._unit)) def retranslate(self) -> None: self.title.setText(t("panels.plots")) + for badge, label in zip(self.channel_badges, CHANNEL_LABELS): + badge.setText(label) + self._render_value_cards() diff --git a/apps/customer/src/orisys_customer/ui/mesh3d_panel.py b/apps/customer/src/orisys_customer/ui/mesh3d_panel.py index 75b2170..7442a30 100644 --- a/apps/customer/src/orisys_customer/ui/mesh3d_panel.py +++ b/apps/customer/src/orisys_customer/ui/mesh3d_panel.py @@ -13,15 +13,23 @@ class Mesh3dPanel(QFrame): super().__init__(parent) self.setObjectName("contentPanel") layout = QVBoxLayout(self) + + header = QVBoxLayout() + header.setSpacing(2) self.title = QLabel() self.title.setObjectName("panelTitle") + header.addWidget(self.title) + layout.addLayout(header) + self.body = QLabel() + self.body.setObjectName("hintText") self.body.setAlignment(Qt.AlignCenter) self.body.setWordWrap(True) - layout.addWidget(self.title) layout.addWidget(self.body, 1) + self.retranslate() def retranslate(self) -> None: self.title.setText(t("panels.mesh3d")) - self.body.setText(t("panels.mesh3d_hint")) + self.body.setText(t("panels.mesh3d_empty")) + self.body.setToolTip(t("panels.mesh3d_empty_tip")) diff --git a/apps/customer/src/orisys_customer/ui/sensor_picker.py b/apps/customer/src/orisys_customer/ui/sensor_picker.py index 1c6f002..b781c0d 100644 --- a/apps/customer/src/orisys_customer/ui/sensor_picker.py +++ b/apps/customer/src/orisys_customer/ui/sensor_picker.py @@ -1,13 +1,26 @@ -"""Sensor type + config picker with thumbnail placeholder.""" +"""Sensor type + config picker with product badge.""" from __future__ import annotations from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget +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 + class SensorPickerWidget(QWidget): sensor_type_changed = pyqtSignal(str) @@ -16,38 +29,49 @@ class SensorPickerWidget(QWidget): def __init__(self, parent=None) -> None: super().__init__(parent) layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(8) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(10) self.thumb = QFrame() - self.thumb.setObjectName("contentPanel") - self.thumb.setFixedSize(72, 72) + 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) self.thumb_label = QLabel() + self.thumb_label.setObjectName("unitBadge") self.thumb_label.setAlignment(Qt.AlignCenter) - self.thumb_label.setWordWrap(True) + self.thumb_label.setWordWrap(False) thumb_layout.addWidget(self.thumb_label) - form = QVBoxLayout() - form.setSpacing(4) - type_row = QHBoxLayout() + form = QFormLayout() + form.setContentsMargins(0, 0, 0, 0) + form.setHorizontalSpacing(8) + form.setVerticalSpacing(6) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) + 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) for name in list_sensor_types(): self.type_combo.addItem(name, name) self.type_combo.currentIndexChanged.connect(self._on_type_index_changed) - type_row.addWidget(self.type_label) - type_row.addWidget(self.type_combo, 1) - config_row = QHBoxLayout() 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.currentIndexChanged.connect(self._on_config_index_changed) - config_row.addWidget(self.config_label) - config_row.addWidget(self.config_combo, 1) - form.addLayout(type_row) - form.addLayout(config_row) + form.addRow(self.type_label, self.type_combo) + form.addRow(self.config_label, self.config_combo) + layout.addWidget(self.thumb) layout.addLayout(form, 1) self._reload_configs() @@ -88,6 +112,11 @@ class SensorPickerWidget(QWidget): self.config_combo.setCurrentIndex(max(0, idx)) self.config_combo.blockSignals(False) + def set_picker_enabled(self, enabled: bool) -> None: + """Enable/disable both pickers (used while a session is running).""" + self.type_combo.setEnabled(bool(enabled)) + self.config_combo.setEnabled(bool(enabled)) + def _reload_configs(self) -> None: self.config_combo.blockSignals(True) self.config_combo.clear() @@ -98,8 +127,7 @@ class SensorPickerWidget(QWidget): self.config_combo.blockSignals(False) def _refresh_thumb(self) -> None: - name = self.sensor_type().upper() - self.thumb_label.setText(name) + self.thumb_label.setText(self.sensor_type().upper()) def _on_type_index_changed(self, _index: int) -> None: self._reload_configs() @@ -113,7 +141,5 @@ class SensorPickerWidget(QWidget): def retranslate(self) -> None: self.type_label.setText(t("sensor.type")) self.config_label.setText(t("sensor.config")) - if not self.thumb_label.text().strip(): - self.thumb_label.setText(t("sensor.thumb_placeholder")) - else: - self._refresh_thumb() + self.thumb.setToolTip(t("sensor.thumb_placeholder")) + self._refresh_thumb() diff --git a/apps/customer/src/orisys_customer/ui/toolbar.py b/apps/customer/src/orisys_customer/ui/toolbar.py index c21a387..1fa7849 100644 --- a/apps/customer/src/orisys_customer/ui/toolbar.py +++ b/apps/customer/src/orisys_customer/ui/toolbar.py @@ -4,20 +4,46 @@ from __future__ import annotations from pathlib import Path -from PyQt5.QtCore import pyqtSignal +from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtWidgets import ( QComboBox, + QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QScrollArea, + QSizePolicy, QSpinBox, QStackedWidget, + QVBoxLayout, QWidget, ) from orisys_app_shared.i18n import t +CONTROL_HEIGHT = 30 +BUTTON_HEIGHT = 32 + + +def _separator() -> QFrame: + line = QFrame() + line.setObjectName("sectionSeparator") + line.setFrameShape(QFrame.VLine) + line.setFixedWidth(1) + line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + return line + + +def _group(*widgets: QWidget) -> QWidget: + box = QWidget() + layout = QHBoxLayout(box) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + for widget in widgets: + layout.addWidget(widget) + return box + class ToolbarWidget(QWidget): search_clicked = pyqtSignal() @@ -31,12 +57,31 @@ class ToolbarWidget(QWidget): def __init__(self, parent=None) -> None: super().__init__(parent) - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(8) + self._run_tooltip_reason = "" + self._run_available = True + 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.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + outer.addWidget(self._scroll) + + content = QWidget() + self._scroll.setWidget(content) + layout = QHBoxLayout(content) + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(10) + + # --- source group ------------------------------------------------- self.source_label = QLabel() + self.source_label.setObjectName("fieldLabel") self.source_combo = QComboBox() + self.source_combo.setMinimumWidth(96) + self.source_combo.setMinimumHeight(CONTROL_HEIGHT) self.source_combo.currentIndexChanged.connect(self._on_source_index_changed) self.source_stack = QStackedWidget() @@ -45,10 +90,12 @@ class ToolbarWidget(QWidget): cam_layout.setContentsMargins(0, 0, 0, 0) cam_layout.setSpacing(8) self.cam_id_label = QLabel() + self.cam_id_label.setObjectName("fieldLabel") self.cam_id_spin = QSpinBox() self.cam_id_spin.setRange(0, 20) self.cam_id_spin.setValue(0) - self.cam_id_spin.setMinimumWidth(72) + self.cam_id_spin.setFixedWidth(72) + self.cam_id_spin.setMinimumHeight(CONTROL_HEIGHT) self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit) cam_layout.addWidget(self.cam_id_label) cam_layout.addWidget(self.cam_id_spin) @@ -59,8 +106,10 @@ class ToolbarWidget(QWidget): video_layout.setSpacing(8) self.video_edit = QLineEdit() self.video_edit.setMinimumWidth(180) + self.video_edit.setMinimumHeight(CONTROL_HEIGHT) self.video_edit.setReadOnly(True) self.btn_browse_video = QPushButton() + self.btn_browse_video.setMinimumHeight(BUTTON_HEIGHT) self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit) video_layout.addWidget(self.video_edit, 1) video_layout.addWidget(self.btn_browse_video) @@ -68,34 +117,45 @@ class ToolbarWidget(QWidget): self.source_stack.addWidget(cam_page) self.source_stack.addWidget(video_page) + # --- action group ------------------------------------------------- self.btn_search = QPushButton() + self.btn_search.setMinimumHeight(BUTTON_HEIGHT) self.btn_search.clicked.connect(self.search_clicked.emit) self.btn_preview = QPushButton() + self.btn_preview.setMinimumHeight(BUTTON_HEIGHT) 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") self.units_combo = QComboBox() + self.units_combo.setFixedWidth(96) + self.units_combo.setMinimumHeight(CONTROL_HEIGHT) self.units_combo.currentIndexChanged.connect(self._on_units_index_changed) - layout.addWidget(self.source_label) - layout.addWidget(self.source_combo) - layout.addWidget(self.source_stack, 1) - layout.addWidget(self.btn_search) - layout.addWidget(self.btn_preview) - layout.addWidget(self.btn_run) - layout.addWidget(self.btn_reset) - layout.addWidget(self.units_label) - layout.addWidget(self.units_combo) + layout.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.addStretch(1) self._populate_source_combo() @@ -103,6 +163,7 @@ class ToolbarWidget(QWidget): self.retranslate() self._apply_source_mode_ui() + # ------------------------------------------------------------------ API def source_mode(self) -> str: data = self.source_combo.currentData() return str(data or "camera") @@ -131,10 +192,7 @@ class ToolbarWidget(QWidget): def set_video_path(self, path: str) -> None: text = str(path or "").strip() self.video_edit.setText(text) - if text: - self.video_edit.setToolTip(text) - else: - self.video_edit.setToolTip("") + self.video_edit.setToolTip(text) def units(self) -> str: data = self.units_combo.currentData() @@ -153,13 +211,13 @@ class ToolbarWidget(QWidget): self.btn_preview.blockSignals(True) self.btn_preview.setChecked(bool(checked)) self.btn_preview.blockSignals(False) - self._refresh_preview_label() + self.refresh_labels() def set_run_checked(self, checked: bool) -> None: self.btn_run.blockSignals(True) self.btn_run.setChecked(bool(checked)) self.btn_run.blockSignals(False) - self._refresh_run_label() + self.refresh_labels() if not checked: self.btn_reset.setEnabled(False) @@ -177,7 +235,14 @@ class ToolbarWidget(QWidget): self.btn_preview.setEnabled(enabled and self.source_mode() == "camera") def set_run_enabled(self, enabled: bool) -> None: + self._run_available = bool(enabled) self.btn_run.setEnabled(bool(enabled)) + self._apply_run_tooltip() + + def set_run_tooltip_reason(self, reason: str) -> None: + """Explain *why* Run is unavailable (shown in the button tooltip).""" + self._run_tooltip_reason = str(reason or "") + self._apply_run_tooltip() def active_source(self) -> str | int | None: if self.source_mode() == "video": @@ -185,6 +250,12 @@ class ToolbarWidget(QWidget): return path if path else None return self.camera_id() + def refresh_labels(self) -> None: + """Public refresh hook (kept separate from ``retranslate`` for reuse).""" + self._refresh_preview_label() + self._refresh_run_label() + + # ------------------------------------------------------------- internals def _populate_source_combo(self) -> None: self.source_combo.blockSignals(True) self.source_combo.clear() @@ -236,11 +307,28 @@ class ToolbarWidget(QWidget): def _refresh_run_label(self) -> None: if self.btn_run.isChecked(): - self.btn_run.setText("■") - self.btn_run.setToolTip(t("toolbar.stop")) + self.btn_run.setText(t("toolbar.stop_label")) else: - self.btn_run.setText("▶") - self.btn_run.setToolTip(t("toolbar.run")) + 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._apply_run_tooltip() + + def _apply_run_tooltip(self) -> None: + if self.btn_run.isChecked(): + self.btn_run.setToolTip(t("toolbar.stop")) + elif not self._run_available and self._run_tooltip_reason: + self.btn_run.setToolTip(self._run_tooltip_reason) + elif not self._run_available: + self.btn_run.setToolTip(t("toolbar.run_unavailable")) + else: + tip = t("toolbar.run") + if self._run_tooltip_reason: + tip = f"{tip} — {self._run_tooltip_reason}" + self.btn_run.setToolTip(tip) def retranslate(self) -> None: self.source_label.setText(t("toolbar.source")) @@ -253,8 +341,7 @@ class ToolbarWidget(QWidget): Path(path).name if path else t("toolbar.video_placeholder") ) self.btn_search.setText(t("toolbar.search_cameras")) - self._refresh_preview_label() - self._refresh_run_label() + self.refresh_labels() self.btn_reset.setText(t("toolbar.reset")) self.units_label.setText(t("toolbar.units")) self.units_combo.setItemText(0, t("toolbar.units_newton")) diff --git a/apps/customer/tests/conftest.py b/apps/customer/tests/conftest.py new file mode 100644 index 0000000..69f2bdd --- /dev/null +++ b/apps/customer/tests/conftest.py @@ -0,0 +1,73 @@ +"""Shared fixtures for the customer app UI tests. + +The tests render real Qt widgets with the offscreen platform plugin, so they need +no display and no camera hardware. A single ``QApplication`` is shared by the +whole session (Qt forbids creating more than one). +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: + _fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" + if _fonts.is_dir(): + os.environ["QT_QPA_FONTDIR"] = str(_fonts) + + +@pytest.fixture(scope="session") +def qt_app(): + from PyQt5.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([]) + yield app + + +@pytest.fixture +def make_window(qt_app, tmp_path, monkeypatch): + """Build a ``CustomerMainWindow`` whose preference writes stay in tmp_path.""" + from orisys_app_shared import prefs as prefs_pkg + from orisys_app_shared import paths as paths_mod + from orisys_customer.app_window import CustomerMainWindow + + prefs_file = tmp_path / "preference.json" + monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file) + monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file) + + default_prefs = { + "language": "zh_CN", + "theme": "dark", + "sensor_type": "box", + "source_mode": "camera", + } + + created = [] + + def _make(**overrides): + data = dict(default_prefs) + data.update(overrides) + window = CustomerMainWindow(prefs=data) + created.append(window) + return window + + yield _make + + for window in created: + try: + window.close() + except Exception: + pass + + +def visible_descendants(widget): + """Yield every visible descendant widget of ``widget``.""" + for child in widget.findChildren(object): + if not hasattr(child, "isVisible") or not hasattr(child, "geometry"): + continue + if child.isVisible(): + yield child diff --git a/apps/customer/tests/test_locales.py b/apps/customer/tests/test_locales.py new file mode 100644 index 0000000..d4b6c5d --- /dev/null +++ b/apps/customer/tests/test_locales.py @@ -0,0 +1,76 @@ +"""Static checks: translation keys and preference defaults stay in sync.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +APP_ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = APP_ROOT / "src" +LOCALES_DIR = APP_ROOT / "config" / "locales" +DEFAULTS_JSON = APP_ROOT / "config" / "preferences.default.json" + +REPO_ROOT = APP_ROOT.parents[1] +STORE_PY = REPO_ROOT / "shared" / "src" / "orisys_app_shared" / "prefs" / "store.py" + +_T_CALL = re.compile(r"""\bt\(\s*["']([A-Za-z0-9_.]+)["']""") +_DEFAULT_BLOCK = re.compile(r"_DEFAULTS[^=]*=\s*\{(?P.*?)\n\}", re.DOTALL) +_KEY = re.compile(r"""["']([A-Za-z0-9_]+)["']\s*:""") + + +def _load_locale(name: str) -> dict: + return json.loads((LOCALES_DIR / f"{name}.json").read_text(encoding="utf-8")) + + +def _flatten(data: dict, prefix: str = "") -> set[str]: + keys: set[str] = set() + for key, value in data.items(): + dotted = f"{prefix}{key}" + if isinstance(value, dict): + keys |= _flatten(value, f"{dotted}.") + else: + keys.add(dotted) + return keys + + +def _referenced_keys() -> set[str]: + found: set[str] = set() + for path in sorted(SRC_DIR.rglob("*.py")): + found |= set(_T_CALL.findall(path.read_text(encoding="utf-8"))) + return found + + +def test_locales_parity_and_used_keys(): + zh = _flatten(_load_locale("zh_CN")) + en = _flatten(_load_locale("en_US")) + + assert zh == en, { + "zh_only": sorted(zh - en), + "en_only": sorted(en - zh), + } + + referenced = _referenced_keys() + assert referenced, "no t() calls found — the source scan is broken" + missing = sorted(key for key in referenced if key not in zh) + assert not missing, f"t() keys missing from locale files: {missing}" + + +def test_preference_defaults_match_store_defaults(): + json_keys = set(json.loads(DEFAULTS_JSON.read_text(encoding="utf-8"))) + source = STORE_PY.read_text(encoding="utf-8") + match = _DEFAULT_BLOCK.search(source) + assert match, "could not locate _DEFAULTS in prefs/store.py" + store_keys = set(_KEY.findall(match.group("body"))) + + assert json_keys == store_keys, { + "json_only": sorted(json_keys - store_keys), + "store_only": sorted(store_keys - json_keys), + } + + +def test_locale_files_are_valid_utf8_json(): + for name in ("zh_CN", "en_US"): + data = _load_locale(name) + assert isinstance(data, dict) + assert data.get("app", {}).get("title") diff --git a/apps/customer/tests/test_ui_smoke.py b/apps/customer/tests/test_ui_smoke.py new file mode 100644 index 0000000..e1369df --- /dev/null +++ b/apps/customer/tests/test_ui_smoke.py @@ -0,0 +1,171 @@ +"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens.""" + +from __future__ import annotations + +import pytest + +from tests.conftest import visible_descendants + +RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080)) + + +def _show(window, size): + window.resize(*size) + window.show() + from PyQt5.QtWidgets import QApplication + + QApplication.processEvents() + return window + + +@pytest.mark.parametrize(("theme", "sensor_type"), [ + ("dark", "box"), + ("light", "box"), + ("dark", "finger"), + ("light", "finger"), +]) +def test_window_builds_in_every_theme(make_window, theme, sensor_type): + window = _show(make_window(theme=theme, sensor_type=sensor_type), (1400, 880)) + assert window.windowTitle() + assert window.content_splitter.count() == 4 + + +@pytest.mark.parametrize("size", RESOLUTIONS) +def test_panels_fit_within_window(make_window, size): + window = _show( + make_window(sensor_type="finger", show_cal_panel=True), + size, + ) + sizes = window.content_splitter.sizes() + panels = [ + window.mesh3d_panel, + window.arrow_panel, + window.force_plots, + window.cal_panel, + ] + for panel, width in zip(panels, sizes): + assert panel.isVisible() + assert width >= panel.minimumWidth(), ( + f"{panel.objectName() or type(panel).__name__} too narrow: {width}" + ) + assert window.force_plots.width() >= window.force_plots.minimumWidth() + assert window.arrow_panel.width() >= window.arrow_panel.minimumWidth() + + +@pytest.mark.parametrize("size", RESOLUTIONS) +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): + 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_theme_tokens_have_identical_keys(): + from orisys_app_shared.theme import build_qss, load_colors + + dark = set(load_colors("dark")) + light = set(load_colors("light")) + assert dark == light, {"dark_only": sorted(dark - light), "light_only": sorted(light - dark)} + assert len(build_qss("dark")) > 1000 + assert len(build_qss("light")) > 1000 + + +def test_arrow_canvas_differs_from_accent(): + """The pure-color arrow canvas must not collide with the accent pen color.""" + from orisys_app_shared.theme import load_colors + + for theme in ("dark", "light"): + colors = load_colors(theme) + assert colors["arrow_canvas"] != colors["accent"], theme + + +def test_disabled_buttons_use_muted_text_not_hover_background(): + from orisys_app_shared.theme import build_qss, load_colors + + for theme in ("dark", "light"): + colors = load_colors(theme) + qss = build_qss(theme) + assert f"color: {colors['text_muted']}" in qss, theme + # The old rule painted disabled buttons with the brighter hover color. + assert f"background-color: {colors['button_hover']};\n border-color: {colors['panel_border']}" not in qss + + +def test_splitter_sizes_round_trip_through_prefs(make_window): + window = _show(make_window(), (1440, 880)) + window.content_splitter.setSizes([300, 420, 520, 0]) + window._persist_prefs() + stored = window.prefs["splitter_sizes"] + assert len(stored) == 4 + assert stored[2] > stored[0] + + +def test_status_chip_tracks_phase(make_window): + window = _show(make_window(), (1400, 880)) + assert window._session_phase == "idle" + window._set_phase("running") + assert window.status_chip.text() + assert "status_running" not in window.status_chip.text() + window._set_phase("nonexistent-phase") + assert window._session_phase == "idle" + + +def test_run_tooltip_explains_disabled_reason(make_window): + window = _show(make_window(source_mode="video", video_path=""), (1400, 880)) + assert not window.toolbar.btn_run.isEnabled() + assert window.toolbar.btn_run.toolTip() + + window.toolbar.set_video_path("C:/tmp/demo.mp4") + window._update_run_enabled() + assert window.toolbar.btn_run.isEnabled() + + +def test_toolbar_run_button_is_labelled(make_window): + window = _show(make_window(), (1400, 880)) + label = window.toolbar.btn_run.text() + assert label.strip() + assert label != "▶" + window.toolbar.set_run_checked(True) + assert window.toolbar.btn_run.text() != label + + +def test_theme_switch_updates_panels(make_window): + window = _show(make_window(), (1400, 880)) + window._set_theme("light") + assert window._theme == "light" + window._set_theme("dark") + assert window._theme == "dark" + + +def test_cal_panel_records_and_lists_samples(make_window): + window = _show(make_window(), (1400, 880)) + cal = window.cal_panel + cal.set_running(True) + assert cal.btn_record.isEnabled() + assert cal.btn_record.text() + + from orisys_app_shared.force.calibration_io import CalSample + + cal._store.upsert("fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02)) + cal._refresh_table() + assert cal.table.rowCount() == 1 + assert not cal.empty_hint.isVisible() + + +def test_force_plots_render_demo_data(make_window): + window = _show(make_window(), (1440, 880)) + for i in range(60): + window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N") + from PyQt5.QtWidgets import QApplication + + QApplication.processEvents() + assert window.force_plots.history.sample_count > 0 + assert all(label.text() for label in window.force_plots.value_labels) diff --git a/apps/customer/tools/ui_check.py b/apps/customer/tools/ui_check.py new file mode 100644 index 0000000..10d0d50 --- /dev/null +++ b/apps/customer/tools/ui_check.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Dependency-free smoke run for the customer UI. + +``pytest`` is the canonical runner (``apps/customer/tests``), but this script +needs nothing beyond the app's own runtime dependencies, so it can be used in +minimal environments: + + python tools/ui_check.py + +It builds the real main window offscreen and asserts the layout, theming and +status-feedback invariants that the UI work depends on. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +APP_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = APP_ROOT.parents[1] +for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"): + if _path.is_dir() and str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: + _fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" + if _fonts.is_dir(): + os.environ["QT_QPA_FONTDIR"] = str(_fonts) +os.chdir(APP_ROOT) + +FAILURES: list[str] = [] +CHECKS = 0 + + +def check(condition: bool, message: str) -> None: + global CHECKS + CHECKS += 1 + if not condition: + FAILURES.append(message) + + +def section(name: str) -> None: + print(f"\n== {name} ==") + + +def main() -> int: + from PyQt5.QtWidgets import QApplication + + from orisys_app_shared.force.calibration_io import CalSample + from orisys_app_shared.i18n import set_locale + from orisys_app_shared.theme import build_qss, load_colors + from orisys_customer.app_window import CustomerMainWindow + + app = QApplication.instance() or QApplication(sys.argv) + set_locale("zh_CN") + + section("theme tokens") + dark = set(load_colors("dark")) + light = set(load_colors("light")) + check(dark == light, f"token keys differ: {sorted(dark ^ light)}") + for theme in ("dark", "light"): + qss = build_qss(theme) + check(len(qss) > 1000, f"{theme} qss suspiciously short") + check( + load_colors(theme)["arrow_canvas"] != load_colors(theme)["accent"], + f"{theme}: arrow canvas collides with accent", + ) + check("QProgressBar::chunk" in qss, f"{theme}: progress bar not styled") + check("QHeaderView::section" in qss, f"{theme}: table header not styled") + check("QRadioButton::indicator" in qss, f"{theme}: radio not styled") + check("QScrollBar::handle" in qss, f"{theme}: scrollbar not styled") + + scene_count = 0 + for theme in ("dark", "light"): + for sensor in ("box", "finger"): + section(f"window {theme}/{sensor}") + scene_count += 1 + app.setStyleSheet(build_qss(theme)) + window = CustomerMainWindow( + prefs={ + "language": "zh_CN", + "theme": theme, + "sensor_type": sensor, + "show_cal_panel": True, + } + ) + window.resize(1440, 880) + window.show() + app.processEvents() + + check(window.windowTitle() != "", "window title empty") + check( + window.status_chip.text() != "", + "status chip has no phase text", + ) + check( + window.toolbar.btn_run.text().strip() not in ("", "▶"), + f"run button is unlabelled: {window.toolbar.btn_run.text()!r}", + ) + + panels = [ + window.mesh3d_panel, + window.arrow_panel, + window.force_plots, + window.cal_panel, + ] + for panel, width in zip(panels, window.content_splitter.sizes()): + if panel.isVisible(): + check( + width >= panel.minimumWidth(), + f"{type(panel).__name__} width {width} < min {panel.minimumWidth()}", + ) + + check( + window.force_plots.width() >= window.force_plots.minimumWidth(), + "force plots narrower than its minimum", + ) + + # value cards must keep their text visible + for card, label in zip( + window.force_plots.value_cards, window.force_plots.value_labels + ): + check( + label.height() >= label.fontMetrics().height(), + "value card text is clipped", + ) + check(label.text() != "", "value card is empty") + + # demo data flows through the plots and the calibration table + for i in range(90): + window.force_plots.push(i / 30.0, float(i % 7), -1.0, 1.0, unit="N") + app.processEvents() + check( + window.force_plots.history.sample_count > 0, + "plots did not accept samples", + ) + + window.cal_panel.set_running(True) + check(window.cal_panel.btn_record.isEnabled(), "record button stays disabled") + window.cal_panel._store.upsert( + "fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02) + ) + window.cal_panel._refresh_table() + check(window.cal_panel.table.rowCount() == 1, "calibration row missing") + check( + not window.cal_panel.empty_hint.isVisible(), + "empty hint still visible with rows", + ) + + window._set_phase("running") + check("status_running" not in window.status_chip.text(), "chip not localized") + window._set_theme("light" if theme == "dark" else "dark") + app.processEvents() + + window._persist_prefs() + check( + len(window.prefs.get("splitter_sizes", [])) == 4, + "splitter sizes not persisted", + ) + window.close() + app.processEvents() + + section("small window") + app.setStyleSheet(build_qss("dark")) + window = CustomerMainWindow( + prefs={ + "language": "en_US", + "theme": "dark", + "sensor_type": "finger", + "show_cal_panel": True, + "show_finger_3d": True, + } + ) + window.resize(1280, 720) + window.show() + app.processEvents() + for panel, width in zip( + [ + window.mesh3d_panel, + window.arrow_panel, + window.force_plots, + window.cal_panel, + ], + window.content_splitter.sizes(), + ): + if panel.isVisible(): + check(width >= panel.minimumWidth(), f"1280px: {type(panel).__name__} squeezed") + oversize = [] + for child in window.findChildren(object): + if not hasattr(child, "isVisible") or not child.isVisible(): + continue + if not hasattr(child, "geometry"): + 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(type(child).__name__) + check(not oversize, f"widgets overflow their parent: {oversize}") + window.close() + app.processEvents() + + print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}") + for failure in FAILURES: + print(f" FAIL: {failure}") + return 1 if FAILURES else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/customer/tools/ui_snapshot.py b/apps/customer/tools/ui_snapshot.py new file mode 100644 index 0000000..735311e --- /dev/null +++ b/apps/customer/tools/ui_snapshot.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Render the customer UI offscreen to PNG files for visual review. + +The tool never opens a camera and never starts acquisition: it builds the real +main window, optionally injects deterministic demo data, then grabs the window. + +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 +""" + +from __future__ import annotations + +import argparse +import math +import os +import sys +from pathlib import Path + +APP_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = APP_ROOT.parents[1] +for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"): + if _path.is_dir() and str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +# Silent platform must be selected before QApplication exists. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: + fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" + if fonts.is_dir(): + os.environ["QT_QPA_FONTDIR"] = str(fonts) +os.chdir(APP_ROOT) + + +def _demo_data(window) -> None: + """Fill the plots and the calibration table with deterministic samples.""" + plots = window.force_plots + for i in range(240): + t = i / 30.0 + fn = 6.0 + 5.0 * math.sin(t * 1.4) + fx = 4.0 * math.sin(t * 2.1 + 0.4) + fy = -3.0 + 2.5 * math.cos(t * 1.1) + plots.push(t, fn, fx, fy, unit="N") + + cal = window.cal_panel + cal.set_running(True) + cal.push_raw(9.81, -0.42, 0.10) + for weight, raw, std in ((1.0, 9.72, 0.031), (2.0, 19.51, 0.044), (5.0, 48.90, 0.061)): + from orisys_app_shared.force.calibration_io import CalSample + + cal._store.upsert("fn", CalSample(weight_n=weight, raw_mean=raw, n=180, std=std)) + cal._refresh_table() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--theme", choices=("dark", "light"), default="dark") + parser.add_argument("--lang", choices=("zh_CN", "en_US"), default="zh_CN") + parser.add_argument("--sensor", choices=("box", "mini", "finger"), default="box") + parser.add_argument( + "--panels", + default="", + help="comma list of optional panels to show: cal,3d", + ) + parser.add_argument("--size", default="1440x880", help="WxH window size") + parser.add_argument("--view", default="arrows_pure") + parser.add_argument("--demo", action="store_true", help="inject demo plot/table data") + parser.add_argument("--out", required=True, help="output PNG path") + args = parser.parse_args() + + try: + width, height = (int(part) for part in args.size.lower().split("x", 1)) + except ValueError: + parser.error("--size must look like 1440x880") + + panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()} + + from PyQt5.QtWidgets import QApplication + + from orisys_app_shared.i18n import set_locale + from orisys_app_shared.theme import build_qss + from orisys_customer.app_window import CustomerMainWindow + + app = QApplication(sys.argv) + set_locale(args.lang) + app.setStyleSheet(build_qss(args.theme)) + + prefs = { + "language": args.lang, + "theme": args.theme, + "sensor_type": args.sensor, + "show_cal_panel": "cal" in panels, + "show_finger_3d": "3d" in panels, + "show_fps": True, + "image_view_mode": args.view, + } + window = CustomerMainWindow(prefs=prefs) + window.resize(width, height) + window.show() + app.processEvents() + + if args.demo: + _demo_data(window) + for _ in range(4): + app.processEvents() + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + ok = window.grab().save(str(out)) + print(f"{'saved' if ok else 'FAILED'} {out} ({width}x{height}, theme={args.theme})") + window.close() + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index 0891a0e..2711c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,13 @@ authors = [{ name = "Orisys-Tech" }] dev = [ "orisys-app-shared @ file:./shared", "orisys-customer @ file:./apps/customer", + "pytest>=7.4", ] [tool.setuptools] packages = [] + +[tool.pytest.ini_options] +testpaths = ["shared/tests", "apps/customer/tests"] +pythonpath = ["shared/src", "apps/customer/src"] +addopts = "-q" diff --git a/shared/src/orisys_app_shared/prefs/store.py b/shared/src/orisys_app_shared/prefs/store.py index 8a97a6f..b67cbad 100644 --- a/shared/src/orisys_app_shared/prefs/store.py +++ b/shared/src/orisys_app_shared/prefs/store.py @@ -26,6 +26,9 @@ _DEFAULTS: dict[str, Any] = { "plot_shear_max": 20.0, "image_view_mode": "arrows_pure", "arrow_rotation_deg": 90, + "show_fps": False, + "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 00122a7..3c4bb0a 100644 --- a/shared/src/orisys_app_shared/theme/colors_dark.json +++ b/shared/src/orisys_app_shared/theme/colors_dark.json @@ -4,8 +4,11 @@ "panel_border": "#2a2d33", "text_primary": "#e8eaed", "text_secondary": "#9aa0a6", - "accent": "#536dce", - "accent_hover": "#6b82d9", + "text_muted": "#6f757c", + "accent": "#4a62c0", + "accent_hover": "#5c74d0", + "accent_text": "#ffffff", + "toolbar_bg": "#1a1c20", "button_bg": "#2a2d33", "button_hover": "#3a3f47", "input_bg": "#0f1012", @@ -15,9 +18,28 @@ "plot_fn": "#8ab4f8", "plot_fx": "#34a853", "plot_fy": "#fbbc04", - "plot_bg": "#121316", + "plot_bg": "#15171a", "plot_grid": "#2a2d33", "arrow_canvas": "#121316", "danger": "#ea4335", - "success": "#34a853" + "danger_hover": "#f2554a", + "danger_text": "#ffffff", + "warning": "#fbbc04", + "info": "#8ab4f8", + "success": "#34a853", + "status_idle": "#9aa0a6", + "status_connecting": "#fbbc04", + "status_running": "#34a853", + "status_error": "#ea4335", + "table_header_bg": "#23262b", + "table_row_alt": "#1e2126", + "table_selected": "#33406b", + "scrollbar_bg": "#1a1c20", + "scrollbar_handle": "#3a3f47", + "scrollbar_handle_hover": "#4a5058", + "card_bg": "#1e2126", + "card_border": "#32363d", + "separator": "#2a2d33", + "focus_ring": "#6b82d9", + "hint_text": "#7d838a" } diff --git a/shared/src/orisys_app_shared/theme/colors_light.json b/shared/src/orisys_app_shared/theme/colors_light.json index 9474180..5b6228a 100644 --- a/shared/src/orisys_app_shared/theme/colors_light.json +++ b/shared/src/orisys_app_shared/theme/colors_light.json @@ -1,23 +1,45 @@ { - "window_bg": "#f5f6f8", - "panel_bg": "#ffffff", - "panel_border": "#d0d4dc", - "text_primary": "#1a1c20", - "text_secondary": "#5f6368", - "accent": "#536dce", - "accent_hover": "#3f56b5", - "button_bg": "#e8eaed", - "button_hover": "#d0d4dc", - "input_bg": "#ffffff", - "input_border": "#c0c4cc", - "menu_bg": "#ffffff", - "menu_hover": "#e8eaed", - "plot_fn": "#1a73e8", + "window_bg": "#ECEFF4", + "panel_bg": "#FFFFFF", + "panel_border": "#D0D5DE", + "text_primary": "#1A1C20", + "text_secondary": "#5F6368", + "text_muted": "#7C838C", + "accent": "#003893", + "accent_hover": "#2A4FA8", + "accent_text": "#ffffff", + "toolbar_bg": "#E8EBF0", + "button_bg": "#F4F6FA", + "button_hover": "#E8ECF5", + "input_bg": "#FFFFFF", + "input_border": "#C5CAD3", + "menu_bg": "#FFFFFF", + "menu_hover": "#EEF1F7", + "plot_fn": "#003893", "plot_fx": "#188038", - "plot_fy": "#f9ab00", - "plot_bg": "#ffffff", - "plot_grid": "#e0e3e8", - "arrow_canvas": "#eef0f3", - "danger": "#d93025", - "success": "#188038" + "plot_fy": "#E8710A", + "plot_bg": "#FAFBFD", + "plot_grid": "#E3E6EC", + "arrow_canvas": "#EEF1F7", + "danger": "#D93025", + "danger_hover": "#B3261C", + "danger_text": "#ffffff", + "warning": "#F9AB00", + "info": "#1A73E8", + "success": "#1B8A5A", + "status_idle": "#5F6368", + "status_connecting": "#F9AB00", + "status_running": "#1B8A5A", + "status_error": "#D93025", + "table_header_bg": "#F1F3F8", + "table_row_alt": "#F8F9FC", + "table_selected": "#DCE6FA", + "scrollbar_bg": "#EDEFF3", + "scrollbar_handle": "#C3C9D4", + "scrollbar_handle_hover": "#A8B0BE", + "card_bg": "#F7F9FC", + "card_border": "#DDE2EA", + "separator": "#D8DDE6", + "focus_ring": "#2A4FA8", + "hint_text": "#7C838C" } diff --git a/shared/src/orisys_app_shared/theme/qss.py b/shared/src/orisys_app_shared/theme/qss.py index e921d38..9a9fd4a 100644 --- a/shared/src/orisys_app_shared/theme/qss.py +++ b/shared/src/orisys_app_shared/theme/qss.py @@ -21,8 +21,71 @@ def load_colors(theme: str = "dark") -> dict[str, str]: return {str(k): str(v) for k, v in data.items()} +def _arrow_icon_cache_dir() -> Path: + import tempfile + + path = Path(tempfile.gettempdir()) / "orisys_app_theme_icons" + path.mkdir(parents=True, exist_ok=True) + return path + + +@lru_cache(maxsize=16) +def _paint_arrow(kind: str, fill: str) -> str: + """Draw a small chevron PNG on demand; return its path ('' on failure).""" + try: + from PyQt5.QtCore import QPointF, Qt + from PyQt5.QtGui import QColor, QImage, QPainter, QPolygonF + except Exception: # pragma: no cover - headless import failure + return "" + try: + size = 14 + safe = fill.strip().lstrip("#").replace("/", "") + path = _arrow_icon_cache_dir() / f"chevron_{kind}_{safe}.png" + if path.is_file(): + return str(path) + image = QImage(size, size, QImage.Format_ARGB32) + image.fill(Qt.transparent) + painter = QPainter(image) + painter.setRenderHint(QPainter.Antialiasing, True) + painter.setBrush(QColor(fill)) + painter.setPen(Qt.NoPen) + half = size / 2.0 + top = size * 0.32 + bottom = size * 0.68 + if kind == "down": + points = [QPointF(size * 0.18, top), QPointF(size * 0.82, top), QPointF(half, bottom)] + else: + points = [ + QPointF(size * 0.18, bottom), + QPointF(size * 0.82, bottom), + QPointF(half, top), + ] + painter.drawPolygon(QPolygonF(points)) + painter.end() + if not image.save(str(path), "PNG"): + return "" + return str(path) + except Exception: # pragma: no cover - never break theming + return "" + + +def _arrow_image(kind: str, fill: str, size: int = 14) -> str: + path = _paint_arrow(kind, fill) + if not path: + return "" + return 'url("' + path.replace("\\", "/") + '")' + + def build_qss(theme: str = "dark") -> str: c = load_colors(theme) + accent_text = c.get("accent_text", "#FFFFFF") + toolbar_bg = c.get("toolbar_bg", c["panel_bg"]) + dim = c.get("text_secondary", "#888888") + muted = c.get("text_muted", "#6f757c") + arrow_down = _arrow_image("down", dim) + arrow_up = _arrow_image("up", dim) + arrow_down_disabled = _arrow_image("down", muted) + arrow_up_disabled = _arrow_image("up", muted) return f""" QWidget {{ background-color: {c['window_bg']}; @@ -37,6 +100,11 @@ QMenuBar {{ background-color: {c['menu_bg']}; color: {c['text_primary']}; border-bottom: 1px solid {c['panel_border']}; + padding: 2px 4px; +}} +QMenuBar::item {{ + padding: 5px 10px; + border-radius: 4px; }} QMenuBar::item:selected {{ background-color: {c['menu_hover']}; @@ -45,54 +113,433 @@ QMenu {{ background-color: {c['menu_bg']}; color: {c['text_primary']}; border: 1px solid {c['panel_border']}; + border-radius: 8px; + padding: 4px; +}} +QMenu::item {{ + padding: 6px 18px; + border-radius: 4px; }} QMenu::item:selected {{ background-color: {c['menu_hover']}; }} -QToolBar {{ - background-color: {c['panel_bg']}; - border-bottom: 1px solid {c['panel_border']}; - spacing: 8px; - padding: 4px; +QMenu::separator {{ + height: 1px; + background-color: {c['separator']}; + margin: 4px 8px; }} +QMenu::indicator {{ + width: 14px; + height: 14px; + margin-left: 6px; +}} +QToolBar {{ + background-color: {toolbar_bg}; + border-bottom: 1px solid {c['panel_border']}; + spacing: 10px; + padding: 8px 10px; +}} +QToolTip {{ + background-color: {c['menu_bg']}; + color: {c['text_primary']}; + border: 1px solid {c['panel_border']}; + border-radius: 6px; + padding: 5px 8px; +}} +/* ---------- Buttons ---------- */ QPushButton {{ background-color: {c['button_bg']}; color: {c['text_primary']}; border: 1px solid {c['panel_border']}; - border-radius: 4px; - padding: 6px 12px; + border-radius: 8px; + padding: 7px 14px; + min-height: 28px; }} QPushButton:hover {{ background-color: {c['button_hover']}; + border-color: {c['accent_hover']}; +}} +QPushButton:pressed {{ + background-color: {c['menu_hover']}; +}} +QPushButton:focus {{ + border-color: {c['focus_ring']}; }} QPushButton:disabled {{ + color: {c['text_muted']}; + background-color: {c['window_bg']}; + border: 1px solid {c['separator']}; +}} +QPushButton#primaryButton {{ + background-color: {c['accent']}; + color: {accent_text}; + border: 1px solid {c['accent']}; + font-weight: 600; + padding: 7px 18px; +}} +QPushButton#primaryButton:hover {{ + background-color: {c['accent_hover']}; + border-color: {c['accent_hover']}; +}} +QPushButton#primaryButton:pressed, +QPushButton#primaryButton:checked {{ + background-color: {c['accent_hover']}; + border-color: {c['accent']}; +}} +QPushButton#primaryButton:disabled {{ + background-color: {c['panel_border']}; + color: {c['text_muted']}; + border-color: {c['panel_border']}; +}} +QPushButton#dangerButton {{ + background-color: {c['danger']}; + color: {c['danger_text']}; + border: 1px solid {c['danger']}; + font-weight: 600; + padding: 7px 18px; +}} +QPushButton#dangerButton:hover {{ + background-color: {c['danger_hover']}; + border-color: {c['danger_hover']}; +}} +QPushButton#dangerButton:pressed, +QPushButton#dangerButton:checked {{ + background-color: {c['danger_hover']}; + border-color: {c['danger']}; +}} +QPushButton#dangerButton:disabled {{ + background-color: {c['panel_border']}; + color: {c['text_muted']}; + border-color: {c['panel_border']}; +}} +QPushButton#ghostButton {{ + background-color: transparent; + border: 1px solid {c['panel_border']}; color: {c['text_secondary']}; }} -QComboBox, QLineEdit, QSpinBox {{ +QPushButton#ghostButton:hover {{ + background-color: {c['button_hover']}; + color: {c['text_primary']}; +}} +QPushButton[compact="true"] {{ + padding: 6px 9px; + font-size: 12px; +}} +QToolButton {{ + background-color: transparent; + color: {c['text_secondary']}; + border: 1px solid transparent; + border-radius: 6px; + padding: 4px 8px; +}} +QToolButton:hover {{ + background-color: {c['button_hover']}; + color: {c['text_primary']}; + border-color: {c['panel_border']}; +}} +/* ---------- Inputs ---------- */ +QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QAbstractSpinBox {{ background-color: {c['input_bg']}; color: {c['text_primary']}; border: 1px solid {c['input_border']}; + border-radius: 8px; + padding: 5px 10px; + min-height: 28px; + selection-background-color: {c['accent']}; + selection-color: {accent_text}; +}} +QComboBox:hover, QLineEdit:hover, QSpinBox:hover, QDoubleSpinBox:hover {{ + border-color: {c['accent_hover']}; +}} +QComboBox:focus, QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus {{ + border: 1px solid {c['accent']}; +}} +QComboBox:disabled, QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled {{ + color: {c['text_muted']}; + background-color: {c['window_bg']}; + border-color: {c['separator']}; +}} +QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: top right; + width: 22px; + border: none; + background: transparent; +}} +QComboBox::down-arrow {{ + image: {arrow_down}; + width: 12px; + height: 12px; + margin-right: 4px; +}} +QComboBox::down-arrow:disabled {{ + image: {arrow_down_disabled}; +}} +QComboBox QAbstractItemView {{ + background-color: {c['menu_bg']}; + color: {c['text_primary']}; + border: 1px solid {c['panel_border']}; + border-radius: 8px; + padding: 4px; + outline: none; + selection-background-color: {c['menu_hover']}; + selection-color: {c['text_primary']}; +}} +QAbstractSpinBox::up-button, QAbstractSpinBox::down-button {{ + subcontrol-origin: border; + width: 18px; + background: transparent; + border: none; +}} +QAbstractSpinBox::up-button {{ + subcontrol-position: top right; +}} +QAbstractSpinBox::down-button {{ + subcontrol-position: bottom right; +}} +QAbstractSpinBox::up-arrow {{ + image: {arrow_up}; + width: 10px; + height: 10px; +}} +QAbstractSpinBox::down-arrow {{ + image: {arrow_down}; + width: 10px; + height: 10px; +}} +QAbstractSpinBox::up-arrow:disabled {{ + image: {arrow_up_disabled}; +}} +QAbstractSpinBox::down-arrow:disabled {{ + image: {arrow_down_disabled}; +}} +/* ---------- Check / radio ---------- */ +QCheckBox, QRadioButton {{ + background: transparent; + color: {c['text_primary']}; + spacing: 7px; + padding: 2px 0; +}} +QCheckBox::indicator, QRadioButton::indicator {{ + width: 15px; + height: 15px; + background-color: {c['input_bg']}; + border: 1px solid {c['input_border']}; +}} +QCheckBox::indicator {{ border-radius: 4px; - padding: 4px 8px; - min-height: 24px; +}} +QRadioButton::indicator {{ + border-radius: 8px; +}} +QCheckBox::indicator:hover, QRadioButton::indicator:hover {{ + border-color: {c['accent_hover']}; +}} +QCheckBox::indicator:checked, QRadioButton::indicator:checked {{ + background-color: {c['accent']}; + border: 4px solid {c['accent']}; +}} +QRadioButton::indicator:checked {{ + background-color: {c['input_bg']}; + border: 4px solid {c['accent']}; +}} +QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {{ + background-color: {c['window_bg']}; + border-color: {c['separator']}; +}} +/* ---------- Progress ---------- */ +QProgressBar {{ + background-color: {c['input_bg']}; + color: {c['text_secondary']}; + border: 1px solid {c['input_border']}; + border-radius: 7px; + height: 14px; + text-align: center; + font-size: 11px; +}} +QProgressBar::chunk {{ + background-color: {c['accent']}; + border-radius: 6px; + margin: 1px; +}} +/* ---------- Tables ---------- */ +QTableWidget, QTableView {{ + background-color: {c['input_bg']}; + alternate-background-color: {c['table_row_alt']}; + color: {c['text_primary']}; + border: 1px solid {c['panel_border']}; + border-radius: 8px; + gridline-color: {c['separator']}; + outline: none; + selection-background-color: {c['table_selected']}; + selection-color: {c['text_primary']}; +}} +QTableWidget::item, QTableView::item {{ + padding: 4px 6px; + border: none; +}} +QTableWidget::item:selected, QTableView::item:selected {{ + background-color: {c['table_selected']}; + color: {c['text_primary']}; +}} +QHeaderView {{ + background-color: {c['table_header_bg']}; + border: none; +}} +QHeaderView::section {{ + background-color: {c['table_header_bg']}; + color: {c['text_secondary']}; + border: none; + border-right: 1px solid {c['separator']}; + border-bottom: 1px solid {c['panel_border']}; + padding: 5px 6px; + font-weight: 600; + font-size: 12px; +}} +QHeaderView::section:last {{ + border-right: none; +}} +QTableCornerButton::section {{ + background-color: {c['table_header_bg']}; + border: none; +}} +/* ---------- Scrollbars ---------- */ +QScrollBar:vertical {{ + background: {c['scrollbar_bg']}; + width: 10px; + margin: 0; + border: none; +}} +QScrollBar:horizontal {{ + background: {c['scrollbar_bg']}; + height: 10px; + margin: 0; + border: none; +}} +QScrollBar::handle:vertical {{ + background: {c['scrollbar_handle']}; + min-height: 28px; + border-radius: 5px; + margin: 2px; +}} +QScrollBar::handle:horizontal {{ + background: {c['scrollbar_handle']}; + min-width: 28px; + border-radius: 5px; + margin: 2px; +}} +QScrollBar::handle:vertical:hover, QScrollBar::handle:horizontal:hover {{ + background: {c['scrollbar_handle_hover']}; +}} +QScrollBar::add-line, QScrollBar::sub-line {{ + width: 0; + height: 0; + border: none; + background: transparent; +}} +QScrollBar::add-page, QScrollBar::sub-page {{ + background: transparent; +}} +QScrollArea {{ + border: none; + background: transparent; +}} +/* ---------- Labels / cards ---------- */ +QLabel {{ + background: transparent; }} QLabel#panelTitle {{ color: {c['text_secondary']}; font-weight: 600; + font-size: 12px; + letter-spacing: 0.3px; + padding: 1px 0; }} QLabel#panelMeta {{ color: {c['text_secondary']}; font-weight: 500; padding-right: 8px; }} +QLabel#fieldLabel {{ + color: {c['text_secondary']}; + font-weight: 500; +}} +QLabel#hintText {{ + color: {c['hint_text']}; + background: transparent; +}} +QLabel#unitBadge {{ + color: {c['text_secondary']}; + background-color: {c['card_bg']}; + border: 1px solid {c['card_border']}; + border-radius: 6px; + padding: 1px 7px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.4px; +}} +QLabel#valueCard {{ + background-color: {c['card_bg']}; + border: 1px solid {c['card_border']}; + border-radius: 8px; +}} +QLabel#statusChip {{ + color: {c['status_idle']}; + background: transparent; + font-weight: 600; + padding: 0 10px 0 4px; +}} +QLabel#aboutBody {{ + color: {c['text_secondary']}; +}} +/* ---------- Frames ---------- */ QFrame#contentPanel {{ background-color: {c['panel_bg']}; border: 1px solid {c['panel_border']}; - border-radius: 6px; + border-radius: 10px; }} +QFrame#cardPanel {{ + background-color: {c['card_bg']}; + border: 1px solid {c['card_border']}; + border-radius: 8px; +}} +QFrame#sectionSeparator {{ + background-color: {c['separator']}; + border: none; + max-width: 1px; + min-width: 1px; +}} +QGroupBox {{ + background-color: {c['card_bg']}; + border: 1px solid {c['card_border']}; + border-radius: 8px; + margin-top: 10px; + padding: 10px 8px 8px 8px; + font-weight: 600; +}} +QGroupBox::title {{ + subcontrol-origin: margin; + subcontrol-position: top left; + left: 10px; + padding: 0 4px; + color: {c['text_secondary']}; +}} +/* ---------- Status bar ---------- */ QStatusBar {{ background-color: {c['panel_bg']}; color: {c['text_secondary']}; + border-top: 1px solid {c['panel_border']}; +}} +QStatusBar::item {{ + border: none; +}} +QSplitter::handle {{ + background-color: {c['window_bg']}; + width: 6px; + height: 6px; +}} +QSplitter::handle:hover {{ + background-color: {c['accent_hover']}; }} """ @@ -103,3 +550,4 @@ def color(theme: str, key: str, default: str = "#888888") -> str: def clear_theme_cache() -> None: load_colors.cache_clear() + _paint_arrow.cache_clear()