dsh UI improvement

This commit is contained in:
ykcheungab
2026-09-12 13:26:39 +08:00
parent 705eaabcca
commit 803a08fc78
23 changed files with 1861 additions and 167 deletions
+5
View File
@@ -17,6 +17,11 @@ venv/
**/preference.json **/preference.json
apps/customer/preference.json apps/customer/preference.json
# Tooling scratch (local pip/pytest caches)
.piptmp/
.pytestlib/
.pytest_cache/
# IDE / OS # IDE / OS
.idea/ .idea/
.vscode/ .vscode/
+39
View File
@@ -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 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 ## Branding
Window / EXE icon: `shared/resources/branding/logo.png` Window / EXE icon: `shared/resources/branding/logo.png`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

+34 -5
View File
@@ -1,6 +1,6 @@
{ {
"app": { "app": {
"title": "Orisys App", "title": "Orisys Customer",
"about_title": "About", "about_title": "About",
"about_body": "Orisys customer desktop application\nVersion: {app_version}", "about_body": "Orisys customer desktop application\nVersion: {app_version}",
"scaffold_hint": "Scaffold shell — acquisition coming later" "scaffold_hint": "Scaffold shell — acquisition coming later"
@@ -21,6 +21,9 @@
"show_fps": "Show FPS", "show_fps": "Show FPS",
"plot_settings": "Plot settings…", "plot_settings": "Plot settings…",
"rotate_arrows": "Rotate arrows 90°", "rotate_arrows": "Rotate arrows 90°",
"run": "Run / Stop",
"preview": "Preview / Stop preview",
"reset": "Reset tracker",
"help": "Help", "help": "Help",
"about": "About" "about": "About"
}, },
@@ -48,15 +51,24 @@
"preview_stop": "Stop preview", "preview_stop": "Stop preview",
"run": "Run", "run": "Run",
"stop": "Stop", "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", "reset": "Reset",
"units": "Units", "units": "Units",
"units_newton": "Newton", "units_newton": "Newton",
"units_raw": "Raw" "units_raw": "Raw"
}, },
"units": {
"newton_short": "N",
"raw_short": "raw"
},
"sensor": { "sensor": {
"type": "Sensor type", "type": "Sensor type",
"config": "Config", "config": "Config",
"thumb_placeholder": "Thumbnail" "thumb_placeholder": "Product model"
}, },
"panels": { "panels": {
"arrow": "2D arrows", "arrow": "2D arrows",
@@ -64,21 +76,34 @@
"view_arrows_stitched": "Arrows / Captured", "view_arrows_stitched": "Arrows / Captured",
"view_depth": "Depth map", "view_depth": "Depth map",
"view_mode_tip": "Image display mode while running", "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", "plots": "Force Charts",
"cal": "Calibration", "cal": "Calibration",
"mesh3d": "3D mesh", "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": { "cal": {
"weight": "Weight (N)", "weight": "Weight (N)",
"duration": "Record duration", "duration": "Record duration",
"record": "Record", "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", "delete_row": "Delete row",
"clear_channel": "Clear channel", "clear_channel": "Clear channel",
"clear_all": "Clear all", "clear_all": "Clear all",
"export": "Export…", "export": "Export…", "sn": "SN",
"sn": "SN",
"sn_placeholder": "Serial number (optional)", "sn_placeholder": "Serial number (optional)",
"live_raw_label": "Live raw",
"live_raw": "Live raw FN={fn} FX={fx} FY={fy}", "live_raw": "Live raw FN={fn} FX={fx} FY={fy}",
"col_weight": "Weight (N)", "col_weight": "Weight (N)",
"col_raw": "Raw mean", "col_raw": "Raw mean",
@@ -95,6 +120,10 @@
}, },
"status": { "status": {
"ready": "Ready", "ready": "Ready",
"phase_idle": "Idle",
"phase_connecting": "Connecting",
"phase_running": "Running",
"phase_error": "Error",
"camera_none": "No cameras found", "camera_none": "No cameras found",
"camera_found": "Found {count} camera(s): {names}", "camera_found": "Found {count} camera(s): {names}",
"preview_started": "Preview started (camera {camera_id})", "preview_started": "Preview started (camera {camera_id})",
+35 -5
View File
@@ -1,6 +1,6 @@
{ {
"app": { "app": {
"title": "Orisys 应用", "title": "Orisys 客户应用",
"about_title": "关于", "about_title": "关于",
"about_body": "Orisys 客户端桌面应用\n版本: {app_version}", "about_body": "Orisys 客户端桌面应用\n版本: {app_version}",
"scaffold_hint": "脚手架界面 — 采集功能稍后加入" "scaffold_hint": "脚手架界面 — 采集功能稍后加入"
@@ -21,6 +21,9 @@
"show_fps": "显示 FPS", "show_fps": "显示 FPS",
"plot_settings": "绘图设置…", "plot_settings": "绘图设置…",
"rotate_arrows": "旋转箭头图 90°", "rotate_arrows": "旋转箭头图 90°",
"run": "运行 / 停止",
"preview": "预览 / 停止预览",
"reset": "重置追踪器",
"help": "帮助", "help": "帮助",
"about": "关于" "about": "关于"
}, },
@@ -48,15 +51,24 @@
"preview_stop": "停止预览", "preview_stop": "停止预览",
"run": "运行", "run": "运行",
"stop": "停止", "stop": "停止",
"run_label": "▶ 运行",
"stop_label": "■ 停止",
"run_unavailable": "当前不可运行",
"run_disabled_video": "请先选择一个视频文件",
"run_disabled_sensor": "此传感器类型暂不支持运行",
"reset": "重置", "reset": "重置",
"units": "单位", "units": "单位",
"units_newton": "牛顿", "units_newton": "牛顿",
"units_raw": "原始" "units_raw": "原始"
}, },
"units": {
"newton_short": "N",
"raw_short": "raw"
},
"sensor": { "sensor": {
"type": "传感器类型", "type": "传感器类型",
"config": "配置", "config": "配置",
"thumb_placeholder": "缩略图" "thumb_placeholder": "产品型号"
}, },
"panels": { "panels": {
"arrow": "形变箭头", "arrow": "形变箭头",
@@ -64,21 +76,35 @@
"view_arrows_stitched": "箭头/采集图像", "view_arrows_stitched": "箭头/采集图像",
"view_depth": "深度图", "view_depth": "深度图",
"view_mode_tip": "运行时的图像显示模式", "view_mode_tip": "运行时的图像显示模式",
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
"plots": "力曲线图", "plots": "力曲线图",
"cal": "标定", "cal": "标定",
"mesh3d": "三维网格", "mesh3d": "三维网格",
"mesh3d_hint": "手指三维视图占位 — Open3D 网格稍后加入" "mesh3d_empty": "手指三维视图占位 —\nOpen3D 网格稍后加入",
"mesh3d_empty_tip": "手指传感器的三维网格预览"
},
"chart": {
"fn": "FN",
"fx": "FX",
"fy": "FY",
"no_data": "—"
}, },
"cal": { "cal": {
"weight": "砝码重量 (N)", "weight": "砝码重量 (N)",
"duration": "采集时长", "duration": "采集时长",
"record": "记录", "record": "记录",
"recording_stop": "停止",
"record_disabled": "请先点击「运行」开始采集,再记录标定样本",
"record_cancelled": "已取消本次采集",
"empty_hint": "当前通道尚无样本:运行后选择通道并点击「记录」。",
"delete_row": "删除行", "delete_row": "删除行",
"clear_channel": "清空当前通道", "clear_channel": "清空通道",
"clear_all": "清空全部", "clear_all": "全部清空",
"export": "导出…", "export": "导出…",
"sn": "序列号", "sn": "序列号",
"sn_placeholder": "序列号(可选)", "sn_placeholder": "序列号(可选)",
"live_raw_label": "实时原始值",
"live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}", "live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}",
"col_weight": "重量 (N)", "col_weight": "重量 (N)",
"col_raw": "原始均值", "col_raw": "原始均值",
@@ -95,6 +121,10 @@
}, },
"status": { "status": {
"ready": "就绪", "ready": "就绪",
"phase_idle": "空闲",
"phase_connecting": "连接中",
"phase_running": "运行中",
"phase_error": "出错",
"camera_none": "未发现摄像机", "camera_none": "未发现摄像机",
"camera_found": "发现 {count} 个摄像机: {names}", "camera_found": "发现 {count} 个摄像机: {names}",
"preview_started": "预览已启动(摄像机 {camera_id})", "preview_started": "预览已启动(摄像机 {camera_id})",
@@ -2,11 +2,13 @@
"language": "zh_CN", "language": "zh_CN",
"theme": "dark", "theme": "dark",
"window_geometry": null, "window_geometry": null,
"window_default_size": [1440, 880],
"camera_id": "0", "camera_id": "0",
"source_mode": "camera", "source_mode": "camera",
"video_path": "", "video_path": "",
"show_cal_panel": false, "show_cal_panel": false,
"show_finger_3d": true, "show_finger_3d": true,
"show_fps": false,
"sensor_type": "box", "sensor_type": "box",
"config_name": "", "config_name": "",
"units": "newton", "units": "newton",
@@ -14,5 +16,6 @@
"plot_fn_max": 20.0, "plot_fn_max": 20.0,
"plot_shear_max": 20.0, "plot_shear_max": 20.0,
"image_view_mode": "arrows_pure", "image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90 "arrow_rotation_deg": 90,
"splitter_sizes": [340, 480, 400]
} }
+137 -18
View File
@@ -16,7 +16,9 @@ from PyQt5.QtWidgets import (
QActionGroup, QActionGroup,
QApplication, QApplication,
QFileDialog, QFileDialog,
QFrame,
QHBoxLayout, QHBoxLayout,
QLabel,
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QSplitter, 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.logging import report_error
from orisys_app_shared.paths import logo_path, sensor_json_path from orisys_app_shared.paths import logo_path, sensor_json_path
from orisys_app_shared.prefs import save_preferences 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.about_dialog import AboutDialog
from orisys_customer.ui.arrow_panel import ArrowPanel from orisys_customer.ui.arrow_panel import ArrowPanel
@@ -50,6 +52,14 @@ logger = logging.getLogger(__name__)
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"}) RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
ARROW_ROTATE_TYPES = frozenset({"box", "mini"}) 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: def _bgr_to_qimage(frame: np.ndarray) -> QImage:
@@ -109,31 +119,44 @@ class CustomerMainWindow(QMainWindow):
root.setSpacing(8) root.setSpacing(8)
top = QHBoxLayout() top = QHBoxLayout()
top.setSpacing(10)
self.toolbar = ToolbarWidget() self.toolbar = ToolbarWidget()
self.sensor_picker = SensorPickerWidget() 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.toolbar, 2)
top.addWidget(self.sensor_picker, 1) top.addWidget(sensor_card)
root.addLayout(top) root.addLayout(top)
self.content_splitter = QSplitter(Qt.Horizontal) self.content_splitter = QSplitter(Qt.Horizontal)
self.mesh3d_panel = Mesh3dPanel() self.mesh3d_panel = Mesh3dPanel()
self.arrow_panel = ArrowPanel() self.arrow_panel = ArrowPanel()
self.force_plots = ForcePlotsPanel(theme=self._theme) 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.mesh3d_panel)
self.content_splitter.addWidget(self.arrow_panel) self.content_splitter.addWidget(self.arrow_panel)
self.content_splitter.addWidget(self.force_plots) self.content_splitter.addWidget(self.force_plots)
self.content_splitter.addWidget(self.cal_panel) self.content_splitter.addWidget(self.cal_panel)
self.content_splitter.setChildrenCollapsible(False)
root.addWidget(self.content_splitter, 1) root.addWidget(self.content_splitter, 1)
self.setStatusBar(QStatusBar()) 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.status_message.connect(self.statusBar().showMessage)
self.cal_panel.calibration_exported.connect(self._on_calibration_exported) self.cal_panel.calibration_exported.connect(self._on_calibration_exported)
self._set_phase("idle")
self._build_menus() self._build_menus()
self._wire_toolbar() self._wire_toolbar()
self._wire_sensor_picker() self._wire_sensor_picker()
self._restore_prefs_into_ui() self._restore_prefs_into_ui()
self._apply_panel_visibility() self._apply_panel_visibility()
self._restore_splitter_sizes()
self._update_run_enabled() self._update_run_enabled()
self.cal_panel.set_meta_context( self.cal_panel.set_meta_context(
sensor_type=self.sensor_picker.sensor_type(), sensor_type=self.sensor_picker.sensor_type(),
@@ -141,7 +164,7 @@ class CustomerMainWindow(QMainWindow):
) )
self.retranslate() self.retranslate()
self._restore_geometry() self._restore_geometry()
self.resize(1280, 720) self._apply_default_size()
@property @property
def _preview_open(self) -> bool: def _preview_open(self) -> bool:
@@ -210,6 +233,34 @@ class CustomerMainWindow(QMainWindow):
self.prefs["config_name"] = self.sensor_picker.config_name() self.prefs["config_name"] = self.sensor_picker.config_name()
self._apply_arrow_rotation_for_sensor() 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: def _build_menus(self) -> None:
menu_bar = self.menuBar() menu_bar = self.menuBar()
@@ -249,6 +300,22 @@ class CustomerMainWindow(QMainWindow):
self.view_menu.addAction(self.act_rotate_arrows) self.view_menu.addAction(self.act_rotate_arrows)
self.view_menu.addSeparator() 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_menu = self.view_menu.addMenu("")
self.theme_group = QActionGroup(self) self.theme_group = QActionGroup(self)
self.theme_group.setExclusive(True) self.theme_group.setExclusive(True)
@@ -304,6 +371,10 @@ class CustomerMainWindow(QMainWindow):
show_3d = is_finger and bool(self._show_finger_3d) show_3d = is_finger and bool(self._show_finger_3d)
self.mesh3d_panel.setVisible(show_3d) self.mesh3d_panel.setVisible(show_3d)
self.cal_panel.setVisible(self._show_cal) 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.setEnabled(is_finger)
self.act_finger_3d.blockSignals(True) self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(show_3d) self.act_finger_3d.setChecked(show_3d)
@@ -311,22 +382,28 @@ class CustomerMainWindow(QMainWindow):
for i in range(self.content_splitter.count()): for i in range(self.content_splitter.count()):
widget = self.content_splitter.widget(i) widget = self.content_splitter.widget(i)
if widget is self.mesh3d_panel: 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: elif widget is self.arrow_panel:
self.content_splitter.setStretchFactor(i, 3) self.content_splitter.setStretchFactor(i, 3)
elif widget is self.force_plots: elif widget is self.force_plots:
self.content_splitter.setStretchFactor(i, 3) self.content_splitter.setStretchFactor(i, 4)
elif widget is self.cal_panel: 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: def _update_run_enabled(self) -> None:
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
source_ok = True 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 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)) self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
if not self._running: if not self._running:
self.toolbar._refresh_run_label() self.toolbar.refresh_labels()
self._sync_shortcut_actions()
def retranslate(self) -> None: def retranslate(self) -> None:
self.setWindowTitle(t("app.title")) self.setWindowTitle(t("app.title"))
@@ -339,6 +416,9 @@ class CustomerMainWindow(QMainWindow):
self.act_show_fps.setText(t("menu.show_fps")) self.act_show_fps.setText(t("menu.show_fps"))
self.act_plot_settings.setText(t("menu.plot_settings")) self.act_plot_settings.setText(t("menu.plot_settings"))
self.act_rotate_arrows.setText(t("menu.rotate_arrows")) 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.theme_menu.setTitle(t("menu.theme"))
self.act_theme_dark.setText(t("menu.theme_dark")) self.act_theme_dark.setText(t("menu.theme_dark"))
self.act_theme_light.setText(t("menu.theme_light")) self.act_theme_light.setText(t("menu.theme_light"))
@@ -353,17 +433,38 @@ class CustomerMainWindow(QMainWindow):
self.arrow_panel.retranslate() self.arrow_panel.retranslate()
self.force_plots.retranslate() self.force_plots.retranslate()
self.cal_panel.retranslate() self.cal_panel.retranslate()
self._refresh_status_chip()
if not self._preview_open and not self._running: if not self._preview_open and not self._running:
self.statusBar().showMessage(t("status.ready")) self.statusBar().showMessage(t("status.ready"))
self._update_run_enabled() 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: def _set_theme(self, theme: str) -> None:
self._theme = theme self._theme = theme
self.prefs["theme"] = theme self.prefs["theme"] = theme
clear_theme_cache()
app = QApplication.instance() app = QApplication.instance()
if app is not None: if app is not None:
app.setStyleSheet(build_qss(theme)) app.setStyleSheet(build_qss(theme))
self.force_plots.set_theme(theme) self.force_plots.set_theme(theme)
self.cal_panel.set_theme(theme)
self._refresh_status_chip()
self._sync_menu_checks() self._sync_menu_checks()
self._persist_prefs() self._persist_prefs()
@@ -482,6 +583,24 @@ class CustomerMainWindow(QMainWindow):
self.prefs["arrow_rotation_deg"] = deg self.prefs["arrow_rotation_deg"] = deg
self._persist_prefs() 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: def _on_config_changed(self, config_name: str) -> None:
self.prefs["config_name"] = str(config_name) self.prefs["config_name"] = str(config_name)
self._persist_prefs() self._persist_prefs()
@@ -585,7 +704,7 @@ class CustomerMainWindow(QMainWindow):
self._preview.close() self._preview.close()
if not self._running: if not self._running:
self.toolbar.set_source_controls_enabled(True) 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.toolbar.set_preview_checked(False)
self._update_run_enabled() self._update_run_enabled()
if announce: if announce:
@@ -655,12 +774,11 @@ class CustomerMainWindow(QMainWindow):
return return
self._running = True 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_meta_context(sensor_type=sensor_type, config_ref=config_ref)
self.cal_panel.set_running(True) self.cal_panel.set_running(True)
self.toolbar.set_source_controls_enabled(False) self.toolbar.set_source_controls_enabled(False)
self.sensor_picker.type_combo.setEnabled(False) self.sensor_picker.set_picker_enabled(False)
self.sensor_picker.config_combo.setEnabled(False)
self.toolbar.set_reset_enabled(False) self.toolbar.set_reset_enabled(False)
self._run_timer.start() self._run_timer.start()
self.statusBar().showMessage(t("status.connecting")) self.statusBar().showMessage(t("status.connecting"))
@@ -676,17 +794,16 @@ class CustomerMainWindow(QMainWindow):
if self._active_worker is not None: if self._active_worker is not None:
self._active_worker.stop() self._active_worker.stop()
self._running = False self._running = False
self._session_phase = "idle" self._set_phase("error" if reason == "failed" else "idle")
self.cal_panel.set_running(False) self.cal_panel.set_running(False)
self.toolbar.set_run_checked(False) self.toolbar.set_run_checked(False)
self.toolbar.set_reset_enabled(False) self.toolbar.set_reset_enabled(False)
self.toolbar.set_source_controls_enabled(True) self.toolbar.set_source_controls_enabled(True)
self.sensor_picker.type_combo.setEnabled(True) self.sensor_picker.set_picker_enabled(True)
self.sensor_picker.config_combo.setEnabled(True)
self._update_run_enabled() self._update_run_enabled()
keep_frame = reason in ("ended", "disconnected") keep_frame = reason in ("ended", "disconnected")
if not keep_frame: if not keep_frame:
self.arrow_panel.clear_preview(t("app.scaffold_hint")) self.arrow_panel.clear_preview(t("panels.arrow_empty"))
else: else:
self.arrow_panel.set_fps(None) self.arrow_panel.set_fps(None)
if reason == "ended": if reason == "ended":
@@ -758,8 +875,9 @@ class CustomerMainWindow(QMainWindow):
is_new = packet_key != self._last_plot_packet_key is_new = packet_key != self._last_plot_packet_key
if self._session_phase == "connecting" and is_new: if self._session_phase == "connecting" and is_new:
self._session_phase = "running" self._set_phase("running")
self.toolbar.set_reset_enabled(True) self.toolbar.set_reset_enabled(True)
self._sync_shortcut_actions()
self.statusBar().showMessage( self.statusBar().showMessage(
t("status.run_started", source=self._run_source_label) t("status.run_started", source=self._run_source_label)
) )
@@ -847,6 +965,7 @@ class CustomerMainWindow(QMainWindow):
def _persist_prefs(self) -> None: def _persist_prefs(self) -> None:
geo = self.saveGeometry().toHex().data().decode("ascii") geo = self.saveGeometry().toHex().data().decode("ascii")
self.prefs["window_geometry"] = geo 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["theme"] = self._theme
self.prefs["language"] = self._language self.prefs["language"] = self._language
self.prefs["camera_id"] = str(self.toolbar.camera_id()) self.prefs["camera_id"] = str(self.toolbar.camera_id())
@@ -59,9 +59,10 @@ class ArrowPanel(QFrame):
self.image_label = QLabel() self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignCenter) 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.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.image_label.setScaledContents(False) self.image_label.setScaledContents(False)
self.image_label.setWordWrap(True)
layout.addWidget(self.image_label, 1) layout.addWidget(self.image_label, 1)
self._placeholder = "" self._placeholder = ""
self._populate_view_combo() self._populate_view_combo()
@@ -143,8 +144,9 @@ class ArrowPanel(QFrame):
if i < self.view_combo.count(): if i < self.view_combo.count():
self.view_combo.setItemText(i, label) self.view_combo.setItemText(i, label)
self.view_combo.setToolTip(t("panels.view_mode_tip")) 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(): 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) self.image_label.setText(self._placeholder)
def _effective_rotation_deg(self) -> int: def _effective_rotation_deg(self) -> int:
@@ -181,7 +183,7 @@ class ArrowPanel(QFrame):
self._source_image = None self._source_image = None
self.image_label.clear() self.image_label.clear()
self.image_label.setPixmap(QPixmap()) 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._placeholder = text
self.image_label.setText(text) self.image_label.setText(text)
self.set_fps(None) self.set_fps(None)
+115 -17
View File
@@ -6,6 +6,7 @@ import math
import time import time
from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QFont, QFontDatabase
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QButtonGroup, QButtonGroup,
@@ -20,6 +21,7 @@ from PyQt5.QtWidgets import (
QProgressBar, QProgressBar,
QPushButton, QPushButton,
QRadioButton, QRadioButton,
QSizePolicy,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
QVBoxLayout, QVBoxLayout,
@@ -34,15 +36,27 @@ from orisys_app_shared.force.calibration_io import (
) )
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.paths import calibration_export_dir, sensors_dir 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): class CalPanel(QFrame):
status_message = pyqtSignal(str) status_message = pyqtSignal(str)
calibration_exported = pyqtSignal(str) # path applied to running sensor 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) super().__init__(parent)
self.setObjectName("contentPanel") self.setObjectName("contentPanel")
self._theme = theme
self._store = CalSessionStore() self._store = CalSessionStore()
self._channel: ChannelKey = "fn" self._channel: ChannelKey = "fn"
self._running = False self._running = False
@@ -56,13 +70,24 @@ class CalPanel(QFrame):
self._sensor_type = "box" self._sensor_type = "box"
self._config_ref: str | None = None self._config_ref: str | None = None
self._record_duration_s = 7.0 self._record_duration_s = 7.0
self._mono = _mono_font()
self._mono_font = QFont(self._mono, 10)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setSpacing(8)
title_row = QHBoxLayout()
self.title = QLabel() self.title = QLabel()
self.title.setObjectName("panelTitle") 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 = QHBoxLayout()
channel_row.setSpacing(10)
self.channel_group = QButtonGroup(self) self.channel_group = QButtonGroup(self)
self.radio_fn = QRadioButton("FN") self.radio_fn = QRadioButton("FN")
self.radio_fx = QRadioButton("FX") self.radio_fx = QRadioButton("FX")
@@ -76,13 +101,18 @@ class CalPanel(QFrame):
root.addLayout(channel_row) root.addLayout(channel_row)
form = QFormLayout() form = QFormLayout()
form.setHorizontalSpacing(10)
form.setVerticalSpacing(6)
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
self._weight_label = QLabel() self._weight_label = QLabel()
self._weight_label.setObjectName("fieldLabel")
self.weight_spin = QDoubleSpinBox() self.weight_spin = QDoubleSpinBox()
self.weight_spin.setRange(0.0, 1e6) self.weight_spin.setRange(0.0, 1e6)
self.weight_spin.setDecimals(4) self.weight_spin.setDecimals(4)
self.weight_spin.setSingleStep(0.1) self.weight_spin.setSingleStep(0.1)
self.weight_spin.setValue(0.0) self.weight_spin.setValue(0.0)
self._duration_label = QLabel() self._duration_label = QLabel()
self._duration_label.setObjectName("fieldLabel")
self.duration_spin = QDoubleSpinBox() self.duration_spin = QDoubleSpinBox()
self.duration_spin.setRange(5.0, 10.0) self.duration_spin.setRange(5.0, 10.0)
self.duration_spin.setDecimals(1) self.duration_spin.setDecimals(1)
@@ -95,6 +125,7 @@ class CalPanel(QFrame):
rec_row = QHBoxLayout() rec_row = QHBoxLayout()
self.btn_record = QPushButton() self.btn_record = QPushButton()
self.btn_record.setMinimumHeight(32)
self.btn_record.clicked.connect(self._on_record_clicked) self.btn_record.clicked.connect(self._on_record_clicked)
rec_row.addWidget(self.btn_record) rec_row.addWidget(self.btn_record)
rec_row.addStretch(1) rec_row.addStretch(1)
@@ -108,18 +139,30 @@ class CalPanel(QFrame):
self.live_label = QLabel() self.live_label = QLabel()
self.live_label.setWordWrap(True) self.live_label.setWordWrap(True)
self.live_label.setFont(self._mono_font)
root.addWidget(self.live_label) 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.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) 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().setVisible(False)
self.table.verticalHeader().setDefaultSectionSize(26)
root.addWidget(self.table, 1) root.addWidget(self.table, 1)
sn_row = QHBoxLayout() sn_row = QHBoxLayout()
self._sn_label = QLabel() self._sn_label = QLabel()
self._sn_label.setObjectName("fieldLabel")
self.sn_edit = QLineEdit() self.sn_edit = QLineEdit()
self.sn_edit.setPlaceholderText("") self.sn_edit.setPlaceholderText("")
sn_row.addWidget(self._sn_label) sn_row.addWidget(self._sn_label)
@@ -127,10 +170,16 @@ class CalPanel(QFrame):
root.addLayout(sn_row) root.addLayout(sn_row)
actions = QHBoxLayout() actions = QHBoxLayout()
actions.setSpacing(6)
self.btn_delete = QPushButton() self.btn_delete = QPushButton()
self.btn_clear_channel = QPushButton() self.btn_clear_channel = QPushButton()
self.btn_clear_all = QPushButton() self.btn_clear_all = QPushButton()
self.btn_export = 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_delete.clicked.connect(self._on_delete_row)
self.btn_clear_channel.clicked.connect(self._on_clear_channel) self.btn_clear_channel.clicked.connect(self._on_clear_channel)
self.btn_clear_all.clicked.connect(self._on_clear_all) self.btn_clear_all.clicked.connect(self._on_clear_all)
@@ -144,8 +193,14 @@ class CalPanel(QFrame):
self._refresh_enabled() self._refresh_enabled()
self._refresh_table() self._refresh_table()
self.set_theme(theme)
self.retranslate() self.retranslate()
# ------------------------------------------------------------------ API
def set_theme(self, theme: str) -> None:
self._theme = theme
self._render_live_label()
def set_running(self, running: bool) -> None: def set_running(self, running: bool) -> None:
self._running = bool(running) self._running = bool(running)
if not self._running and self._recording: if not self._running and self._recording:
@@ -160,7 +215,7 @@ class CalPanel(QFrame):
self._live_fn = float(fn) self._live_fn = float(fn)
self._live_fx = float(fx) self._live_fx = float(fx)
self._live_fy = float(fy) self._live_fy = float(fy)
self._update_live_label() self._render_live_label()
if not self._recording: if not self._recording:
return return
now = time.perf_counter() now = time.perf_counter()
@@ -177,19 +232,21 @@ class CalPanel(QFrame):
self.title.setText(t("panels.cal")) self.title.setText(t("panels.cal"))
self._weight_label.setText(t("cal.weight")) self._weight_label.setText(t("cal.weight"))
self._duration_label.setText(t("cal.duration")) 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_delete.setText(t("cal.delete_row"))
self.btn_clear_channel.setText(t("cal.clear_channel")) self.btn_clear_channel.setText(t("cal.clear_channel"))
self.btn_clear_all.setText(t("cal.clear_all")) self.btn_clear_all.setText(t("cal.clear_all"))
self.btn_export.setText(t("cal.export")) self.btn_export.setText(t("cal.export"))
self._sn_label.setText(t("cal.sn")) self._sn_label.setText(t("cal.sn"))
self.sn_edit.setPlaceholderText(t("cal.sn_placeholder")) self.sn_edit.setPlaceholderText(t("cal.sn_placeholder"))
self.empty_hint.setText(t("cal.empty_hint"))
headers = ( headers = (
t("cal.col_weight"), t("cal.col_weight"),
t("cal.col_raw"), t("cal.col_raw"),
t("cal.col_n"), t("cal.col_n"),
t("cal.col_std"), t("cal.col_std"),
t("cal.col_actions"),
) )
for i, text in enumerate(headers): for i, text in enumerate(headers):
item = self.table.horizontalHeaderItem(i) item = self.table.horizontalHeaderItem(i)
@@ -198,9 +255,20 @@ class CalPanel(QFrame):
self.table.setHorizontalHeaderItem(i, item) self.table.setHorizontalHeaderItem(i, item)
else: else:
item.setText(text) item.setText(text)
self._update_live_label() self._refresh_channel_badge()
self._render_live_label()
self._refresh_enabled() 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: def _on_channel_button(self, button, checked: bool) -> None:
if not checked: if not checked:
return return
@@ -211,35 +279,60 @@ class CalPanel(QFrame):
if self._recording: if self._recording:
self._cancel_recording() self._cancel_recording()
self._channel = key # type: ignore[assignment] self._channel = key # type: ignore[assignment]
self._refresh_channel_badge()
self._refresh_table() self._refresh_table()
def _refresh_enabled(self) -> None: def _refresh_enabled(self) -> None:
can_record = self._running and not self._recording can_record = self._running and not self._recording
self.btn_record.setEnabled(can_record) 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.weight_spin.setEnabled(not self._recording)
self.duration_spin.setEnabled(not self._recording) self.duration_spin.setEnabled(not self._recording)
for radio in (self.radio_fn, self.radio_fx, self.radio_fy): for radio in (self.radio_fn, self.radio_fx, self.radio_fy):
radio.setEnabled(not self._recording) radio.setEnabled(not self._recording)
self.btn_export.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: def _render_live_label(self) -> None:
self.live_label.setText( colors = {
t( "fn": color(self._theme, "plot_fn"),
"cal.live_raw", "fx": color(self._theme, "plot_fx"),
fn=f"{self._live_fn:.3f}", "fy": color(self._theme, "plot_fy"),
fx=f"{self._live_fx:.3f}", }
fy=f"{self._live_fy:.3f}", parts = []
for key, value in (
("fn", self._live_fn),
("fx", self._live_fx),
("fy", self._live_fy),
):
parts.append(
f"<span style='color:{colors[key]}; font-weight:600'>{key.upper()}</span>"
f"&nbsp;{value:.3f}"
) )
self.live_label.setText(
f"<span style='color:{color(self._theme, 'text_secondary')}'>"
f"{t('cal.live_raw_label')}</span>&nbsp;&nbsp;" + "&nbsp;&nbsp;&nbsp;".join(parts)
) )
def _on_record_clicked(self) -> None: 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 return
self._recording = True self._recording = True
self._record_samples = [] self._record_samples = []
self._record_duration_s = float(self.duration_spin.value()) self._record_duration_s = float(self.duration_spin.value())
self._record_started = time.perf_counter() self._record_started = time.perf_counter()
self._record_deadline = self._record_started + self._record_duration_s self._record_deadline = self._record_started + self._record_duration_s
self.progress.setVisible(True)
self.progress.setValue(0) self.progress.setValue(0)
self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s") self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s")
self._refresh_enabled() self._refresh_enabled()
@@ -257,6 +350,7 @@ class CalPanel(QFrame):
self._recording = False self._recording = False
self._record_samples = [] self._record_samples = []
self.progress.setValue(1000) self.progress.setValue(1000)
self.progress.setFormat("")
self._refresh_enabled() self._refresh_enabled()
if len(samples) < 2: if len(samples) < 2:
self.status_message.emit(t("cal.record_failed_few")) self.status_message.emit(t("cal.record_failed_few"))
@@ -285,12 +379,16 @@ class CalPanel(QFrame):
f"{sample.raw_mean:.4f}", f"{sample.raw_mean:.4f}",
str(sample.n), str(sample.n),
f"{sample.std:.4f}", f"{sample.std:.4f}",
"",
) )
for c, text in enumerate(values): for c, text in enumerate(values):
item = QTableWidgetItem(text) item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemIsEditable) 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.table.setItem(r, c, item)
self.empty_hint.setVisible(not rows)
def _on_delete_row(self) -> None: def _on_delete_row(self) -> None:
row = self.table.currentRow() row = self.table.currentRow()
@@ -4,7 +4,7 @@ from __future__ import annotations
import numpy as np import numpy as np
from PyQt5.QtCore import Qt 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 PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ 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 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]]: def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
if hi <= lo: if hi <= lo:
return [(lo, f"{lo:g}")] 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)] 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): class ForcePlotsPanel(QFrame):
def __init__(self, parent=None, *, theme: str = "dark") -> None: def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent) super().__init__(parent)
@@ -35,6 +65,7 @@ class ForcePlotsPanel(QFrame):
self._shear_max = 20.0 self._shear_max = 20.0
self._fixed_newton = True self._fixed_newton = True
self._unit = "raw" self._unit = "raw"
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
self.history = MetricHistory( self.history = MetricHistory(
channels=3, channels=3,
window_sec=4.0, window_sec=4.0,
@@ -45,45 +76,96 @@ class ForcePlotsPanel(QFrame):
) )
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(10, 8, 10, 10)
root.setSpacing(6)
header = QHBoxLayout()
header.setSpacing(6)
self.title = QLabel() self.title = QLabel()
self.title.setObjectName("panelTitle") 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 = pg.GraphicsLayoutWidget()
self.plot_widget.ci.setSpacing(2)
plot_layout.addWidget(self.plot_widget)
root.addWidget(self.plot_frame, 1)
self.plots = [] self.plots = []
self.curves = [] self.curves = []
names = ("FN", "FX", "FY") pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2)
for i, name in enumerate(names): for i, _name in enumerate(CHANNEL_LABELS):
plot = self.plot_widget.addPlot(row=i, col=0) plot = self.plot_widget.addPlot(row=i, col=0)
plot.showGrid(x=True, y=True, alpha=0.25) plot.showGrid(x=True, y=True, alpha=0.25)
plot.setLabel("left", name)
plot.setMouseEnabled(x=False, y=False) plot.setMouseEnabled(x=False, y=False)
if i > 0: if i > 0:
plot.setXLink(self.plots[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.plots.append(plot)
self.curves.append(curve) self.curves.append(curve)
root.addWidget(self.plot_widget, 1)
values_row = QHBoxLayout() values_row = QHBoxLayout()
self.value_labels = [] values_row.setSpacing(6)
for _ in names: self.value_cards: list[QFrame] = []
lab = QLabel("—") self.value_labels: list[QLabel] = []
lab.setAlignment(Qt.AlignCenter) self.channel_badges: list[QLabel] = []
values_row.addWidget(lab) self.value_units: list[QLabel] = []
self.value_labels.append(lab) 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) root.addLayout(values_row)
self.set_theme(theme) self.set_theme(theme)
self._apply_axis_mode() self._apply_axis_mode()
self.retranslate() 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: def set_theme(self, theme: str) -> None:
self._theme = theme self._theme = theme
bg = color(theme, "plot_bg", "#121316") bg = color(theme, "plot_bg")
text = color(theme, "text_primary", "#e8eaed") text = color(theme, "text_primary")
self.plot_widget.setBackground(bg) self.plot_widget.setBackground(bg)
axis_pen = pg.mkPen(text) axis_pen = pg.mkPen(text)
for plot in self.plots: for i, plot in enumerate(self.plots):
try: try:
plot.getViewBox().setBackgroundColor(QColor(bg)) plot.getViewBox().setBackgroundColor(QColor(bg))
except Exception: except Exception:
@@ -93,16 +175,22 @@ class ForcePlotsPanel(QFrame):
axis.setPen(axis_pen) axis.setPen(axis_pen)
axis.setTextPen(axis_pen) axis.setTextPen(axis_pen)
plot.showGrid(x=True, y=True, alpha=0.25) plot.showGrid(x=True, y=True, alpha=0.25)
label = plot.getAxis("left").labelText or "" # Color each channel axis label to match its curve pen.
if label: plot.setLabel("left", CHANNEL_LABELS[i], color=color(theme, CHANNEL_TOKENS[i]))
plot.setLabel("left", label, color=text) pens = tuple(color(theme, token) for token in CHANNEL_TOKENS)
pens = (
color(theme, "plot_fn", "#8ab4f8"),
color(theme, "plot_fx", "#34a853"),
color(theme, "plot_fy", "#fbbc04"),
)
for curve, pen in zip(self.curves, pens): for curve, pen in zip(self.curves, pens):
curve.setPen(pg.mkPen(pen, width=2)) 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: def set_window_sec(self, sec: float) -> None:
self.history.set_window_sec(sec) self.history.set_window_sec(sec)
@@ -121,8 +209,9 @@ class ForcePlotsPanel(QFrame):
def reset(self) -> None: def reset(self) -> None:
self.history.reset() self.history.reset()
self._clear_curves() self._clear_curves()
for lab in self.value_labels: self._last_values = (0.0, 0.0, 0.0)
lab.setText("—") self._unit = "raw"
self._render_value_cards()
self._apply_axis_mode() self._apply_axis_mode()
def push( def push(
@@ -135,10 +224,11 @@ class ForcePlotsPanel(QFrame):
unit: str = "raw", unit: str = "raw",
) -> None: ) -> None:
self._unit = unit self._unit = unit
self._last_values = (float(fn), float(fx), float(fy))
use_fixed = self._uses_fixed_y() use_fixed = self._uses_fixed_y()
plot_fn = max(0.0, float(fn)) if use_fixed else float(fn) plot_fn = max(0.0, float(fn)) if use_fixed else float(fn)
self.history.push(timestamp, plot_fn, float(fx), float(fy)) 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() state = self.history.state()
if state is None: if state is None:
return return
@@ -182,11 +272,17 @@ class ForcePlotsPanel(QFrame):
for curve in self.curves: for curve in self.curves:
curve.setData([], []) curve.setData([], [])
def _update_value_labels(self, fn: float, fx: float, fy: float) -> None: def _render_value_cards(self) -> None:
unit = self._unit fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}"
fmt = "{name}: {value:.4f} {unit}" if unit == "N" else "{name}: {value:.2f} {unit}" for value_label, unit_label, value in zip(
for lab, name, value in zip(self.value_labels, ("FN", "FX", "FY"), (fn, fx, fy)): self.value_labels, self.value_units, self._last_values
lab.setText(fmt.format(name=name, value=value, unit=unit)) ):
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: def retranslate(self) -> None:
self.title.setText(t("panels.plots")) self.title.setText(t("panels.plots"))
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
badge.setText(label)
self._render_value_cards()
@@ -13,15 +13,23 @@ class Mesh3dPanel(QFrame):
super().__init__(parent) super().__init__(parent)
self.setObjectName("contentPanel") self.setObjectName("contentPanel")
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
header = QVBoxLayout()
header.setSpacing(2)
self.title = QLabel() self.title = QLabel()
self.title.setObjectName("panelTitle") self.title.setObjectName("panelTitle")
header.addWidget(self.title)
layout.addLayout(header)
self.body = QLabel() self.body = QLabel()
self.body.setObjectName("hintText")
self.body.setAlignment(Qt.AlignCenter) self.body.setAlignment(Qt.AlignCenter)
self.body.setWordWrap(True) self.body.setWordWrap(True)
layout.addWidget(self.title)
layout.addWidget(self.body, 1) layout.addWidget(self.body, 1)
self.retranslate() self.retranslate()
def retranslate(self) -> None: def retranslate(self) -> None:
self.title.setText(t("panels.mesh3d")) 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"))
@@ -1,13 +1,26 @@
"""Sensor type + config picker with thumbnail placeholder.""" """Sensor type + config picker with product badge."""
from __future__ import annotations from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal 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.i18n import t
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types 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): class SensorPickerWidget(QWidget):
sensor_type_changed = pyqtSignal(str) sensor_type_changed = pyqtSignal(str)
@@ -16,38 +29,49 @@ class SensorPickerWidget(QWidget):
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
layout = QHBoxLayout(self) layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(8, 6, 8, 6)
layout.setSpacing(8) layout.setSpacing(10)
self.thumb = QFrame() self.thumb = QFrame()
self.thumb.setObjectName("contentPanel") self.thumb.setObjectName("cardPanel")
self.thumb.setFixedSize(72, 72) self.thumb.setFixedSize(THUMB_SIZE, THUMB_SIZE)
thumb_layout = QVBoxLayout(self.thumb) thumb_layout = QVBoxLayout(self.thumb)
thumb_layout.setContentsMargins(2, 2, 2, 2)
thumb_layout.setSpacing(0)
self.thumb_label = QLabel() self.thumb_label = QLabel()
self.thumb_label.setObjectName("unitBadge")
self.thumb_label.setAlignment(Qt.AlignCenter) self.thumb_label.setAlignment(Qt.AlignCenter)
self.thumb_label.setWordWrap(True) self.thumb_label.setWordWrap(False)
thumb_layout.addWidget(self.thumb_label) thumb_layout.addWidget(self.thumb_label)
form = QVBoxLayout() form = QFormLayout()
form.setSpacing(4) form.setContentsMargins(0, 0, 0, 0)
type_row = QHBoxLayout() form.setHorizontalSpacing(8)
form.setVerticalSpacing(6)
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
self.type_label = QLabel() self.type_label = QLabel()
self.type_label.setObjectName("fieldLabel")
self.type_combo = QComboBox() 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(): for name in list_sensor_types():
self.type_combo.addItem(name, name) self.type_combo.addItem(name, name)
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed) 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 = QLabel()
self.config_label.setObjectName("fieldLabel")
self.config_combo = QComboBox() 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) 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.addRow(self.type_label, self.type_combo)
form.addLayout(config_row) form.addRow(self.config_label, self.config_combo)
layout.addWidget(self.thumb) layout.addWidget(self.thumb)
layout.addLayout(form, 1) layout.addLayout(form, 1)
self._reload_configs() self._reload_configs()
@@ -88,6 +112,11 @@ class SensorPickerWidget(QWidget):
self.config_combo.setCurrentIndex(max(0, idx)) self.config_combo.setCurrentIndex(max(0, idx))
self.config_combo.blockSignals(False) 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: def _reload_configs(self) -> None:
self.config_combo.blockSignals(True) self.config_combo.blockSignals(True)
self.config_combo.clear() self.config_combo.clear()
@@ -98,8 +127,7 @@ class SensorPickerWidget(QWidget):
self.config_combo.blockSignals(False) self.config_combo.blockSignals(False)
def _refresh_thumb(self) -> None: def _refresh_thumb(self) -> None:
name = self.sensor_type().upper() self.thumb_label.setText(self.sensor_type().upper())
self.thumb_label.setText(name)
def _on_type_index_changed(self, _index: int) -> None: def _on_type_index_changed(self, _index: int) -> None:
self._reload_configs() self._reload_configs()
@@ -113,7 +141,5 @@ class SensorPickerWidget(QWidget):
def retranslate(self) -> None: def retranslate(self) -> None:
self.type_label.setText(t("sensor.type")) self.type_label.setText(t("sensor.type"))
self.config_label.setText(t("sensor.config")) self.config_label.setText(t("sensor.config"))
if not self.thumb_label.text().strip(): self.thumb.setToolTip(t("sensor.thumb_placeholder"))
self.thumb_label.setText(t("sensor.thumb_placeholder")) self._refresh_thumb()
else:
self._refresh_thumb()
+113 -26
View File
@@ -4,20 +4,46 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from PyQt5.QtCore import pyqtSignal from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QComboBox, QComboBox,
QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox, QSpinBox,
QStackedWidget, QStackedWidget,
QVBoxLayout,
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t 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): class ToolbarWidget(QWidget):
search_clicked = pyqtSignal() search_clicked = pyqtSignal()
@@ -31,12 +57,31 @@ class ToolbarWidget(QWidget):
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
layout = QHBoxLayout(self) self._run_tooltip_reason = ""
layout.setContentsMargins(0, 0, 0, 0) self._run_available = True
layout.setSpacing(8) 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 = QLabel()
self.source_label.setObjectName("fieldLabel")
self.source_combo = QComboBox() 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_combo.currentIndexChanged.connect(self._on_source_index_changed)
self.source_stack = QStackedWidget() self.source_stack = QStackedWidget()
@@ -45,10 +90,12 @@ class ToolbarWidget(QWidget):
cam_layout.setContentsMargins(0, 0, 0, 0) cam_layout.setContentsMargins(0, 0, 0, 0)
cam_layout.setSpacing(8) cam_layout.setSpacing(8)
self.cam_id_label = QLabel() self.cam_id_label = QLabel()
self.cam_id_label.setObjectName("fieldLabel")
self.cam_id_spin = QSpinBox() self.cam_id_spin = QSpinBox()
self.cam_id_spin.setRange(0, 20) self.cam_id_spin.setRange(0, 20)
self.cam_id_spin.setValue(0) 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) self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
cam_layout.addWidget(self.cam_id_label) cam_layout.addWidget(self.cam_id_label)
cam_layout.addWidget(self.cam_id_spin) cam_layout.addWidget(self.cam_id_spin)
@@ -59,8 +106,10 @@ class ToolbarWidget(QWidget):
video_layout.setSpacing(8) video_layout.setSpacing(8)
self.video_edit = QLineEdit() self.video_edit = QLineEdit()
self.video_edit.setMinimumWidth(180) self.video_edit.setMinimumWidth(180)
self.video_edit.setMinimumHeight(CONTROL_HEIGHT)
self.video_edit.setReadOnly(True) self.video_edit.setReadOnly(True)
self.btn_browse_video = QPushButton() self.btn_browse_video = QPushButton()
self.btn_browse_video.setMinimumHeight(BUTTON_HEIGHT)
self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit) self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit)
video_layout.addWidget(self.video_edit, 1) video_layout.addWidget(self.video_edit, 1)
video_layout.addWidget(self.btn_browse_video) video_layout.addWidget(self.btn_browse_video)
@@ -68,34 +117,45 @@ class ToolbarWidget(QWidget):
self.source_stack.addWidget(cam_page) self.source_stack.addWidget(cam_page)
self.source_stack.addWidget(video_page) self.source_stack.addWidget(video_page)
# --- action group -------------------------------------------------
self.btn_search = QPushButton() self.btn_search = QPushButton()
self.btn_search.setMinimumHeight(BUTTON_HEIGHT)
self.btn_search.clicked.connect(self.search_clicked.emit) self.btn_search.clicked.connect(self.search_clicked.emit)
self.btn_preview = QPushButton() self.btn_preview = QPushButton()
self.btn_preview.setMinimumHeight(BUTTON_HEIGHT)
self.btn_preview.setCheckable(True) self.btn_preview.setCheckable(True)
self.btn_preview.toggled.connect(self._on_preview_toggled) self.btn_preview.toggled.connect(self._on_preview_toggled)
self.btn_run = QPushButton() 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.setCheckable(True)
self.btn_run.toggled.connect(self._on_run_toggled) self.btn_run.toggled.connect(self._on_run_toggled)
self.btn_reset = QPushButton() self.btn_reset = QPushButton()
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
self.btn_reset.setEnabled(False) self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self.reset_clicked.emit) self.btn_reset.clicked.connect(self.reset_clicked.emit)
# --- units group --------------------------------------------------
self.units_label = QLabel() self.units_label = QLabel()
self.units_label.setObjectName("fieldLabel")
self.units_combo = QComboBox() 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) self.units_combo.currentIndexChanged.connect(self._on_units_index_changed)
layout.addWidget(self.source_label) layout.addWidget(
layout.addWidget(self.source_combo) _group(self.source_label, self.source_combo, self.source_stack)
layout.addWidget(self.source_stack, 1) )
layout.addWidget(self.btn_search) layout.addWidget(_separator())
layout.addWidget(self.btn_preview) layout.addWidget(
layout.addWidget(self.btn_run) _group(self.btn_search, self.btn_preview, self.btn_run, self.btn_reset)
layout.addWidget(self.btn_reset) )
layout.addWidget(self.units_label) layout.addWidget(_separator())
layout.addWidget(self.units_combo) layout.addWidget(_group(self.units_label, self.units_combo))
layout.addStretch(1) layout.addStretch(1)
self._populate_source_combo() self._populate_source_combo()
@@ -103,6 +163,7 @@ class ToolbarWidget(QWidget):
self.retranslate() self.retranslate()
self._apply_source_mode_ui() self._apply_source_mode_ui()
# ------------------------------------------------------------------ API
def source_mode(self) -> str: def source_mode(self) -> str:
data = self.source_combo.currentData() data = self.source_combo.currentData()
return str(data or "camera") return str(data or "camera")
@@ -131,10 +192,7 @@ class ToolbarWidget(QWidget):
def set_video_path(self, path: str) -> None: def set_video_path(self, path: str) -> None:
text = str(path or "").strip() text = str(path or "").strip()
self.video_edit.setText(text) self.video_edit.setText(text)
if text: self.video_edit.setToolTip(text)
self.video_edit.setToolTip(text)
else:
self.video_edit.setToolTip("")
def units(self) -> str: def units(self) -> str:
data = self.units_combo.currentData() data = self.units_combo.currentData()
@@ -153,13 +211,13 @@ class ToolbarWidget(QWidget):
self.btn_preview.blockSignals(True) self.btn_preview.blockSignals(True)
self.btn_preview.setChecked(bool(checked)) self.btn_preview.setChecked(bool(checked))
self.btn_preview.blockSignals(False) self.btn_preview.blockSignals(False)
self._refresh_preview_label() self.refresh_labels()
def set_run_checked(self, checked: bool) -> None: def set_run_checked(self, checked: bool) -> None:
self.btn_run.blockSignals(True) self.btn_run.blockSignals(True)
self.btn_run.setChecked(bool(checked)) self.btn_run.setChecked(bool(checked))
self.btn_run.blockSignals(False) self.btn_run.blockSignals(False)
self._refresh_run_label() self.refresh_labels()
if not checked: if not checked:
self.btn_reset.setEnabled(False) self.btn_reset.setEnabled(False)
@@ -177,7 +235,14 @@ class ToolbarWidget(QWidget):
self.btn_preview.setEnabled(enabled and self.source_mode() == "camera") self.btn_preview.setEnabled(enabled and self.source_mode() == "camera")
def set_run_enabled(self, enabled: bool) -> None: def set_run_enabled(self, enabled: bool) -> None:
self._run_available = bool(enabled)
self.btn_run.setEnabled(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: def active_source(self) -> str | int | None:
if self.source_mode() == "video": if self.source_mode() == "video":
@@ -185,6 +250,12 @@ class ToolbarWidget(QWidget):
return path if path else None return path if path else None
return self.camera_id() 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: def _populate_source_combo(self) -> None:
self.source_combo.blockSignals(True) self.source_combo.blockSignals(True)
self.source_combo.clear() self.source_combo.clear()
@@ -236,11 +307,28 @@ class ToolbarWidget(QWidget):
def _refresh_run_label(self) -> None: def _refresh_run_label(self) -> None:
if self.btn_run.isChecked(): if self.btn_run.isChecked():
self.btn_run.setText("■") self.btn_run.setText(t("toolbar.stop_label"))
self.btn_run.setToolTip(t("toolbar.stop"))
else: else:
self.btn_run.setText("▶") self.btn_run.setText(t("toolbar.run_label"))
self.btn_run.setToolTip(t("toolbar.run")) 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: def retranslate(self) -> None:
self.source_label.setText(t("toolbar.source")) self.source_label.setText(t("toolbar.source"))
@@ -253,8 +341,7 @@ class ToolbarWidget(QWidget):
Path(path).name if path else t("toolbar.video_placeholder") Path(path).name if path else t("toolbar.video_placeholder")
) )
self.btn_search.setText(t("toolbar.search_cameras")) self.btn_search.setText(t("toolbar.search_cameras"))
self._refresh_preview_label() self.refresh_labels()
self._refresh_run_label()
self.btn_reset.setText(t("toolbar.reset")) self.btn_reset.setText(t("toolbar.reset"))
self.units_label.setText(t("toolbar.units")) self.units_label.setText(t("toolbar.units"))
self.units_combo.setItemText(0, t("toolbar.units_newton")) self.units_combo.setItemText(0, t("toolbar.units_newton"))
+73
View File
@@ -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
+76
View File
@@ -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<body>.*?)\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")
+171
View File
@@ -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)
+213
View File
@@ -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())
+118
View File
@@ -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())
+6
View File
@@ -14,7 +14,13 @@ authors = [{ name = "Orisys-Tech" }]
dev = [ dev = [
"orisys-app-shared @ file:./shared", "orisys-app-shared @ file:./shared",
"orisys-customer @ file:./apps/customer", "orisys-customer @ file:./apps/customer",
"pytest>=7.4",
] ]
[tool.setuptools] [tool.setuptools]
packages = [] packages = []
[tool.pytest.ini_options]
testpaths = ["shared/tests", "apps/customer/tests"]
pythonpath = ["shared/src", "apps/customer/src"]
addopts = "-q"
@@ -26,6 +26,9 @@ _DEFAULTS: dict[str, Any] = {
"plot_shear_max": 20.0, "plot_shear_max": 20.0,
"image_view_mode": "arrows_pure", "image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90, "arrow_rotation_deg": 90,
"show_fps": False,
"splitter_sizes": [340, 480, 400],
"window_default_size": [1440, 880],
} }
@@ -4,8 +4,11 @@
"panel_border": "#2a2d33", "panel_border": "#2a2d33",
"text_primary": "#e8eaed", "text_primary": "#e8eaed",
"text_secondary": "#9aa0a6", "text_secondary": "#9aa0a6",
"accent": "#536dce", "text_muted": "#6f757c",
"accent_hover": "#6b82d9", "accent": "#4a62c0",
"accent_hover": "#5c74d0",
"accent_text": "#ffffff",
"toolbar_bg": "#1a1c20",
"button_bg": "#2a2d33", "button_bg": "#2a2d33",
"button_hover": "#3a3f47", "button_hover": "#3a3f47",
"input_bg": "#0f1012", "input_bg": "#0f1012",
@@ -15,9 +18,28 @@
"plot_fn": "#8ab4f8", "plot_fn": "#8ab4f8",
"plot_fx": "#34a853", "plot_fx": "#34a853",
"plot_fy": "#fbbc04", "plot_fy": "#fbbc04",
"plot_bg": "#121316", "plot_bg": "#15171a",
"plot_grid": "#2a2d33", "plot_grid": "#2a2d33",
"arrow_canvas": "#121316", "arrow_canvas": "#121316",
"danger": "#ea4335", "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"
} }
@@ -1,23 +1,45 @@
{ {
"window_bg": "#f5f6f8", "window_bg": "#ECEFF4",
"panel_bg": "#ffffff", "panel_bg": "#FFFFFF",
"panel_border": "#d0d4dc", "panel_border": "#D0D5DE",
"text_primary": "#1a1c20", "text_primary": "#1A1C20",
"text_secondary": "#5f6368", "text_secondary": "#5F6368",
"accent": "#536dce", "text_muted": "#7C838C",
"accent_hover": "#3f56b5", "accent": "#003893",
"button_bg": "#e8eaed", "accent_hover": "#2A4FA8",
"button_hover": "#d0d4dc", "accent_text": "#ffffff",
"input_bg": "#ffffff", "toolbar_bg": "#E8EBF0",
"input_border": "#c0c4cc", "button_bg": "#F4F6FA",
"menu_bg": "#ffffff", "button_hover": "#E8ECF5",
"menu_hover": "#e8eaed", "input_bg": "#FFFFFF",
"plot_fn": "#1a73e8", "input_border": "#C5CAD3",
"menu_bg": "#FFFFFF",
"menu_hover": "#EEF1F7",
"plot_fn": "#003893",
"plot_fx": "#188038", "plot_fx": "#188038",
"plot_fy": "#f9ab00", "plot_fy": "#E8710A",
"plot_bg": "#ffffff", "plot_bg": "#FAFBFD",
"plot_grid": "#e0e3e8", "plot_grid": "#E3E6EC",
"arrow_canvas": "#eef0f3", "arrow_canvas": "#EEF1F7",
"danger": "#d93025", "danger": "#D93025",
"success": "#188038" "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"
} }
+459 -11
View File
@@ -21,8 +21,71 @@ def load_colors(theme: str = "dark") -> dict[str, str]:
return {str(k): str(v) for k, v in data.items()} 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: def build_qss(theme: str = "dark") -> str:
c = load_colors(theme) 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""" return f"""
QWidget {{ QWidget {{
background-color: {c['window_bg']}; background-color: {c['window_bg']};
@@ -37,6 +100,11 @@ QMenuBar {{
background-color: {c['menu_bg']}; background-color: {c['menu_bg']};
color: {c['text_primary']}; color: {c['text_primary']};
border-bottom: 1px solid {c['panel_border']}; border-bottom: 1px solid {c['panel_border']};
padding: 2px 4px;
}}
QMenuBar::item {{
padding: 5px 10px;
border-radius: 4px;
}} }}
QMenuBar::item:selected {{ QMenuBar::item:selected {{
background-color: {c['menu_hover']}; background-color: {c['menu_hover']};
@@ -45,54 +113,433 @@ QMenu {{
background-color: {c['menu_bg']}; background-color: {c['menu_bg']};
color: {c['text_primary']}; color: {c['text_primary']};
border: 1px solid {c['panel_border']}; border: 1px solid {c['panel_border']};
border-radius: 8px;
padding: 4px;
}}
QMenu::item {{
padding: 6px 18px;
border-radius: 4px;
}} }}
QMenu::item:selected {{ QMenu::item:selected {{
background-color: {c['menu_hover']}; background-color: {c['menu_hover']};
}} }}
QToolBar {{ QMenu::separator {{
background-color: {c['panel_bg']}; height: 1px;
border-bottom: 1px solid {c['panel_border']}; background-color: {c['separator']};
spacing: 8px; margin: 4px 8px;
padding: 4px;
}} }}
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 {{ QPushButton {{
background-color: {c['button_bg']}; background-color: {c['button_bg']};
color: {c['text_primary']}; color: {c['text_primary']};
border: 1px solid {c['panel_border']}; border: 1px solid {c['panel_border']};
border-radius: 4px; border-radius: 8px;
padding: 6px 12px; padding: 7px 14px;
min-height: 28px;
}} }}
QPushButton:hover {{ QPushButton:hover {{
background-color: {c['button_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 {{ 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']}; 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']}; background-color: {c['input_bg']};
color: {c['text_primary']}; color: {c['text_primary']};
border: 1px solid {c['input_border']}; 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; 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 {{ QLabel#panelTitle {{
color: {c['text_secondary']}; color: {c['text_secondary']};
font-weight: 600; font-weight: 600;
font-size: 12px;
letter-spacing: 0.3px;
padding: 1px 0;
}} }}
QLabel#panelMeta {{ QLabel#panelMeta {{
color: {c['text_secondary']}; color: {c['text_secondary']};
font-weight: 500; font-weight: 500;
padding-right: 8px; 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 {{ QFrame#contentPanel {{
background-color: {c['panel_bg']}; background-color: {c['panel_bg']};
border: 1px solid {c['panel_border']}; 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 {{ QStatusBar {{
background-color: {c['panel_bg']}; background-color: {c['panel_bg']};
color: {c['text_secondary']}; 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: def clear_theme_cache() -> None:
load_colors.cache_clear() load_colors.cache_clear()
_paint_arrow.cache_clear()