dsh UI improvement
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
@@ -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 |
@@ -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})",
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" {value:.3f}"
|
||||||
)
|
)
|
||||||
|
self.live_label.setText(
|
||||||
|
f"<span style='color:{color(self._theme, 'text_secondary')}'>"
|
||||||
|
f"{t('cal.live_raw_label')}</span> " + " ".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()
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user