add the Studio element manifest and its window-matching test
ui-layout.md named the arrangement's owners but nothing knew which elements exist, so a view switch could silently take a running session's tracker reset away: docs/ui-elements.json now states all 195 by region, pane and view, ui/elements.py loads it, and tools/ui_elements_dump.py drafts it. Verified: apps/studio 393 items (13 new) and ui_check 600/0, autocal 358, shared 390 with its 2 known wall-clock failures, lint and doc gates clean; a removed element, a renamed objectName and a new named widget each fail the suite.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,518 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"scope": "apps/studio",
|
||||||
|
"note": "Authored, not generated: docs/ui-elements.json is the machine-readable half of the layout contract in docs/ui-layout.md, and apps/studio/tests/test_ui_elements.py fails when the window and this file disagree. tools/ui_elements_dump.py drafts it from a live window; its output must be curated, never copied. An element is found by 'path' (dot path from the window, with '()' for a Qt getter, '[i]' for an index and '.' for the window itself) or by a unique 'objectName'. 'objectName' is recorded only when the name is a stable identity; 'style' records the stylesheet token a widget carries instead (the rail's transport is 'primaryButton' while idle and 'ghostButton' while acquiring, so it has no name of its own). 'condition' names a predicate from ui/elements.py CONDITIONS: the element is expected only when it holds, and the suite has a case per condition so no element is permanently skipped. 'attrs' records a structural property of the widget itself (a table's column count, a splitter's child count). The drift report compares the visible widgets' objectNames against the element identities plus the style_tokens below (Qt's own 'qt_' names excluded), and views.<view>.unlisted_named_widgets records what is deliberately left over; it is empty because every name on screen is accounted for. 'rows' expands a per-channel template into one element per force axis.",
|
||||||
|
"window": {
|
||||||
|
"id": "main_window",
|
||||||
|
"path": ".",
|
||||||
|
"objectName": null,
|
||||||
|
"class": "StudioMainWindow",
|
||||||
|
"purpose": "the shell: brand bar, rail, splitter, menus and status bar on one window",
|
||||||
|
"views": "all"
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"live": {
|
||||||
|
"purpose": "live measurement: the measurement panes lead, the charts and the rail's settings accompany them",
|
||||||
|
"primary": ["measurement_2d", "measurement_3d"],
|
||||||
|
"companions": ["charts"],
|
||||||
|
"unlisted_named_widgets": []
|
||||||
|
},
|
||||||
|
"replay": {
|
||||||
|
"purpose": "history browsing: the charts lead, the measurement panes stay beside them, and the rail shows a recorded video instead of a camera",
|
||||||
|
"primary": ["charts"],
|
||||||
|
"companions": ["measurement_2d", "measurement_3d"],
|
||||||
|
"unlisted_named_widgets": []
|
||||||
|
},
|
||||||
|
"calibration": {
|
||||||
|
"purpose": "force calibration: cal_panel's six-column table and action row lead, and the charts remain on screen beside it",
|
||||||
|
"primary": ["cal_panel"],
|
||||||
|
"companions": ["charts"],
|
||||||
|
"unlisted_named_widgets": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"style_tokens": [
|
||||||
|
{ "name": "navButton", "purpose": "brand-bar view tab" },
|
||||||
|
{ "name": "sectionSeparator", "purpose": "1 px rule between groups" },
|
||||||
|
{ "name": "panelTitle", "purpose": "panel and section heading" },
|
||||||
|
{ "name": "panelMeta", "purpose": "secondary readout beside a heading" },
|
||||||
|
{ "name": "hintText", "purpose": "explanation or state line under a control" },
|
||||||
|
{ "name": "fieldLabel", "purpose": "label of a form field" },
|
||||||
|
{ "name": "unitBadge", "purpose": "small filled badge: unit, channel or product" },
|
||||||
|
{ "name": "valueCard", "purpose": "large monospace numeric reading" },
|
||||||
|
{ "name": "valueGroup", "purpose": "container of one reading's card" },
|
||||||
|
{ "name": "cardPanel", "purpose": "raised container for a picture or a plot" },
|
||||||
|
{ "name": "contentPanel", "purpose": "the panel frame every splitter slot uses" },
|
||||||
|
{ "name": "primaryButton", "purpose": "the filled action of the moment" },
|
||||||
|
{ "name": "ghostButton", "purpose": "outline action" },
|
||||||
|
{ "name": "dangerOutline", "purpose": "outline stop/cancel action" },
|
||||||
|
{ "name": "dangerButton", "purpose": "filled stop/record-stop action" }
|
||||||
|
],
|
||||||
|
"regions": [
|
||||||
|
{
|
||||||
|
"id": "brand_bar",
|
||||||
|
"path": "brand_bar",
|
||||||
|
"objectName": "brandBar",
|
||||||
|
"class": "BrandBar",
|
||||||
|
"purpose": "the three top-level view tabs, the lockup and the product name",
|
||||||
|
"elements": [
|
||||||
|
{ "id": "brand_logo", "path": "brand_bar.logo", "objectName": "brandLogo", "class": "QLabel",
|
||||||
|
"purpose": "corporate mark, re-tinted from the theme; the text lockup stays if the asset is missing" },
|
||||||
|
{ "id": "brand_wordmark", "path": "brand_bar.name", "objectName": "brandName", "class": "QLabel",
|
||||||
|
"purpose": "product wordmark beside the mark" },
|
||||||
|
{ "id": "brand_tagline", "path": "brand_bar.tagline", "objectName": "brandTagline", "class": "QLabel",
|
||||||
|
"purpose": "tagline: kept in the layout but hidden in the one-line rail, where it is the wordmark's tooltip" },
|
||||||
|
{ "id": "brand_product", "path": "brand_bar.product", "objectName": "brandProduct", "class": "QLabel",
|
||||||
|
"purpose": "product title, right-aligned at the end of the bar" },
|
||||||
|
{ "id": "brand_divider", "path": "brand_bar.divider", "objectName": null, "style": "sectionSeparator", "class": "QFrame",
|
||||||
|
"purpose": "1 px rule between the tabs and the product title" },
|
||||||
|
{ "id": "tab_live", "path": "brand_bar.btn_live", "objectName": null, "style": "navButton", "class": "QPushButton",
|
||||||
|
"purpose": "the live (measurement) view tab; its checked state marks the active view" },
|
||||||
|
{ "id": "tab_replay", "path": "brand_bar.btn_playback", "objectName": null, "style": "navButton", "class": "QPushButton",
|
||||||
|
"purpose": "the replay (history) view tab" },
|
||||||
|
{ "id": "tab_calibration", "path": "brand_bar.btn_calibration", "objectName": null, "style": "navButton", "class": "QPushButton",
|
||||||
|
"purpose": "the calibration view tab" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rail",
|
||||||
|
"path": "toolbar",
|
||||||
|
"objectName": null,
|
||||||
|
"class": "ToolbarWidget",
|
||||||
|
"purpose": "the status rail: sensor summary, session phase, capture source, record state, transport and the settings toggle",
|
||||||
|
"elements": [
|
||||||
|
{ "id": "sensor_button", "path": "toolbar.btn_sensor", "objectName": null, "style": "ghostButton", "class": "QPushButton",
|
||||||
|
"purpose": "sensor and config summary ('BOX . box/sensor.json'); opens the sensor dialog" },
|
||||||
|
{ "id": "phase_chip", "path": "toolbar.status_chip", "objectName": "statusChip", "class": "QLabel",
|
||||||
|
"purpose": "session phase (idle / connecting / running / error)" },
|
||||||
|
{ "id": "source_summary", "path": "toolbar.source_summary", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "capture-source detail (rate, video or recording name); hidden while empty, with the whole text in its tooltip" },
|
||||||
|
{ "id": "calibration_summary", "path": "toolbar.calibration_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "which calibration the device confirmed; hidden while empty" },
|
||||||
|
{ "id": "data_state", "path": "toolbar.data_state", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "data-validity readout; hidden until a sample has been judged" },
|
||||||
|
{ "id": "record_chip", "path": "toolbar.record_chip", "objectName": "recordChip", "class": "RecordStatusChip",
|
||||||
|
"purpose": "record state, elapsed time and what the writer dropped" },
|
||||||
|
{ "id": "source_slot", "path": "toolbar.source_slot", "objectName": null, "class": "QWidget",
|
||||||
|
"purpose": "the one capture-source slot; the view decides which of the three groups inside it is shown" },
|
||||||
|
{ "id": "camera_group", "path": "toolbar.camera_group", "objectName": null, "class": "QWidget", "views": ["live"],
|
||||||
|
"purpose": "live camera picker group (camera index and search)" },
|
||||||
|
{ "id": "camera_id_label", "path": "toolbar.cam_id_label", "objectName": null, "style": "fieldLabel", "class": "QLabel", "views": ["live"],
|
||||||
|
"purpose": "label of the camera index field" },
|
||||||
|
{ "id": "camera_id_spin", "path": "toolbar.cam_id_spin", "objectName": null, "class": "QSpinBox", "views": ["live"],
|
||||||
|
"purpose": "camera index the next run opens" },
|
||||||
|
{ "id": "camera_search", "path": "toolbar.btn_search", "objectName": null, "class": "QPushButton", "views": ["live"],
|
||||||
|
"purpose": "probe the cameras and offer the ones found" },
|
||||||
|
{ "id": "video_group", "path": "toolbar.video_group", "objectName": null, "class": "QWidget", "views": ["live"], "condition": "video_source",
|
||||||
|
"purpose": "shown instead of the camera picker while the run source is a file" },
|
||||||
|
{ "id": "use_camera", "path": "toolbar.btn_use_camera", "objectName": null, "class": "QPushButton", "views": ["live"], "condition": "video_source",
|
||||||
|
"purpose": "one-click way back to the camera source" },
|
||||||
|
{ "id": "replay_group", "path": "toolbar.replay_group", "objectName": null, "class": "QWidget", "views": ["replay"],
|
||||||
|
"purpose": "the replay view's source group" },
|
||||||
|
{ "id": "replay_video", "path": "toolbar.btn_replay_video", "objectName": null, "class": "QPushButton", "views": ["replay"],
|
||||||
|
"purpose": "choose the recorded video to play" },
|
||||||
|
{ "id": "recompute", "path": "toolbar.btn_recompute", "objectName": null, "class": "QPushButton", "views": ["replay"],
|
||||||
|
"purpose": "re-run the vision pipeline over that video: the only processed picture a recording can produce" },
|
||||||
|
{ "id": "record", "path": "toolbar.btn_record", "objectName": null, "style": "primaryButton / dangerButton", "class": "QPushButton",
|
||||||
|
"purpose": "start or stop the recording; enabled with a live session and always enabled while recording" },
|
||||||
|
{ "id": "transport", "path": "toolbar.btn_run", "objectName": null, "style": "primaryButton / ghostButton", "class": "QPushButton",
|
||||||
|
"purpose": "the transport of both views: play / pause / resume; unchecked means paused while a session exists and idle when none does" },
|
||||||
|
{ "id": "stop", "path": "toolbar.btn_stop", "objectName": null, "style": "dangerOutline / dangerButton", "class": "QPushButton",
|
||||||
|
"purpose": "end the live session, leave the replay, or cancel a connect that has not returned" },
|
||||||
|
{ "id": "reset", "path": "toolbar.btn_reset", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "zero the device tracker; enabled by the session state, never by which panel is on screen" },
|
||||||
|
{ "id": "settings_toggle", "path": "toolbar.btn_settings", "objectName": null, "style": "ghostButton", "class": "QPushButton",
|
||||||
|
"purpose": "expand or collapse the settings area" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "settings_area",
|
||||||
|
"path": "toolbar.settings_page",
|
||||||
|
"objectName": null,
|
||||||
|
"class": "QWidget",
|
||||||
|
"purpose": "the collapsible settings area: what is configuration rather than state",
|
||||||
|
"elements": [
|
||||||
|
{ "id": "settings_scroll", "path": "toolbar._settings_scroll", "objectName": null, "class": "QScrollArea",
|
||||||
|
"purpose": "the settings area scrolls sideways so a narrow window squeezes nothing into an unreadable row" },
|
||||||
|
{ "id": "preview", "path": "toolbar.btn_preview", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "live camera preview on/off; offered only for a camera source in the live view" },
|
||||||
|
{ "id": "units_label", "path": "toolbar.units_label", "objectName": null, "style": "fieldLabel", "class": "QLabel",
|
||||||
|
"purpose": "label of the display-unit field" },
|
||||||
|
{ "id": "units_combo", "path": "toolbar.units_combo", "objectName": null, "class": "QComboBox", "attrs": { "count": 2 },
|
||||||
|
"purpose": "display unit for the charts and the readings: newtons or raw counts" },
|
||||||
|
{ "id": "record_mode_label", "path": "toolbar.record_label", "objectName": null, "style": "fieldLabel", "class": "QLabel",
|
||||||
|
"purpose": "label of the record-mode field" },
|
||||||
|
{ "id": "record_mode_combo", "path": "toolbar.record_mode_combo", "objectName": null, "class": "QComboBox", "attrs": { "count": 2 },
|
||||||
|
"purpose": "what a recording writes: video + data, or data only" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "menus",
|
||||||
|
"path": "menuBar()",
|
||||||
|
"objectName": null,
|
||||||
|
"class": "QMenuBar",
|
||||||
|
"purpose": "the menu bar, its menus, and the actions they hold (plus the Space shortcut, which has no menu entry)",
|
||||||
|
"elements": [
|
||||||
|
{ "id": "file_menu", "path": "file_menu", "objectName": null, "class": "QMenu",
|
||||||
|
"purpose": "open a video, reveal the sensor config, open a recording for playback" },
|
||||||
|
{ "id": "view_menu", "path": "view_menu", "objectName": null, "class": "QMenu",
|
||||||
|
"purpose": "what is on screen: panels, 3D, display aids, transport shortcuts, theme and language" },
|
||||||
|
{ "id": "theme_menu", "path": "theme_menu", "objectName": null, "class": "QMenu",
|
||||||
|
"purpose": "the dark / light submenu of the view menu" },
|
||||||
|
{ "id": "lang_menu", "path": "lang_menu", "objectName": null, "class": "QMenu",
|
||||||
|
"purpose": "the Chinese / English submenu of the view menu" },
|
||||||
|
{ "id": "help_menu", "path": "help_menu", "objectName": null, "class": "QMenu",
|
||||||
|
"purpose": "the about entry" },
|
||||||
|
{ "id": "act_open_video", "path": "act_open_video", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "file menu: open a recorded video in the replay view (enabled in the replay view only)" },
|
||||||
|
{ "id": "act_reveal_config", "path": "act_reveal_config", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "file menu: show the active sensor config in the file manager" },
|
||||||
|
{ "id": "act_playback", "path": "act_playback", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "file menu: open a recorded session for playback" },
|
||||||
|
{ "id": "act_playback_latest", "path": "act_playback_latest", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "file menu: open the most recent recording" },
|
||||||
|
{ "id": "act_play", "path": "act_play", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "Space: the transport shortcut, a window action with no menu entry" },
|
||||||
|
{ "id": "act_cal_panel", "path": "act_cal_panel", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "view menu: show the calibration panel (checking it enters the calibration view, unchecking returns to live)" },
|
||||||
|
{ "id": "act_finger_3d", "path": "act_finger_3d", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "view menu: the 3D pane on/off, the same state as the pane's own switch; enabled for a finger sensor" },
|
||||||
|
{ "id": "act_scene3d_settings", "path": "act_scene3d_settings", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "view menu: open the 3D display settings dialog (the pane's gear is the same call)" },
|
||||||
|
{ "id": "act_show_fps", "path": "act_show_fps", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "view menu: show the frame-rate readout" },
|
||||||
|
{ "id": "act_show_diagnostics", "path": "act_show_diagnostics", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "view menu: how much the panels explain themselves around their pictures" },
|
||||||
|
{ "id": "act_raw_auto_zero", "path": "act_raw_auto_zero", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "view menu: the display baseline (auto-zero) master switch" },
|
||||||
|
{ "id": "act_raw_reestimate", "path": "act_raw_reestimate", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "view menu: restart the idle baseline window" },
|
||||||
|
{ "id": "act_settings", "path": "act_settings", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "view menu: the rail's settings area, the same state as its toggle button" },
|
||||||
|
{ "id": "act_plot_settings", "path": "act_plot_settings", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "view menu: chart window, ranges and acquisition filters (the chart gear is the same call)" },
|
||||||
|
{ "id": "act_rotate_arrows", "path": "act_rotate_arrows", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "view menu: rotate the 2D arrow field by 90 degrees for the mounted sensors" },
|
||||||
|
{ "id": "act_recompute", "path": "act_recompute", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "view menu: recompute the vision pipeline over the recorded video, the same state as the rail's button" },
|
||||||
|
{ "id": "act_run", "path": "act_run", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "F5: start a run (the rail's transport is the primary control)" },
|
||||||
|
{ "id": "act_stop", "path": "act_stop", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "Esc: stop the session or the replay" },
|
||||||
|
{ "id": "act_preview", "path": "act_preview", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "F6: camera preview on/off" },
|
||||||
|
{ "id": "act_reset", "path": "act_reset", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "Ctrl+R: zero the device tracker, the same action as the rail's reset" },
|
||||||
|
{ "id": "act_reset_view", "path": "act_reset_view", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "F8: reset the views (curves, window, cursor, 3D camera) without touching the device" },
|
||||||
|
{ "id": "act_pause", "path": "act_pause", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "F7: pause/resume the live session, the same state as the transport" },
|
||||||
|
{ "id": "act_theme_dark", "path": "act_theme_dark", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "theme submenu: dark theme" },
|
||||||
|
{ "id": "act_theme_light", "path": "act_theme_light", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "theme submenu: light theme" },
|
||||||
|
{ "id": "act_lang_zh", "path": "act_lang_zh", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "language submenu: Chinese" },
|
||||||
|
{ "id": "act_lang_en", "path": "act_lang_en", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "language submenu: English" },
|
||||||
|
{ "id": "act_about", "path": "act_about", "objectName": null, "class": "QAction",
|
||||||
|
"purpose": "help menu: the about dialog" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "status_bar",
|
||||||
|
"path": "statusBar()",
|
||||||
|
"objectName": null,
|
||||||
|
"class": "_NoticeBar",
|
||||||
|
"purpose": "the one-off notice line: the expiring-message QStatusBar subclass; session states live on the rail, not here",
|
||||||
|
"elements": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "content",
|
||||||
|
"path": "content_splitter",
|
||||||
|
"objectName": null,
|
||||||
|
"class": "QSplitter",
|
||||||
|
"attrs": { "count": 4 },
|
||||||
|
"purpose": "the content splitter: the measurement region and its companions, in panel order (3D, 2D, charts, calibration); its children are the panes below",
|
||||||
|
"elements": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sensor_picker",
|
||||||
|
"path": "sensor_picker",
|
||||||
|
"objectName": null,
|
||||||
|
"class": "SensorPickerWidget",
|
||||||
|
"purpose": "the sensor type and config pickers the rail summarises in one button; the dialog is where they live",
|
||||||
|
"elements": [
|
||||||
|
{ "id": "picker_badge", "path": "sensor_picker.thumb_label", "objectName": null, "style": "unitBadge", "class": "QLabel",
|
||||||
|
"purpose": "the selected product's name as a badge" },
|
||||||
|
{ "id": "picker_type_label", "path": "sensor_picker.type_label", "objectName": null, "style": "fieldLabel", "class": "QLabel",
|
||||||
|
"purpose": "label of the sensor-type field" },
|
||||||
|
{ "id": "picker_type_combo", "path": "sensor_picker.type_combo", "objectName": null, "class": "QComboBox",
|
||||||
|
"purpose": "the sensor type: which configs exist follows from it" },
|
||||||
|
{ "id": "picker_config_label", "path": "sensor_picker.config_label", "objectName": null, "style": "fieldLabel", "class": "QLabel",
|
||||||
|
"purpose": "label of the config-file field" },
|
||||||
|
{ "id": "picker_config_combo", "path": "sensor_picker.config_combo", "objectName": null, "class": "QComboBox",
|
||||||
|
"purpose": "the config file that belongs to the selected sensor type" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sensor_dialog",
|
||||||
|
"path": "sensor_dialog",
|
||||||
|
"objectName": "sensorDialog",
|
||||||
|
"class": "SensorDialog",
|
||||||
|
"condition": "sensor_dialog_open",
|
||||||
|
"purpose": "the pickers' dialog plus the calibration facts that follow them; non-modal and built on first use",
|
||||||
|
"elements": [
|
||||||
|
{ "id": "dialog_calibration_title", "path": "sensor_dialog.calib_title", "objectName": null, "style": "panelTitle", "class": "QLabel",
|
||||||
|
"purpose": "heading of the calibration half" },
|
||||||
|
{ "id": "dialog_version_label", "path": "sensor_dialog.version_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "the calibration identity the device confirmed" },
|
||||||
|
{ "id": "dialog_axes_label", "path": "sensor_dialog.axes_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "which axes that calibration covers" },
|
||||||
|
{ "id": "dialog_config_label", "path": "sensor_dialog.config_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "the active sensor config file" },
|
||||||
|
{ "id": "dialog_reveal", "path": "sensor_dialog.btn_reveal", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "show that config file in the file manager" },
|
||||||
|
{ "id": "dialog_export_title", "path": "sensor_dialog.export_title", "objectName": null, "style": "panelTitle", "class": "QLabel",
|
||||||
|
"purpose": "heading of the exported-coefficients half" },
|
||||||
|
{ "id": "dialog_export_label", "path": "sensor_dialog.export_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "the last coefficients file written by the calibration panel" },
|
||||||
|
{ "id": "dialog_identity_label", "path": "sensor_dialog.identity_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "what that file says it belongs to, so a mismatched apply is visible" },
|
||||||
|
{ "id": "dialog_export", "path": "sensor_dialog.btn_export", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "write the calibration panel's coefficients (the panel's own action)" },
|
||||||
|
{ "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "push the last export into the running device" },
|
||||||
|
{ "id": "dialog_close", "path": "sensor_dialog.btn_close", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "close the dialog" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"panes": [
|
||||||
|
{
|
||||||
|
"id": "measurement_2d",
|
||||||
|
"path": "arrow_panel",
|
||||||
|
"objectName": null,
|
||||||
|
"style": "contentPanel",
|
||||||
|
"class": "ArrowPanel",
|
||||||
|
"views": ["live", "replay"],
|
||||||
|
"purpose": "the 2D measurement pane: arrows, stitched arrows or depth, drawn from the same frame the 3D pane reads",
|
||||||
|
"elements": [
|
||||||
|
{ "id": "arrow_title", "path": "arrow_panel.title", "objectName": null, "style": "panelTitle", "class": "QLabel",
|
||||||
|
"purpose": "panel heading" },
|
||||||
|
{ "id": "arrow_view_combo", "path": "arrow_panel.view_combo", "objectName": null, "class": "QComboBox",
|
||||||
|
"purpose": "the view modes the current frame can actually offer (a recording offers only its raw picture)" },
|
||||||
|
{ "id": "arrow_canvas", "path": "arrow_panel.image_label", "objectName": null, "class": "AspectImageLabel",
|
||||||
|
"purpose": "the picture area, which keeps the frame's own aspect ratio" },
|
||||||
|
{ "id": "arrow_scale_label", "path": "arrow_panel.scale_label", "objectName": null, "style": "hintText", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "footer: the magnification the arrows were drawn at" },
|
||||||
|
{ "id": "arrow_pipeline_label", "path": "arrow_panel.pipeline_label", "objectName": null, "style": "hintText", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "footer: source to output, shown with the diagnostics switch" },
|
||||||
|
{ "id": "arrow_area_label", "path": "arrow_panel.area_label", "objectName": null, "style": "hintText", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "footer: the valid sensing area of the frame" },
|
||||||
|
{ "id": "arrow_note_label", "path": "arrow_panel.note_label", "objectName": null, "style": "hintText", "class": "ShrinkableLabel",
|
||||||
|
"purpose": "one line explaining an unexpected plan, e.g. that a recording stores only the raw camera image" },
|
||||||
|
{ "id": "arrow_colorbar", "path": "arrow_panel.colorbar", "objectName": "colorBar", "class": "ColorBarLabel",
|
||||||
|
"purpose": "depth colour strip, painted from the same lookup table the picture uses; hidden outside the depth view" },
|
||||||
|
{ "id": "arrow_depth_scale", "path": "arrow_panel.depth_scale_label", "objectName": null, "style": "panelMeta", "class": "QLabel",
|
||||||
|
"purpose": "the fixed depth range the colour strip is scaled to; hidden outside the depth view" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "measurement_3d",
|
||||||
|
"path": "mesh3d_panel",
|
||||||
|
"objectName": null,
|
||||||
|
"style": "contentPanel",
|
||||||
|
"class": "Mesh3dPanel",
|
||||||
|
"views": ["live", "replay"],
|
||||||
|
"condition": "mesh3d_view",
|
||||||
|
"purpose": "the 3D pane: a peer of the 2D pane and never a replacement for it; only a finger sensor has a scene",
|
||||||
|
"elements": [
|
||||||
|
{ "id": "mesh3d_title", "path": "mesh3d_panel.title", "objectName": null, "style": "panelTitle", "class": "QLabel",
|
||||||
|
"purpose": "panel heading" },
|
||||||
|
{ "id": "mesh3d_reset_view", "path": "mesh3d_panel.btn_reset_view", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "back to the framing the scene asset calls its own" },
|
||||||
|
{ "id": "mesh3d_settings_button", "path": "mesh3d_panel.btn_settings", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "open the 3D settings dialog; also what builds the view on first use" },
|
||||||
|
{ "id": "mesh3d_message", "path": "mesh3d_panel.message", "objectName": null, "style": "hintText", "class": "QLabel",
|
||||||
|
"purpose": "the one-line reason there is no picture (no scene, no asset, no GL, loading, refused)" },
|
||||||
|
{ "id": "mesh3d_native_handle", "path": "mesh3d_panel._native_handle", "objectName": "mesh3dNativeHandle", "class": "QWidget",
|
||||||
|
"purpose": "zero-sized holder that reserves the window's native handle, so adding the first GL widget cannot make Qt re-create (flash) the window" },
|
||||||
|
{ "id": "mesh3d_controls", "path": "mesh3d_panel.view.panel.controls_widget", "objectName": "scene3dControls", "class": "QWidget", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "the block of 3D settings: the pane never shows it, the settings dialog hosts it" },
|
||||||
|
{ "id": "mesh3d_enable", "path": "mesh3d_panel.view.panel.enable_check", "objectName": null, "class": "QCheckBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "the pane's own on/off switch, the same state as the view menu's 3D entry" },
|
||||||
|
{ "id": "mesh3d_preset_label", "path": "mesh3d_panel.view.panel.preset_label", "objectName": null, "class": "QLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "label of the camera-preset field" },
|
||||||
|
{ "id": "mesh3d_preset_combo", "path": "mesh3d_panel.view.panel.preset_combo", "objectName": null, "class": "QComboBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "camera presets: the asset's own default view and the ones it names" },
|
||||||
|
{ "id": "mesh3d_housing", "path": "mesh3d_panel.view.panel.housing_check", "objectName": null, "class": "QCheckBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "show the housing geometry, a mechanical check rather than the measurement; off by default" },
|
||||||
|
{ "id": "mesh3d_deform", "path": "mesh3d_panel.view.panel.deform_check", "objectName": null, "class": "QCheckBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "exaggerate the deformation so a small displacement is visible" },
|
||||||
|
{ "id": "mesh3d_gain", "path": "mesh3d_panel.view.panel.gain_spin", "objectName": null, "class": "QDoubleSpinBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "the deformation exaggeration factor" },
|
||||||
|
{ "id": "mesh3d_field_label", "path": "mesh3d_panel.view.panel.field_label", "objectName": null, "class": "QLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "label of the displayed-quantity field" },
|
||||||
|
{ "id": "mesh3d_field_combo", "path": "mesh3d_panel.view.panel.field_combo", "objectName": null, "class": "QComboBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "which quantity colours the surface: depth response or optical-flow magnitude" },
|
||||||
|
{ "id": "mesh3d_field_note", "path": "mesh3d_panel.view.panel.field_note", "objectName": null, "style": "hintText", "class": "QLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "says which quantity is actually on screen" },
|
||||||
|
{ "id": "mesh3d_flow_arrows", "path": "mesh3d_panel.view.panel.flow_arrows_check", "objectName": null, "class": "QCheckBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "the optical-flow arrow overlay on the surface" },
|
||||||
|
{ "id": "mesh3d_contact_arrow", "path": "mesh3d_panel.view.panel.contact_arrow_check", "objectName": null, "class": "QCheckBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "the contact arrow overlay: where the surface is being touched" },
|
||||||
|
{ "id": "mesh3d_scale_label", "path": "mesh3d_panel.view.panel.scale_label", "objectName": null, "class": "QLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "label of the colour-scale mode field" },
|
||||||
|
{ "id": "mesh3d_scale_combo", "path": "mesh3d_panel.view.panel.scale_combo", "objectName": null, "class": "QComboBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "fixed bounds or an automatic range for the colour scale" },
|
||||||
|
{ "id": "mesh3d_range_label", "path": "mesh3d_panel.view.panel.range_label", "objectName": null, "class": "QLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "label of the fixed colour-range fields" },
|
||||||
|
{ "id": "mesh3d_range_lower", "path": "mesh3d_panel.view.panel.lower_spin", "objectName": null, "class": "QDoubleSpinBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "lower bound of the fixed colour range" },
|
||||||
|
{ "id": "mesh3d_range_upper", "path": "mesh3d_panel.view.panel.upper_spin", "objectName": null, "class": "QDoubleSpinBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "upper bound of the fixed colour range" },
|
||||||
|
{ "id": "mesh3d_fps_label", "path": "mesh3d_panel.view.panel.fps_label", "objectName": null, "class": "QLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "label of the display-budget field" },
|
||||||
|
{ "id": "mesh3d_fps_combo", "path": "mesh3d_panel.view.panel.fps_combo", "objectName": null, "class": "QComboBox", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "the 3D pane's display budget in frames per second; never reaches the camera or the recording" },
|
||||||
|
{ "id": "mesh3d_reset_button", "path": "mesh3d_panel.view.panel.reset_button", "objectName": null, "class": "QPushButton", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "reset the 3D view from inside the settings dialog" },
|
||||||
|
{ "id": "mesh3d_stats", "path": "mesh3d_panel.view.panel.stats_label", "objectName": null, "style": "panelMeta", "class": "QLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "display-side counters: an engineering readout, not something to read while measuring" },
|
||||||
|
{ "id": "mesh3d_scale_lower", "path": "mesh3d_panel.view.panel.scale_lower_label", "objectName": null, "style": "panelMeta", "class": "QLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "the lower bound the legend was drawn with" },
|
||||||
|
{ "id": "mesh3d_scale_bar", "path": "mesh3d_panel.view.panel.scale_bar", "objectName": "colorScaleBar", "class": "ColorScaleBar", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "the colour ramp the shader actually used, from the same lookup table" },
|
||||||
|
{ "id": "mesh3d_scale_upper", "path": "mesh3d_panel.view.panel.scale_upper_label", "objectName": null, "style": "panelMeta", "class": "QLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "the upper bound the legend was drawn with" },
|
||||||
|
{ "id": "mesh3d_legend", "path": "mesh3d_panel.view.panel.legend_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "one legend line: what the colours mean" },
|
||||||
|
{ "id": "mesh3d_source", "path": "mesh3d_panel.view.panel.source_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "which source produced the field on screen: live, replay or a recompute pass" },
|
||||||
|
{ "id": "mesh3d_state", "path": "mesh3d_panel.view.panel.state_label", "objectName": null, "style": "hintText", "class": "QLabel", "condition": "mesh3d_panel",
|
||||||
|
"purpose": "the scene state line: whether this frame is a measurement or a reference" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "charts",
|
||||||
|
"path": "force_plots",
|
||||||
|
"objectName": null,
|
||||||
|
"style": "contentPanel",
|
||||||
|
"class": "ForcePlotsPanel",
|
||||||
|
"views": ["live", "replay", "calibration"],
|
||||||
|
"purpose": "the force charts: three channels plus a whole-session overview strip, with the per-axis readings above the curves",
|
||||||
|
"rows": {
|
||||||
|
"values": [0, 1, 2],
|
||||||
|
"names": ["fn", "fx", "fy"],
|
||||||
|
"keys": ["card", "badge", "name", "value", "unit", "detail"],
|
||||||
|
"template": {
|
||||||
|
"card": { "id": "chart_{name}_card", "path": "force_plots.value_cards[{i}]", "objectName": null, "style": "valueGroup", "class": "QFrame",
|
||||||
|
"purpose": "the {name} channel's reading: badge and name over the number, then the selection's mean and sigma" },
|
||||||
|
"badge": { "id": "chart_{name}_badge", "path": "force_plots.channel_badges[{i}]", "objectName": null, "style": "unitBadge", "class": "QLabel",
|
||||||
|
"purpose": "the {name} channel badge, coloured like its curve" },
|
||||||
|
"name": { "id": "chart_{name}_name", "path": "force_plots.channel_names[{i}]", "objectName": null, "style": "hintText", "class": "QLabel",
|
||||||
|
"purpose": "what the {name} channel measures, in the operator's language" },
|
||||||
|
"value": { "id": "chart_{name}_value", "path": "force_plots.value_labels[{i}]", "objectName": null, "style": "valueCard", "class": "QLabel",
|
||||||
|
"purpose": "the {name} reading itself: large monospace digits, so the axes can be compared at a glance" },
|
||||||
|
"unit": { "id": "chart_{name}_unit", "path": "force_plots.value_units[{i}]", "objectName": null, "style": "hintText", "class": "QLabel",
|
||||||
|
"purpose": "the unit the {name} reading is in, which the chip decides for every axis at once" },
|
||||||
|
"detail": { "id": "chart_{name}_detail", "path": "force_plots.value_details[{i}]", "objectName": null, "style": "hintText", "class": "QLabel",
|
||||||
|
"purpose": "the {name} selection's mean and sigma; kept at a fixed height, so selecting a window adds a number instead of moving the charts" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"elements": [
|
||||||
|
{ "id": "chart_title", "path": "force_plots.title", "objectName": null, "style": "panelTitle", "class": "QLabel",
|
||||||
|
"purpose": "panel heading" },
|
||||||
|
{ "id": "chart_mode_label", "path": "force_plots.mode_label", "objectName": null, "style": "hintText", "class": "QLabel",
|
||||||
|
"purpose": "whether the charts are following live, browsing a selection, or reading a recording" },
|
||||||
|
{ "id": "chart_latest", "path": "force_plots.btn_latest", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "leave browsing and follow the newest sample; disabled for a recording" },
|
||||||
|
{ "id": "chart_overview", "path": "force_plots.btn_overview", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "fit the whole session in the window" },
|
||||||
|
{ "id": "chart_box_zoom", "path": "force_plots.btn_box_zoom", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "box zoom, the one chart action that is a mode and stays checked" },
|
||||||
|
{ "id": "chart_settings", "path": "force_plots.btn_settings", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "open the chart settings: window, ranges and acquisition filters" },
|
||||||
|
{ "id": "chart_unit_badge", "path": "force_plots.unit_badge", "objectName": null, "style": "unitBadge", "class": "QPushButton",
|
||||||
|
"purpose": "the unit chip: a button, because switching newtons/raw is a chart concern" },
|
||||||
|
{ "id": "chart_value_group", "path": "force_plots.value_group", "objectName": null, "style": "valueGroup", "class": "QFrame",
|
||||||
|
"purpose": "the row of per-axis readings, placed above the curves because that is what is read at a glance" },
|
||||||
|
{ "id": "chart_plot_frame", "path": "force_plots.plot_frame", "objectName": null, "style": "cardPanel", "class": "QFrame",
|
||||||
|
"purpose": "the raised container of the plot canvas" },
|
||||||
|
{ "id": "chart_plot_widget", "path": "force_plots.plot_widget", "objectName": null, "class": "GraphicsLayoutWidget",
|
||||||
|
"purpose": "the pyqtgraph canvas: one subplot per channel and the whole-session overview strip below them" },
|
||||||
|
{ "id": "chart_window_caption", "path": "force_plots.window_caption", "objectName": null, "style": "panelTitle", "class": "QLabel",
|
||||||
|
"purpose": "caption of the overview strip" },
|
||||||
|
{ "id": "chart_window_label", "path": "force_plots.window_label", "objectName": null, "style": "panelMeta", "class": "QLabel",
|
||||||
|
"purpose": "the time window the strip is showing" },
|
||||||
|
{ "id": "chart_stats", "path": "force_plots.stats_label", "objectName": null, "style": "panelMeta", "class": "QLabel",
|
||||||
|
"purpose": "how long the selection is and how many samples it holds; the full per-axis readout is its tooltip" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cal_panel",
|
||||||
|
"path": "cal_panel",
|
||||||
|
"objectName": null,
|
||||||
|
"style": "contentPanel",
|
||||||
|
"class": "CalPanel",
|
||||||
|
"views": ["calibration"],
|
||||||
|
"purpose": "the calibration panel: sample each axis against a reference load, review the points, export and apply the coefficients",
|
||||||
|
"elements": [
|
||||||
|
{ "id": "cal_scroll", "path": "cal_panel.scroll", "objectName": null, "class": "QScrollArea",
|
||||||
|
"purpose": "everything below the title scrolls, so a short window clips content instead of losing a function" },
|
||||||
|
{ "id": "cal_title", "path": "cal_panel.title", "objectName": null, "style": "panelTitle", "class": "QLabel",
|
||||||
|
"purpose": "panel heading" },
|
||||||
|
{ "id": "cal_channel_badge", "path": "cal_panel.channel_badge", "objectName": null, "style": "unitBadge", "class": "QLabel",
|
||||||
|
"purpose": "which channel is being calibrated, coloured like that channel's curve" },
|
||||||
|
{ "id": "cal_radio_fn", "path": "cal_panel.radio_fn", "objectName": null, "class": "QRadioButton",
|
||||||
|
"purpose": "calibrate the normal force channel" },
|
||||||
|
{ "id": "cal_radio_fx", "path": "cal_panel.radio_fx", "objectName": null, "class": "QRadioButton",
|
||||||
|
"purpose": "calibrate the shear channel FX" },
|
||||||
|
{ "id": "cal_radio_fy", "path": "cal_panel.radio_fy", "objectName": null, "class": "QRadioButton",
|
||||||
|
"purpose": "calibrate the shear channel FY" },
|
||||||
|
{ "id": "cal_weight_label", "path": "cal_panel._weight_label", "objectName": null, "style": "fieldLabel", "class": "QLabel",
|
||||||
|
"purpose": "label of the reference-load field" },
|
||||||
|
{ "id": "cal_weight_spin", "path": "cal_panel.weight_spin", "objectName": null, "class": "QDoubleSpinBox",
|
||||||
|
"purpose": "the reference load applied for the point being sampled" },
|
||||||
|
{ "id": "cal_duration_label", "path": "cal_panel._duration_label", "objectName": null, "style": "fieldLabel", "class": "QLabel",
|
||||||
|
"purpose": "label of the sampling-duration field" },
|
||||||
|
{ "id": "cal_duration_spin", "path": "cal_panel.duration_spin", "objectName": null, "class": "QDoubleSpinBox",
|
||||||
|
"purpose": "how long one calibration point is sampled for" },
|
||||||
|
{ "id": "cal_record", "path": "cal_panel.btn_record", "objectName": null, "style": "primaryButton / dangerButton", "class": "QPushButton",
|
||||||
|
"purpose": "sample one point; while sampling it is the cancel affordance and stays enabled" },
|
||||||
|
{ "id": "cal_progress", "path": "cal_panel.progress", "objectName": null, "class": "QProgressBar",
|
||||||
|
"purpose": "how far the current point's sampling has got" },
|
||||||
|
{ "id": "cal_live_label", "path": "cal_panel.live_label", "objectName": null, "class": "QLabel",
|
||||||
|
"purpose": "the three raw axis readings while a point is sampled" },
|
||||||
|
{ "id": "cal_empty_hint", "path": "cal_panel.empty_hint", "objectName": null, "style": "hintText", "class": "QLabel",
|
||||||
|
"purpose": "what to do when there are no points yet" },
|
||||||
|
{ "id": "cal_table", "path": "cal_panel.table", "objectName": null, "class": "QTableWidget", "attrs": { "columnCount": 6 },
|
||||||
|
"purpose": "the diagnostic table: six columns (reference load, FN, FX, FY, stability, state), resized to their contents so a narrow panel scrolls instead of eliding a heading" },
|
||||||
|
{ "id": "cal_fit_label", "path": "cal_panel.fit_label", "objectName": null, "style": "hintText", "class": "QLabel",
|
||||||
|
"purpose": "what the current points fit: the readout that says whether more points are needed" },
|
||||||
|
{ "id": "cal_sn_label", "path": "cal_panel._sn_label", "objectName": null, "style": "fieldLabel", "class": "QLabel",
|
||||||
|
"purpose": "label of the device serial field" },
|
||||||
|
{ "id": "cal_sn_edit", "path": "cal_panel.sn_edit", "objectName": null, "class": "QLineEdit",
|
||||||
|
"purpose": "the device serial written into the exported coefficients and the session metadata" },
|
||||||
|
{ "id": "cal_toggle_excluded", "path": "cal_panel.btn_toggle_excluded", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "selection action: exclude or include the selected point in the fit" },
|
||||||
|
{ "id": "cal_toggle_validation", "path": "cal_panel.btn_toggle_validation", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "selection action: mark the selected point as a validation point" },
|
||||||
|
{ "id": "cal_delete", "path": "cal_panel.btn_delete", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "selection action: delete the selected point" },
|
||||||
|
{ "id": "cal_clear_channel", "path": "cal_panel.btn_clear_channel", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "selection action: clear the channel being calibrated" },
|
||||||
|
{ "id": "cal_clear_all", "path": "cal_panel.btn_clear_all", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "selection action: clear every channel" },
|
||||||
|
{ "id": "cal_open_record", "path": "cal_panel.btn_open_record", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "file row: load a saved calibration record" },
|
||||||
|
{ "id": "cal_save_record", "path": "cal_panel.btn_save_record", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "file row: save the samples and their identity as an audit record" },
|
||||||
|
{ "id": "cal_export", "path": "cal_panel.btn_export", "objectName": null, "class": "QPushButton",
|
||||||
|
"purpose": "file row: write the fitted coefficients to disk; it does not apply them" },
|
||||||
|
{ "id": "cal_apply", "path": "cal_panel.btn_apply", "objectName": null, "style": "primaryButton", "class": "QPushButton",
|
||||||
|
"purpose": "file row: push the last export into the running device" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -126,3 +126,37 @@ not a prerequisite.
|
|||||||
|
|
||||||
**Golden images, if adopted:** pin the window size, scale factor, font and theme, or they will flake
|
**Golden images, if adopted:** pin the window size, scale factor, font and theme, or they will flake
|
||||||
and be disabled. A flaky render gate is worse than none.
|
and be disabled. A flaky render gate is worse than none.
|
||||||
|
|
||||||
|
## 7. The element manifest
|
||||||
|
|
||||||
|
[`ui-elements.json`](ui-elements.json) is the machine-readable half of this contract. The rules
|
||||||
|
above say who owns the arrangement; the manifest says **which elements are supposed to exist**, per
|
||||||
|
region, per pane and per view. Each entry carries a stable `id`, the Qt `objectName` (recorded only
|
||||||
|
when it is a stable identity — the rail's transport carries a stylesheet token instead, so it is
|
||||||
|
found by path), the class, a one-line purpose, and the views it appears in. Anything that exists
|
||||||
|
only in some views, or only while a condition holds, declares `views` or a named `condition`
|
||||||
|
rather than leaving it ambiguous.
|
||||||
|
|
||||||
|
[`ui/elements.py`](../src/orisys_studio/ui/elements.py) loads it and answers the questions a test
|
||||||
|
needs: `missing()`, `wrong_class()`, `unlisted_named_widgets()` and `validate()`.
|
||||||
|
[`tests/test_ui_elements.py`](../tests/test_ui_elements.py) runs them against a real
|
||||||
|
`StudioMainWindow` in each view, and asserts the declared panes are the visible ones.
|
||||||
|
[`tools/ui_elements_dump.py`](../tools/ui_elements_dump.py) drafts the file from a live window.
|
||||||
|
|
||||||
|
**The manifest is authored, not generated.** A generated file accepts whatever the code happens to
|
||||||
|
contain, which is the drift it exists to catch; the dump is a drafting aid and its output must be
|
||||||
|
curated by hand. **An element is listed before a test relies on it** — that is what makes the file a
|
||||||
|
spec rather than a description of what someone found. The drift report (visible named widgets the
|
||||||
|
manifest accounts for neither as an element identity nor as a declared stylesheet token) is
|
||||||
|
compared with the record in each view, so new interface names fail until the manifest says what
|
||||||
|
they are.
|
||||||
|
|
||||||
|
Whoever changes the set of elements — adding, renaming or removing a widget the manifest covers —
|
||||||
|
updates the manifest in the same change, before the test is asked to rely on it.
|
||||||
|
|
||||||
|
This is the **element** tier of §6 and nothing more. It does not assert geometry or containment, it
|
||||||
|
does not run the locale/theme/minimum-size matrix, and it does not compare rendered pixels. Those
|
||||||
|
extend it rather than replace it: the geometry tier would add the rectangle a listed element is
|
||||||
|
allowed to occupy, the matrix tier would vary the window states the same list is checked under, and
|
||||||
|
the rendered tier would pin a reference image for the panes it names. None of that is attempted
|
||||||
|
here.
|
||||||
|
|||||||
@@ -0,0 +1,926 @@
|
|||||||
|
"""The Studio window's element manifest, and the helpers that compare it to reality.
|
||||||
|
|
||||||
|
``docs/ui-layout.md`` is the layout contract in prose: it says who owns the arrangement and
|
||||||
|
what each view is for, but nothing in it knows *which elements are supposed to exist*. This
|
||||||
|
module reads the machine-readable half of that contract — ``docs/ui-elements.json`` — and
|
||||||
|
answers the questions a test needs to ask of a real ``StudioMainWindow``:
|
||||||
|
|
||||||
|
* ``missing(window, view)`` — expected elements this window does not have;
|
||||||
|
* ``wrong_class(window, view)`` — elements that are not the recorded Qt class;
|
||||||
|
* ``unlisted_named_widgets(window, view)`` — the **drift report**: visible widgets whose
|
||||||
|
``objectName`` the manifest accounts for neither as an element's identity nor as a
|
||||||
|
declared stylesheet token;
|
||||||
|
* ``validate(window, view)`` — all of the above in one structured result.
|
||||||
|
|
||||||
|
The drift report is deliberately separate from the hard assertions: the manifest will
|
||||||
|
legitimately lag the code, and the point is to *report* the difference rather than to pretend
|
||||||
|
it does not exist. The suite records the drift it expects for each view
|
||||||
|
(``views.<view>.unlisted_named_widgets``) and compares, so a newly named widget fails the
|
||||||
|
test until the manifest is updated.
|
||||||
|
|
||||||
|
The manifest is **authored and reviewed, never generated**; ``tools/ui_elements_dump.py``
|
||||||
|
drafts it from a live window, and that output must be curated before it is adopted.
|
||||||
|
|
||||||
|
An element is located by ``path`` (a dot path from the window, e.g. ``toolbar.btn_run``, with
|
||||||
|
``()`` for a Qt getter such as ``menuBar()``, ``[i]`` for an index, and ``.`` for the window
|
||||||
|
itself) or, when it has a genuinely unique Qt ``objectName``, by that name. Both exist because
|
||||||
|
a Qt ``objectName`` is often a *style* rather than an identifier: the rail's transport carries
|
||||||
|
``primaryButton`` while idle and ``ghostButton`` while acquiring, so it has no stable name to
|
||||||
|
find it by. ``objectName`` therefore records the name only when it is stable, ``style``
|
||||||
|
records the transient or shared token, and ``path`` is what the loader uses.
|
||||||
|
|
||||||
|
The module needs only the standard library and Qt, and it is imported by tests and tools — the
|
||||||
|
application itself does not import it, so the manifest does not need to ship in a frozen
|
||||||
|
build. ``load()`` raises when the file is missing rather than inventing an empty spec: a
|
||||||
|
manifest that cannot be read is not a window with no elements.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
#: The authored manifest. Derived from this file's location, not from the working directory,
|
||||||
|
#: so a test can run from anywhere (``apps/studio`` per the suite's convention).
|
||||||
|
MANIFEST_PATH = Path(__file__).resolve().parents[3] / "docs" / "ui-elements.json"
|
||||||
|
|
||||||
|
#: Named state predicates an element may declare in ``condition``. An element whose condition
|
||||||
|
#: is false for the window under test is *skipped*, not failed: it is a fact about that window
|
||||||
|
#: (a box sensor has no 3D scene), not drift. Keeping the vocabulary closed means a typo in
|
||||||
|
#: the manifest is a manifest problem rather than a silently-skipped element.
|
||||||
|
CONDITIONS: dict[str, str] = {
|
||||||
|
"mesh3d_view": "the 3D view exists (finger sensor with the pane on)",
|
||||||
|
"mesh3d_panel": "the 3D pane's own controls exist (a 3D view could be built)",
|
||||||
|
"video_source": "the run source is a video file",
|
||||||
|
"sensor_dialog_open": "the sensor/calibration dialog has been built",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Qt names its own internal children (``qt_spinbox_lineedit``, ``qt_splithandle_…``). They
|
||||||
|
#: are not part of the window's interface and are excluded from the drift report by prefix.
|
||||||
|
QT_INTERNAL_PREFIX = "qt_"
|
||||||
|
|
||||||
|
_VALID_ROLES = ("window", "region", "pane", "element")
|
||||||
|
_SEGMENT = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)(\(\))?(?:\[(\d+)\])?$")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- model
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Element:
|
||||||
|
"""One entry of the manifest: an element the window is supposed to have."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
object_name: str | None
|
||||||
|
class_name: str
|
||||||
|
purpose: str
|
||||||
|
path: str | None
|
||||||
|
views: tuple[str, ...]
|
||||||
|
condition: str | None = None
|
||||||
|
style: str | None = None
|
||||||
|
role: str = "element"
|
||||||
|
group: str = ""
|
||||||
|
attrs: tuple[tuple[str, Any], ...] = ()
|
||||||
|
primary: bool = False
|
||||||
|
companion: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
"""What a failing assertion should name: the path when there is one."""
|
||||||
|
return self.path or self.object_name or self.id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attributes(self) -> dict[str, Any]:
|
||||||
|
return dict(self.attrs)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Region:
|
||||||
|
"""A named part of the window (the brand bar, the rail, the dialog)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
class_name: str
|
||||||
|
purpose: str
|
||||||
|
path: str | None
|
||||||
|
object_name: str | None
|
||||||
|
views: tuple[str, ...]
|
||||||
|
elements: tuple[Element, ...]
|
||||||
|
condition: str | None = None
|
||||||
|
attrs: tuple[tuple[str, Any], ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def element(self) -> Element:
|
||||||
|
return Element(
|
||||||
|
id=self.id,
|
||||||
|
object_name=self.object_name,
|
||||||
|
class_name=self.class_name,
|
||||||
|
purpose=self.purpose,
|
||||||
|
path=self.path,
|
||||||
|
views=self.views,
|
||||||
|
condition=self.condition,
|
||||||
|
role="region",
|
||||||
|
group=self.id,
|
||||||
|
attrs=self.attrs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Pane:
|
||||||
|
"""A panel in the content splitter: an element, plus the elements inside it."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
class_name: str
|
||||||
|
purpose: str
|
||||||
|
path: str | None
|
||||||
|
object_name: str | None
|
||||||
|
style: str | None
|
||||||
|
views: tuple[str, ...]
|
||||||
|
elements: tuple[Element, ...]
|
||||||
|
condition: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def element(self) -> Element:
|
||||||
|
return Element(
|
||||||
|
id=self.id,
|
||||||
|
object_name=self.object_name,
|
||||||
|
class_name=self.class_name,
|
||||||
|
purpose=self.purpose,
|
||||||
|
path=self.path,
|
||||||
|
views=self.views,
|
||||||
|
condition=self.condition,
|
||||||
|
style=self.style,
|
||||||
|
role="pane",
|
||||||
|
group=self.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ViewSpec:
|
||||||
|
"""One top-level view: what leads, what accompanies it, and the recorded drift."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
purpose: str
|
||||||
|
primary: tuple[str, ...]
|
||||||
|
companions: tuple[str, ...]
|
||||||
|
unlisted_named_widgets: tuple[str, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def panes(self) -> tuple[str, ...]:
|
||||||
|
return self.primary + self.companions
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Manifest:
|
||||||
|
"""The parsed manifest. Constructed by :func:`load`, not by hand."""
|
||||||
|
|
||||||
|
version: int
|
||||||
|
scope: str
|
||||||
|
window: Element
|
||||||
|
views: dict[str, ViewSpec]
|
||||||
|
region_list: tuple[Region, ...]
|
||||||
|
panes: tuple[Pane, ...]
|
||||||
|
styles: tuple[tuple[str, str], ...]
|
||||||
|
path: Path
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- accessors
|
||||||
|
def view_ids(self) -> tuple[str, ...]:
|
||||||
|
return tuple(self.views)
|
||||||
|
|
||||||
|
def view_spec(self, view: str) -> ViewSpec:
|
||||||
|
try:
|
||||||
|
return self.views[view]
|
||||||
|
except KeyError:
|
||||||
|
raise KeyError(
|
||||||
|
f"unknown view {view!r}; the manifest declares {list(self.views)}"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def regions(self, view: str | None = None) -> tuple[Region, ...]:
|
||||||
|
"""Every region, or only those that appear in ``view``."""
|
||||||
|
if view is None:
|
||||||
|
return self.region_list
|
||||||
|
return tuple(region for region in self.region_list if view in region.views)
|
||||||
|
|
||||||
|
def panes_for(self, view: str) -> tuple[Pane, ...]:
|
||||||
|
return tuple(pane for pane in self.panes if view in pane.views)
|
||||||
|
|
||||||
|
def elements_for(self, view: str) -> tuple[Element, ...]:
|
||||||
|
"""Every element this view is supposed to show: regions, their members, the panes
|
||||||
|
and the panes' own contents.
|
||||||
|
|
||||||
|
Membership is declared once, on each entry's own ``views``; the view entry holds the
|
||||||
|
*arrangement* (``primary``/``companions``) and the recorded drift, not a second copy
|
||||||
|
of the membership list.
|
||||||
|
"""
|
||||||
|
found: list[Element] = []
|
||||||
|
for region in self.region_list:
|
||||||
|
if view not in region.views:
|
||||||
|
continue
|
||||||
|
found.append(region.element)
|
||||||
|
found.extend(
|
||||||
|
element for element in region.elements if view in element.views
|
||||||
|
)
|
||||||
|
for pane in self.panes:
|
||||||
|
if view not in pane.views:
|
||||||
|
continue
|
||||||
|
found.append(pane.element)
|
||||||
|
found.extend(element for element in pane.elements if view in element.views)
|
||||||
|
return tuple(found)
|
||||||
|
|
||||||
|
def all_elements(self) -> tuple[Element, ...]:
|
||||||
|
found = [self.window]
|
||||||
|
for region in self.region_list:
|
||||||
|
found.append(region.element)
|
||||||
|
found.extend(region.elements)
|
||||||
|
for pane in self.panes:
|
||||||
|
found.append(pane.element)
|
||||||
|
found.extend(pane.elements)
|
||||||
|
return tuple(found)
|
||||||
|
|
||||||
|
def by_object_name(self) -> dict[str, tuple[Element, ...]]:
|
||||||
|
"""Map a stable ``objectName`` to the elements that carry it.
|
||||||
|
|
||||||
|
A tuple, not a single element: an ``objectName`` is not an identity, and two elements
|
||||||
|
sharing one is information (it means the name is a style) rather than an error. The
|
||||||
|
map covers recorded identities only; elements found by ``path`` are absent by design.
|
||||||
|
"""
|
||||||
|
found: dict[str, list[Element]] = {}
|
||||||
|
for element in self.all_elements():
|
||||||
|
if element.object_name:
|
||||||
|
found.setdefault(element.object_name, []).append(element)
|
||||||
|
return {name: tuple(items) for name, items in found.items()}
|
||||||
|
|
||||||
|
def style_tokens(self) -> dict[str, str]:
|
||||||
|
"""The stylesheet vocabulary: objectNames that are styles, not identities."""
|
||||||
|
return dict(self.styles)
|
||||||
|
|
||||||
|
def by_id(self) -> dict[str, Element]:
|
||||||
|
"""Every element by its stable id (ids are unique; ``problems()`` enforces it)."""
|
||||||
|
return {element.id: element for element in self.all_elements()}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- self-check
|
||||||
|
def problems(self) -> list[str]:
|
||||||
|
"""Structural defects in the manifest itself (not in the window).
|
||||||
|
|
||||||
|
The test checks these too: a spec with a duplicated id, an unreachable lookup, an
|
||||||
|
unknown condition or a dangling pane reference would otherwise surface as a
|
||||||
|
mysterious window defect.
|
||||||
|
"""
|
||||||
|
problems: list[str] = []
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
for element in self.all_elements():
|
||||||
|
seen[element.id] = seen.get(element.id, 0) + 1
|
||||||
|
for element_id, count in sorted(seen.items()):
|
||||||
|
if count > 1:
|
||||||
|
problems.append(
|
||||||
|
f"duplicate element id {element_id!r} ({count} entries)"
|
||||||
|
)
|
||||||
|
paths: dict[str, str] = {}
|
||||||
|
for element in self.all_elements():
|
||||||
|
if not element.path and not element.object_name:
|
||||||
|
problems.append(f"{element.id}: needs a path or an objectName")
|
||||||
|
if element.condition and element.condition not in CONDITIONS:
|
||||||
|
problems.append(
|
||||||
|
f"{element.id}: unknown condition {element.condition!r}"
|
||||||
|
)
|
||||||
|
if element.role not in _VALID_ROLES:
|
||||||
|
problems.append(f"{element.id}: unknown role {element.role!r}")
|
||||||
|
for view in element.views:
|
||||||
|
if view not in self.views:
|
||||||
|
problems.append(f"{element.id}: unknown view {view!r}")
|
||||||
|
if element.path:
|
||||||
|
previous = paths.get(element.path)
|
||||||
|
if previous and previous != element.id:
|
||||||
|
problems.append(
|
||||||
|
f"path {element.path!r} is claimed by both {previous!r} and "
|
||||||
|
f"{element.id!r}"
|
||||||
|
)
|
||||||
|
paths[element.path] = element.id
|
||||||
|
for name, elements in self.by_object_name().items():
|
||||||
|
if len(elements) > 1 and any(not element.path for element in elements):
|
||||||
|
problems.append(
|
||||||
|
f"objectName {name!r} is shared by "
|
||||||
|
f"{[element.id for element in elements]} without paths"
|
||||||
|
)
|
||||||
|
style_names = set(self.style_tokens())
|
||||||
|
for element in self.all_elements():
|
||||||
|
for token in (element.style or "").split("/"):
|
||||||
|
token = token.strip()
|
||||||
|
if token and token not in style_names:
|
||||||
|
problems.append(
|
||||||
|
f"{element.id}: style {token!r} is not in style_tokens"
|
||||||
|
)
|
||||||
|
for name in self.style_tokens():
|
||||||
|
if name in self.by_object_name():
|
||||||
|
problems.append(
|
||||||
|
f"{name!r} is declared both as an element's objectName and as a style"
|
||||||
|
)
|
||||||
|
pane_ids = {pane.id for pane in self.panes}
|
||||||
|
for spec in self.views.values():
|
||||||
|
for pane_id in spec.panes:
|
||||||
|
if pane_id not in pane_ids:
|
||||||
|
problems.append(f"view {spec.id!r}: unknown pane {pane_id!r}")
|
||||||
|
for name in spec.unlisted_named_widgets:
|
||||||
|
if name in self.by_object_name() or name in style_names:
|
||||||
|
problems.append(
|
||||||
|
f"view {spec.id!r}: {name!r} is recorded as unlisted but the "
|
||||||
|
"manifest already accounts for it"
|
||||||
|
)
|
||||||
|
for pane in self.panes:
|
||||||
|
listed = {
|
||||||
|
view_id for view_id, spec in self.views.items() if pane.id in spec.panes
|
||||||
|
}
|
||||||
|
if listed != set(pane.views):
|
||||||
|
problems.append(
|
||||||
|
f"pane {pane.id!r}: views {sorted(pane.views)} disagree with the view "
|
||||||
|
f"lists {sorted(listed)}"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------ load
|
||||||
|
|
||||||
|
|
||||||
|
def _views_of(raw: Any, default_views: tuple[str, ...], where: str) -> tuple[str, ...]:
|
||||||
|
if raw is None or raw == "all":
|
||||||
|
return default_views
|
||||||
|
if isinstance(raw, str):
|
||||||
|
return (raw,)
|
||||||
|
if isinstance(raw, list) and all(isinstance(item, str) for item in raw):
|
||||||
|
return tuple(raw)
|
||||||
|
raise ValueError(
|
||||||
|
f"{where}: 'views' must be 'all', a view id, or a list of view ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _attrs_of(raw: Any, where: str) -> tuple[tuple[str, Any], ...]:
|
||||||
|
if raw is None:
|
||||||
|
return ()
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError(f"{where}: 'attrs' must be an object")
|
||||||
|
for key, value in raw.items():
|
||||||
|
if not isinstance(key, str) or not key:
|
||||||
|
raise ValueError(f"{where}: attribute names must be non-empty strings")
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
raise ValueError(f"{where}: attribute {key!r} must be a scalar")
|
||||||
|
return tuple((str(key), value) for key, value in raw.items())
|
||||||
|
|
||||||
|
|
||||||
|
def _element(
|
||||||
|
raw: dict,
|
||||||
|
default_views: tuple[str, ...],
|
||||||
|
group: str,
|
||||||
|
role: str,
|
||||||
|
*,
|
||||||
|
default_condition: str | None = None,
|
||||||
|
) -> Element:
|
||||||
|
element_id = str(raw.get("id") or "")
|
||||||
|
if not element_id:
|
||||||
|
raise ValueError(f"{group}: an element has no id: {raw!r}")
|
||||||
|
where = f"{group}.{element_id}"
|
||||||
|
class_name = raw.get("class")
|
||||||
|
if not isinstance(class_name, str) or not class_name:
|
||||||
|
raise ValueError(f"{where}: 'class' is required")
|
||||||
|
purpose = raw.get("purpose")
|
||||||
|
if not isinstance(purpose, str) or not purpose:
|
||||||
|
raise ValueError(f"{where}: 'purpose' is required")
|
||||||
|
object_name = raw.get("objectName")
|
||||||
|
if object_name is not None and not isinstance(object_name, str):
|
||||||
|
raise ValueError(f"{where}: 'objectName' must be a string or null")
|
||||||
|
path = raw.get("path")
|
||||||
|
if path is not None and not isinstance(path, str):
|
||||||
|
raise ValueError(f"{where}: 'path' must be a string or null")
|
||||||
|
condition = raw.get("condition")
|
||||||
|
if condition is not None and not isinstance(condition, str):
|
||||||
|
raise ValueError(f"{where}: 'condition' must be a string or null")
|
||||||
|
return Element(
|
||||||
|
id=element_id,
|
||||||
|
object_name=object_name or None,
|
||||||
|
class_name=class_name,
|
||||||
|
purpose=purpose,
|
||||||
|
path=path or None,
|
||||||
|
views=_views_of(raw.get("views"), default_views, where),
|
||||||
|
# A condition declared on the region or pane applies to its contents: they cannot
|
||||||
|
# exist while it does not, and repeating it on every child would be noise.
|
||||||
|
condition=condition or default_condition,
|
||||||
|
style=raw.get("style") or None,
|
||||||
|
role=str(raw.get("role") or role),
|
||||||
|
group=group,
|
||||||
|
attrs=_attrs_of(raw.get("attrs"), where),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(
|
||||||
|
entry: dict,
|
||||||
|
default_views: tuple[str, ...],
|
||||||
|
group: str,
|
||||||
|
role: str,
|
||||||
|
default_condition: str | None = None,
|
||||||
|
) -> tuple[Element, ...]:
|
||||||
|
"""Expand a ``rows`` template into one element per row.
|
||||||
|
|
||||||
|
The three force channels have identical controls, so the manifest describes the row once
|
||||||
|
with ``{i}``/``{name}`` placeholders instead of listing it three times. Expansion is a
|
||||||
|
deliberate, narrow exception to "what is written is what is checked": the placeholders are
|
||||||
|
substituted here, and every expanded element is an ordinary element afterwards.
|
||||||
|
"""
|
||||||
|
template = entry.get("rows")
|
||||||
|
if not template:
|
||||||
|
return ()
|
||||||
|
if not isinstance(template, dict) or not isinstance(template.get("template"), dict):
|
||||||
|
raise ValueError(f"{group}: 'rows' needs a 'template' object")
|
||||||
|
values = template.get("values")
|
||||||
|
if not isinstance(values, list) or not values:
|
||||||
|
raise ValueError(f"{group}: 'rows.values' must be a non-empty list")
|
||||||
|
names = template.get("names")
|
||||||
|
if names is not None and (not isinstance(names, list) or len(names) != len(values)):
|
||||||
|
raise ValueError(f"{group}: 'rows.names' must match 'rows.values' in length")
|
||||||
|
keys = tuple(template.get("keys") or ())
|
||||||
|
if not keys:
|
||||||
|
raise ValueError(f"{group}: 'rows.keys' must name the per-row entries")
|
||||||
|
expanded: list[Element] = []
|
||||||
|
for index, value in enumerate(values):
|
||||||
|
fields = {"i": index, "value": value}
|
||||||
|
if names is not None:
|
||||||
|
fields["name"] = names[index]
|
||||||
|
for key in keys:
|
||||||
|
raw = template["template"].get(key)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError(f"{group}: rows.template.{key} must be an object")
|
||||||
|
|
||||||
|
def substitute(text: str, _fields=fields, _key=key) -> str:
|
||||||
|
try:
|
||||||
|
return text.format(**_fields)
|
||||||
|
except (KeyError, IndexError, ValueError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"{group}: rows.template.{_key} has a bad placeholder: {exc}"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
item = {
|
||||||
|
name: substitute(value) if isinstance(value, str) else value
|
||||||
|
for name, value in raw.items()
|
||||||
|
}
|
||||||
|
expanded.append(
|
||||||
|
_element(
|
||||||
|
item,
|
||||||
|
default_views,
|
||||||
|
group,
|
||||||
|
role,
|
||||||
|
default_condition=default_condition,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(expanded)
|
||||||
|
|
||||||
|
|
||||||
|
def _elements_of(
|
||||||
|
entry: dict,
|
||||||
|
default_views: tuple[str, ...],
|
||||||
|
group: str,
|
||||||
|
role: str,
|
||||||
|
default_condition: str | None = None,
|
||||||
|
) -> tuple[Element, ...]:
|
||||||
|
elements = tuple(
|
||||||
|
_element(raw, default_views, group, role, default_condition=default_condition)
|
||||||
|
for raw in entry.get("elements") or ()
|
||||||
|
)
|
||||||
|
return elements + _rows(entry, default_views, group, role, default_condition)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(payload: dict, path: Path | str = MANIFEST_PATH) -> Manifest:
|
||||||
|
"""Turn a decoded manifest into the model, validating its shape as it goes."""
|
||||||
|
default_views = tuple(payload.get("views") or ())
|
||||||
|
if not default_views:
|
||||||
|
raise ValueError("manifest declares no views")
|
||||||
|
window = _element(
|
||||||
|
{**(payload.get("window") or {}), "views": "all"},
|
||||||
|
default_views,
|
||||||
|
"window",
|
||||||
|
"window",
|
||||||
|
)
|
||||||
|
regions: list[Region] = []
|
||||||
|
for raw_region in payload.get("regions") or ():
|
||||||
|
region_id = str(raw_region.get("id") or "")
|
||||||
|
if not region_id:
|
||||||
|
raise ValueError("a region has no id")
|
||||||
|
region_class = raw_region.get("class")
|
||||||
|
if not isinstance(region_class, str) or not region_class:
|
||||||
|
raise ValueError(f"region {region_id}: 'class' is required")
|
||||||
|
region_purpose = raw_region.get("purpose")
|
||||||
|
if not isinstance(region_purpose, str) or not region_purpose:
|
||||||
|
raise ValueError(f"region {region_id}: 'purpose' is required")
|
||||||
|
region_views = _views_of(
|
||||||
|
raw_region.get("views"), default_views, f"region {region_id}"
|
||||||
|
)
|
||||||
|
region_condition = raw_region.get("condition") or None
|
||||||
|
regions.append(
|
||||||
|
Region(
|
||||||
|
id=region_id,
|
||||||
|
class_name=region_class,
|
||||||
|
purpose=region_purpose,
|
||||||
|
path=raw_region.get("path") or None,
|
||||||
|
object_name=raw_region.get("objectName") or None,
|
||||||
|
views=region_views,
|
||||||
|
elements=_elements_of(
|
||||||
|
raw_region, region_views, region_id, "element", region_condition
|
||||||
|
),
|
||||||
|
condition=region_condition,
|
||||||
|
attrs=_attrs_of(raw_region.get("attrs"), f"region {region_id}"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
panes: list[Pane] = []
|
||||||
|
for raw_pane in payload.get("panes") or ():
|
||||||
|
pane_id = str(raw_pane.get("id") or "")
|
||||||
|
if not pane_id:
|
||||||
|
raise ValueError("a pane has no id")
|
||||||
|
pane_class = raw_pane.get("class")
|
||||||
|
if not isinstance(pane_class, str) or not pane_class:
|
||||||
|
raise ValueError(f"pane {pane_id}: 'class' is required")
|
||||||
|
pane_purpose = raw_pane.get("purpose")
|
||||||
|
if not isinstance(pane_purpose, str) or not pane_purpose:
|
||||||
|
raise ValueError(f"pane {pane_id}: 'purpose' is required")
|
||||||
|
pane_views = _views_of(raw_pane.get("views"), default_views, f"pane {pane_id}")
|
||||||
|
pane_condition = raw_pane.get("condition") or None
|
||||||
|
panes.append(
|
||||||
|
Pane(
|
||||||
|
id=pane_id,
|
||||||
|
class_name=pane_class,
|
||||||
|
purpose=pane_purpose,
|
||||||
|
path=raw_pane.get("path") or None,
|
||||||
|
object_name=raw_pane.get("objectName") or None,
|
||||||
|
style=raw_pane.get("style") or None,
|
||||||
|
views=pane_views,
|
||||||
|
elements=_elements_of(
|
||||||
|
raw_pane, pane_views, pane_id, "element", pane_condition
|
||||||
|
),
|
||||||
|
condition=pane_condition,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
views: dict[str, ViewSpec] = {}
|
||||||
|
for view_id, raw_spec in (payload.get("views") or {}).items():
|
||||||
|
if not isinstance(raw_spec, dict):
|
||||||
|
raise ValueError(f"view {view_id!r}: must be an object")
|
||||||
|
views[view_id] = ViewSpec(
|
||||||
|
id=view_id,
|
||||||
|
purpose=str(raw_spec.get("purpose") or ""),
|
||||||
|
primary=tuple(raw_spec.get("primary") or ()),
|
||||||
|
companions=tuple(raw_spec.get("companions") or ()),
|
||||||
|
unlisted_named_widgets=tuple(raw_spec.get("unlisted_named_widgets") or ()),
|
||||||
|
)
|
||||||
|
styles: list[tuple[str, str]] = []
|
||||||
|
for raw_style in payload.get("style_tokens") or ():
|
||||||
|
if not isinstance(raw_style, dict) or not raw_style.get("name"):
|
||||||
|
raise ValueError("each style token needs a name")
|
||||||
|
styles.append((str(raw_style["name"]), str(raw_style.get("purpose") or "")))
|
||||||
|
return Manifest(
|
||||||
|
version=int(payload.get("version") or 0),
|
||||||
|
scope=str(payload.get("scope") or ""),
|
||||||
|
window=window,
|
||||||
|
views=views,
|
||||||
|
region_list=tuple(regions),
|
||||||
|
panes=tuple(panes),
|
||||||
|
styles=tuple(styles),
|
||||||
|
path=Path(path),
|
||||||
|
note=str(payload.get("note") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def load(path: str | Path | None = None) -> Manifest:
|
||||||
|
"""Read and parse the manifest (cached per path: it is read-only at runtime)."""
|
||||||
|
target = Path(path) if path is not None else MANIFEST_PATH
|
||||||
|
if not target.is_file():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"UI element manifest not found: {target}. It is an authored document; see "
|
||||||
|
"docs/ui-layout.md and tools/ui_elements_dump.py to draft a replacement."
|
||||||
|
)
|
||||||
|
with target.open(encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
return parse(payload, target)
|
||||||
|
|
||||||
|
|
||||||
|
def manifest() -> Manifest:
|
||||||
|
"""The project's manifest (the common case for callers)."""
|
||||||
|
return load()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_manifest() -> Manifest:
|
||||||
|
"""The manifest to compare against when a caller did not pass one."""
|
||||||
|
return manifest()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- window access
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(root: Any, path: str) -> Any | None:
|
||||||
|
if path == ".":
|
||||||
|
return root
|
||||||
|
node = root
|
||||||
|
for segment in path.split("."):
|
||||||
|
match = _SEGMENT.match(segment)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
name, call, index = match.groups()
|
||||||
|
try:
|
||||||
|
if not hasattr(node, name):
|
||||||
|
return None
|
||||||
|
node = getattr(node, name)
|
||||||
|
if call:
|
||||||
|
node = node()
|
||||||
|
if index is not None:
|
||||||
|
node = node[int(index)]
|
||||||
|
except Exception: # noqa: BLE001 - a path that cannot be walked is a missing element
|
||||||
|
return None
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(window: Any, element: Element) -> Any | None:
|
||||||
|
"""The live object an element names, or ``None``.
|
||||||
|
|
||||||
|
``path`` wins because it is unambiguous; an element with only an ``objectName`` is looked
|
||||||
|
up among the window's descendants. A widget is returned as it is, including one that is
|
||||||
|
not visible: the manifest states what exists, and visibility belongs to the view.
|
||||||
|
"""
|
||||||
|
if window is None:
|
||||||
|
return None
|
||||||
|
if element.path:
|
||||||
|
return _resolve_path(window, element.path)
|
||||||
|
if not element.object_name:
|
||||||
|
return None
|
||||||
|
from PySide6.QtCore import QObject
|
||||||
|
|
||||||
|
return window.findChild(QObject, element.object_name)
|
||||||
|
|
||||||
|
|
||||||
|
def condition_holds(window: Any, condition: str | None) -> bool:
|
||||||
|
"""Whether a named condition is true for this window.
|
||||||
|
|
||||||
|
``None`` (an unconditional element) is always true. An unknown condition is false and is
|
||||||
|
reported by ``Manifest.problems()``, so a typo cannot silently skip an element.
|
||||||
|
"""
|
||||||
|
if not condition:
|
||||||
|
return True
|
||||||
|
if condition == "mesh3d_view":
|
||||||
|
return getattr(window.mesh3d_panel, "view", None) is not None
|
||||||
|
if condition == "mesh3d_panel":
|
||||||
|
view = getattr(window.mesh3d_panel, "view", None)
|
||||||
|
return getattr(view, "panel", None) is not None
|
||||||
|
if condition == "video_source":
|
||||||
|
return window.toolbar.source_mode() == "video"
|
||||||
|
if condition == "sensor_dialog_open":
|
||||||
|
return window.sensor_dialog is not None
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def descendants(window: Any) -> Iterator[Any]:
|
||||||
|
"""Every ``QWidget`` below ``window``, the window itself first."""
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
yield window
|
||||||
|
yield from window.findChildren(QWidget)
|
||||||
|
|
||||||
|
|
||||||
|
def visible_named_widgets(window: Any) -> set[str]:
|
||||||
|
"""Names of the visible widgets: what "on screen in this view" means here.
|
||||||
|
|
||||||
|
Only visible widgets count, so the set describes the view rather than the whole
|
||||||
|
lazily-built tree, and Qt's own internal names are dropped because they are not part of
|
||||||
|
the interface.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
widget.objectName()
|
||||||
|
for widget in descendants(window)
|
||||||
|
if widget.isVisible()
|
||||||
|
and widget.objectName()
|
||||||
|
and not widget.objectName().startswith(QT_INTERNAL_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _accounted_names(manifest_obj: Manifest) -> set[str]:
|
||||||
|
"""Names the manifest already explains: element identities and declared styles."""
|
||||||
|
names = set(manifest_obj.style_tokens())
|
||||||
|
names.update(
|
||||||
|
element.object_name
|
||||||
|
for element in manifest_obj.all_elements()
|
||||||
|
if element.object_name
|
||||||
|
)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- comparison
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClassMismatch:
|
||||||
|
"""An element that resolved to the wrong Qt class."""
|
||||||
|
|
||||||
|
element: Element
|
||||||
|
found: str
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"{self.element.id} ({self.element.key}): {self.found} != "
|
||||||
|
f"{self.element.class_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AttrMismatch:
|
||||||
|
"""An element whose recorded attribute is not the value on the widget."""
|
||||||
|
|
||||||
|
element: Element
|
||||||
|
name: str
|
||||||
|
expected: Any
|
||||||
|
found: Any
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"{self.element.id} ({self.element.key}).{self.name}: "
|
||||||
|
f"{self.found!r} != {self.expected!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Validation:
|
||||||
|
"""The structured result of ``validate`` for one window and one view."""
|
||||||
|
|
||||||
|
view: str
|
||||||
|
checked: int
|
||||||
|
skipped: tuple[Element, ...] = ()
|
||||||
|
missing: tuple[Element, ...] = ()
|
||||||
|
wrong_class: tuple[ClassMismatch, ...] = ()
|
||||||
|
wrong_attrs: tuple[AttrMismatch, ...] = ()
|
||||||
|
unlisted: tuple[str, ...] = ()
|
||||||
|
unlisted_recorded: tuple[str, ...] = ()
|
||||||
|
pane_visibility: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def new_unlisted(self) -> tuple[str, ...]:
|
||||||
|
"""Named widgets present now that the manifest does not record."""
|
||||||
|
return tuple(sorted(set(self.unlisted) - set(self.unlisted_recorded)))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stale_unlisted(self) -> tuple[str, ...]:
|
||||||
|
"""Names the manifest records that are no longer on screen."""
|
||||||
|
return tuple(sorted(set(self.unlisted_recorded) - set(self.unlisted)))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def drift(self) -> bool:
|
||||||
|
"""Whether the drift report differs from the drift the manifest records."""
|
||||||
|
return bool(self.new_unlisted or self.stale_unlisted)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return not (
|
||||||
|
self.missing
|
||||||
|
or self.wrong_class
|
||||||
|
or self.wrong_attrs
|
||||||
|
or self.pane_visibility
|
||||||
|
or self.drift
|
||||||
|
)
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
lines = [
|
||||||
|
f"view {self.view}: {self.checked} element(s) checked, "
|
||||||
|
f"{len(self.skipped)} skipped by condition"
|
||||||
|
]
|
||||||
|
for element in self.missing:
|
||||||
|
lines.append(f" missing: {element.id} ({element.key})")
|
||||||
|
for mismatch in self.wrong_class:
|
||||||
|
lines.append(f" wrong class: {mismatch}")
|
||||||
|
for mismatch in self.wrong_attrs:
|
||||||
|
lines.append(f" wrong attribute: {mismatch}")
|
||||||
|
for problem in self.pane_visibility:
|
||||||
|
lines.append(f" pane visibility: {problem}")
|
||||||
|
for name in self.new_unlisted:
|
||||||
|
lines.append(f" unlisted named widget: {name!r}")
|
||||||
|
for name in self.stale_unlisted:
|
||||||
|
lines.append(f" recorded but no longer on screen: {name!r}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def missing(
|
||||||
|
window: Any, view: str, manifest_obj: Manifest | None = None
|
||||||
|
) -> list[Element]:
|
||||||
|
"""Elements this view expects that the window does not have (condition permitting)."""
|
||||||
|
spec = (manifest_obj or _default_manifest()).elements_for(view)
|
||||||
|
return [
|
||||||
|
element
|
||||||
|
for element in spec
|
||||||
|
if condition_holds(window, element.condition)
|
||||||
|
and resolve(window, element) is None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def wrong_class(
|
||||||
|
window: Any, view: str, manifest_obj: Manifest | None = None
|
||||||
|
) -> list[ClassMismatch]:
|
||||||
|
"""Elements that exist but are not the recorded Qt class."""
|
||||||
|
found: list[ClassMismatch] = []
|
||||||
|
for element in (manifest_obj or _default_manifest()).elements_for(view):
|
||||||
|
if not condition_holds(window, element.condition):
|
||||||
|
continue
|
||||||
|
widget = resolve(window, element)
|
||||||
|
if widget is None:
|
||||||
|
continue
|
||||||
|
actual = type(widget).__name__
|
||||||
|
if actual != element.class_name:
|
||||||
|
found.append(ClassMismatch(element=element, found=actual))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def wrong_attrs(
|
||||||
|
window: Any, view: str, manifest_obj: Manifest | None = None
|
||||||
|
) -> list[AttrMismatch]:
|
||||||
|
"""Recorded structural attributes (a table's column count, a splitter's child count)
|
||||||
|
that the live widget does not have.
|
||||||
|
|
||||||
|
A recorded ``objectName`` is checked here too: the name is part of what the manifest
|
||||||
|
claims about the widget, so a rename is reported rather than silently ignored.
|
||||||
|
"""
|
||||||
|
found: list[AttrMismatch] = []
|
||||||
|
for element in (manifest_obj or _default_manifest()).elements_for(view):
|
||||||
|
if not condition_holds(window, element.condition):
|
||||||
|
continue
|
||||||
|
widget = resolve(window, element)
|
||||||
|
if widget is None:
|
||||||
|
continue
|
||||||
|
checks = list(element.attrs)
|
||||||
|
if element.object_name:
|
||||||
|
checks.append(("objectName", element.object_name))
|
||||||
|
for name, expected in checks:
|
||||||
|
actual = getattr(widget, name, None)
|
||||||
|
if callable(actual):
|
||||||
|
actual = actual()
|
||||||
|
if actual != expected:
|
||||||
|
found.append(
|
||||||
|
AttrMismatch(
|
||||||
|
element=element, name=name, expected=expected, found=actual
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def unlisted_named_widgets(
|
||||||
|
window: Any, view: str, manifest_obj: Manifest | None = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""Visible named widgets this view has that the manifest does not account for.
|
||||||
|
|
||||||
|
A *report*, not a verdict: the manifest is expected to lag, so the suite compares this
|
||||||
|
with the drift the manifest records instead of requiring it to be empty.
|
||||||
|
"""
|
||||||
|
manifest_obj = manifest_obj or _default_manifest()
|
||||||
|
return sorted(visible_named_widgets(window) - _accounted_names(manifest_obj))
|
||||||
|
|
||||||
|
|
||||||
|
def pane_visibility_problems(
|
||||||
|
window: Any, view: str, manifest_obj: Manifest | None = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""Panes on screen in a view that does not list them, or missing from the one that does.
|
||||||
|
|
||||||
|
The view entry's ``primary`` and ``companions`` are the arrangement, so they are also the
|
||||||
|
statement of what is on screen: a pane listed there must be visible once its condition
|
||||||
|
holds, and a pane that is not listed must not be.
|
||||||
|
"""
|
||||||
|
manifest_obj = manifest_obj or _default_manifest()
|
||||||
|
spec = manifest_obj.view_spec(view)
|
||||||
|
problems: list[str] = []
|
||||||
|
for pane in manifest_obj.panes:
|
||||||
|
widget = resolve(window, pane.element)
|
||||||
|
if widget is None:
|
||||||
|
continue
|
||||||
|
expected = pane.id in spec.panes and condition_holds(window, pane.condition)
|
||||||
|
visible = bool(widget.isVisible())
|
||||||
|
if expected and not visible:
|
||||||
|
problems.append(f"{pane.id} is listed in {view} but is not visible")
|
||||||
|
elif visible and pane.id not in spec.panes:
|
||||||
|
problems.append(f"{pane.id} is visible in {view} but is not listed there")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
window: Any, view: str, manifest_obj: Manifest | None = None
|
||||||
|
) -> Validation:
|
||||||
|
"""Everything the manifest can say about this window in this view, in one result."""
|
||||||
|
manifest_obj = manifest_obj or _default_manifest()
|
||||||
|
elements = manifest_obj.elements_for(view)
|
||||||
|
skipped = tuple(
|
||||||
|
element
|
||||||
|
for element in elements
|
||||||
|
if not condition_holds(window, element.condition)
|
||||||
|
)
|
||||||
|
return Validation(
|
||||||
|
view=view,
|
||||||
|
checked=len(elements) - len(skipped),
|
||||||
|
skipped=skipped,
|
||||||
|
missing=tuple(missing(window, view, manifest_obj)),
|
||||||
|
wrong_class=tuple(wrong_class(window, view, manifest_obj)),
|
||||||
|
wrong_attrs=tuple(wrong_attrs(window, view, manifest_obj)),
|
||||||
|
unlisted=tuple(unlisted_named_widgets(window, view, manifest_obj)),
|
||||||
|
unlisted_recorded=manifest_obj.view_spec(view).unlisted_named_widgets,
|
||||||
|
pane_visibility=tuple(pane_visibility_problems(window, view, manifest_obj)),
|
||||||
|
)
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
"""The UI element manifest against a real window, in every view.
|
||||||
|
|
||||||
|
``docs/ui-elements.json`` is the machine-readable half of the layout contract in
|
||||||
|
``docs/ui-layout.md``: the prose says who owns the arrangement, the manifest says *which
|
||||||
|
elements are supposed to exist*. These tests make that claim falsifiable against a real
|
||||||
|
``StudioMainWindow``:
|
||||||
|
|
||||||
|
* every element the view declares exists, is reachable from the window and is the recorded
|
||||||
|
Qt class (plus the recorded structural attributes, and the recorded ``objectName``);
|
||||||
|
* the view's panes are the ones the manifest says are on screen;
|
||||||
|
* the drift report — visible named widgets the manifest accounts for neither as an element
|
||||||
|
identity nor as a declared stylesheet token — equals the drift the manifest records;
|
||||||
|
* every condition the manifest uses is exercised by some window here, so no element is
|
||||||
|
permanently skipped;
|
||||||
|
* the transport group the manifest declares is the behaviour of a *live* session: a running
|
||||||
|
session keeps the tracker reset available and reads "pause", and a paused session reads
|
||||||
|
"resume". That is the defect class this artifact exists to prevent — a state assertion
|
||||||
|
about widgets passing while the visible result is wrong.
|
||||||
|
|
||||||
|
A deliberate asymmetry: the existence assertions are hard (a missing element fails), while
|
||||||
|
the drift report is compared with a record. The manifest is expected to lag the code, and the
|
||||||
|
point of the report is to *show* the difference rather than to pretend it does not exist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import ui_check
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_studio.ui import elements
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from tests.conftest import finish_run, show_window
|
||||||
|
|
||||||
|
VIEWS = ("live", "replay", "calibration")
|
||||||
|
SIZE = (1440, 880)
|
||||||
|
#: The four windows the conditional elements need. ``box`` needs nothing; the others are the
|
||||||
|
#: states a condition names (see ``elements.CONDITIONS``).
|
||||||
|
SCENARIOS = {
|
||||||
|
"box": {},
|
||||||
|
"finger": {"sensor_type": "finger"},
|
||||||
|
"video": {"sensor_type": "box", "source_mode": "video"},
|
||||||
|
"dialog": {"sensor_type": "box"},
|
||||||
|
}
|
||||||
|
#: The condition each scenario must satisfy, so a scenario cannot pass by satisfying nothing.
|
||||||
|
SCENARIO_CONDITIONS = {
|
||||||
|
"box": set(),
|
||||||
|
"finger": {"mesh3d_view", "mesh3d_panel"},
|
||||||
|
"video": {"video_source"},
|
||||||
|
"dialog": {"sensor_dialog_open"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare(scenario: str, window) -> None:
|
||||||
|
"""Put a window into the state its scenario names."""
|
||||||
|
if scenario == "video":
|
||||||
|
window.toolbar.set_video_path("C:/tmp/ui-elements-demo.mp4")
|
||||||
|
window._set_source_mode("video")
|
||||||
|
elif scenario == "dialog":
|
||||||
|
window._open_sensor_dialog()
|
||||||
|
QApplication.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _window(make_window, scenario: str, view: str):
|
||||||
|
window = show_window(make_window(**SCENARIOS[scenario]), SIZE)
|
||||||
|
window.layout_ctl.apply_preset(view)
|
||||||
|
_prepare(scenario, window)
|
||||||
|
return window
|
||||||
|
|
||||||
|
|
||||||
|
def _running_session(make_window):
|
||||||
|
"""A window with a live session that has actually started.
|
||||||
|
|
||||||
|
``conftest.running_window`` states the phase directly, which skips the first-frame
|
||||||
|
transition (and with it the rail update) that this test is about; this is the same setup
|
||||||
|
without the shortcut, so the transport and the reset reach their states through the
|
||||||
|
application's own path.
|
||||||
|
"""
|
||||||
|
window = show_window(make_window(sensor_type="box", source_mode="camera"), SIZE)
|
||||||
|
stuck = ui_check._StuckWorker()
|
||||||
|
window._ensure_workers = lambda: None
|
||||||
|
window._box_worker = stuck
|
||||||
|
window._finger_worker = stuck
|
||||||
|
window._start_run()
|
||||||
|
window._run_timer.stop()
|
||||||
|
stuck.emit(ui_check._fake_packet(1))
|
||||||
|
window._on_run_tick()
|
||||||
|
return window, stuck
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- the spec itself
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_manifest_itself_is_sound():
|
||||||
|
"""A spec with a duplicated id, a dangling pane or an unknown condition is not a spec."""
|
||||||
|
manifest = elements.manifest()
|
||||||
|
assert manifest.version == 1, "the manifest states a schema version"
|
||||||
|
assert manifest.scope == "apps/studio"
|
||||||
|
assert manifest.path.is_file(), manifest.path
|
||||||
|
assert manifest.problems() == [], manifest.problems()
|
||||||
|
assert manifest.regions(), "no regions declared"
|
||||||
|
assert manifest.panes, "no panes declared"
|
||||||
|
assert manifest.style_tokens(), (
|
||||||
|
"the stylesheet vocabulary is what the audit subtracts"
|
||||||
|
)
|
||||||
|
for view in VIEWS:
|
||||||
|
assert manifest.regions(view), f"{view} has no regions"
|
||||||
|
assert manifest.panes_for(view), f"{view} has no panes"
|
||||||
|
assert manifest.elements_for(view), f"{view} has no elements"
|
||||||
|
assert manifest.view_spec(view).purpose, f"{view} has no stated purpose"
|
||||||
|
by_name = manifest.by_object_name()
|
||||||
|
assert by_name, "no element records a stable objectName"
|
||||||
|
assert all(items for items in by_name.values())
|
||||||
|
assert len(manifest.by_id()) == len(manifest.all_elements())
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_declared_condition_is_used_and_reaches_a_window(make_window):
|
||||||
|
"""Conditions are the escape hatch for lazily-built elements, so prove each one opens.
|
||||||
|
|
||||||
|
A manifest with a condition no window ever satisfies is a manifest that quietly checks
|
||||||
|
less than it appears to, and a condition the loader declares but the manifest never uses
|
||||||
|
is vocabulary that can rot.
|
||||||
|
"""
|
||||||
|
manifest = elements.manifest()
|
||||||
|
used = {
|
||||||
|
element.condition for element in manifest.all_elements() if element.condition
|
||||||
|
}
|
||||||
|
assert used <= set(elements.CONDITIONS), "undeclared condition in the manifest"
|
||||||
|
assert used == set(elements.CONDITIONS), (
|
||||||
|
f"unused conditions in the loader: {sorted(set(elements.CONDITIONS) - used)}"
|
||||||
|
)
|
||||||
|
exercised: set[str] = set()
|
||||||
|
for scenario, expected in SCENARIO_CONDITIONS.items():
|
||||||
|
window = _window(make_window, scenario, "live")
|
||||||
|
result = elements.validate(window, "live")
|
||||||
|
assert result.ok, f"{scenario}:\n{result.summary()}"
|
||||||
|
held = {
|
||||||
|
name
|
||||||
|
for name in elements.CONDITIONS
|
||||||
|
if elements.condition_holds(window, name)
|
||||||
|
}
|
||||||
|
assert held == expected, f"{scenario}: satisfied {sorted(held)}"
|
||||||
|
exercised |= held
|
||||||
|
window.close()
|
||||||
|
window.deleteLater()
|
||||||
|
assert exercised == set(elements.CONDITIONS), (
|
||||||
|
f"no window satisfies {sorted(set(elements.CONDITIONS) - exercised)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- the elements themselves
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("view", VIEWS)
|
||||||
|
def test_every_element_of_the_view_exists_is_reachable_and_is_the_recorded_class(
|
||||||
|
make_window, view
|
||||||
|
):
|
||||||
|
window = _window(make_window, "box", view)
|
||||||
|
try:
|
||||||
|
absent = elements.missing(window, view)
|
||||||
|
assert absent == [], [element.key for element in absent]
|
||||||
|
assert elements.wrong_class(window, view) == []
|
||||||
|
result = elements.validate(window, view)
|
||||||
|
assert result.wrong_attrs == (), result.summary()
|
||||||
|
assert result.pane_visibility == (), result.summary()
|
||||||
|
# Nothing is silently skipped: the checked and the skipped elements are the manifest's
|
||||||
|
assert result.checked + len(result.skipped) == len(
|
||||||
|
elements.manifest().elements_for(view)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("view", VIEWS)
|
||||||
|
def test_the_named_widget_audit_matches_the_manifest(make_window, view):
|
||||||
|
"""The drift report is compared with the recorded drift, in both directions.
|
||||||
|
|
||||||
|
A new named widget changes the report and fails here until the manifest accounts for it
|
||||||
|
(as an element, or as a declared stylesheet token); a recorded name that disappears from
|
||||||
|
the window fails too. The report is deliberately not part of the existence test above.
|
||||||
|
"""
|
||||||
|
window = _window(make_window, "box", view)
|
||||||
|
try:
|
||||||
|
manifest = elements.manifest()
|
||||||
|
on_screen = elements.visible_named_widgets(window)
|
||||||
|
assert on_screen, "the audit would be vacuous: no visible named widget at all"
|
||||||
|
audit = elements.unlisted_named_widgets(window, view)
|
||||||
|
recorded = list(manifest.view_spec(view).unlisted_named_widgets)
|
||||||
|
assert audit == recorded, (
|
||||||
|
f"{view}: named widgets the manifest does not account for: "
|
||||||
|
f"new={sorted(set(audit) - set(recorded))}, "
|
||||||
|
f"recorded but gone={sorted(set(recorded) - set(audit))}"
|
||||||
|
)
|
||||||
|
result = elements.validate(window, view)
|
||||||
|
assert result.new_unlisted == () and result.stale_unlisted == ()
|
||||||
|
assert result.drift is False
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- the views' panes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("view", VIEWS)
|
||||||
|
def test_the_panes_on_screen_are_the_views_own(make_window, view):
|
||||||
|
"""The view entry's primary/companion lists are the arrangement *and* what is visible."""
|
||||||
|
window = _window(make_window, "finger", view)
|
||||||
|
try:
|
||||||
|
spec = elements.manifest().view_spec(view)
|
||||||
|
visible = {
|
||||||
|
pane.id
|
||||||
|
for pane in elements.manifest().panes
|
||||||
|
if (widget := elements.resolve(window, pane.element)) is not None
|
||||||
|
and widget.isVisible()
|
||||||
|
}
|
||||||
|
assert visible == set(spec.panes), (
|
||||||
|
f"{view}: on screen {sorted(visible)} != declared {sorted(spec.panes)}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- the transport contract
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_transport_group_matches_the_live_session_states(make_window):
|
||||||
|
"""The defect class: a view switch silently taking the session's reset away.
|
||||||
|
|
||||||
|
The manifest declares the rail's transport group (``transport``, ``stop``, ``reset``,
|
||||||
|
``record``); this drives the real session and reads those elements back, so the states
|
||||||
|
the group was fixed to produce are the states the manifest names.
|
||||||
|
"""
|
||||||
|
manifest = elements.manifest()
|
||||||
|
by_id = manifest.by_id()
|
||||||
|
group = ("transport", "stop", "reset", "record")
|
||||||
|
assert set(group) <= set(by_id), (
|
||||||
|
"the manifest no longer declares the transport group"
|
||||||
|
)
|
||||||
|
assert {by_id[name].group for name in group} == {"rail"}, (
|
||||||
|
"the transport group left the rail"
|
||||||
|
)
|
||||||
|
|
||||||
|
window, stuck = _running_session(make_window)
|
||||||
|
try:
|
||||||
|
transport = elements.resolve(window, by_id["transport"])
|
||||||
|
reset = elements.resolve(window, by_id["reset"])
|
||||||
|
stop = elements.resolve(window, by_id["stop"])
|
||||||
|
assert transport is window.toolbar.btn_run
|
||||||
|
assert reset is window.toolbar.btn_reset
|
||||||
|
assert window._session_phase == "running", window._session_phase
|
||||||
|
|
||||||
|
# A running session: the transport reads pause and the reset is available.
|
||||||
|
assert transport.isChecked(), (
|
||||||
|
"a running session must show the transport engaged"
|
||||||
|
)
|
||||||
|
assert transport.text() == t("toolbar.pause"), transport.text()
|
||||||
|
assert reset.isEnabled(), "a running session must offer the tracker reset"
|
||||||
|
assert stop.isEnabled(), "a running session must offer stop"
|
||||||
|
|
||||||
|
# Paused: the transport reads resume, and the device action stays available.
|
||||||
|
transport.setChecked(False)
|
||||||
|
assert window._display_paused is True, "unchecking the transport did not pause"
|
||||||
|
assert transport.text() == t("toolbar.resume_label"), transport.text()
|
||||||
|
assert reset.isEnabled(), (
|
||||||
|
"pausing took the tracker reset away: the device is still open"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The view switch that used to disable it: the session is unchanged throughout.
|
||||||
|
window.brand_bar.btn_calibration.click()
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert window.layout_ctl.view_preset == "calibration"
|
||||||
|
assert reset.isEnabled(), "the calibration view disabled a live session's reset"
|
||||||
|
assert transport.text() == t("toolbar.resume_label"), (
|
||||||
|
"the view switch changed what the transport says about the session"
|
||||||
|
)
|
||||||
|
window.brand_bar.btn_live.click()
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert reset.isEnabled(), "the return to the live view lost the session's reset"
|
||||||
|
|
||||||
|
# Resuming: back to the acquiring state.
|
||||||
|
transport.setChecked(True)
|
||||||
|
assert window._display_paused is False
|
||||||
|
assert transport.text() == t("toolbar.pause"), transport.text()
|
||||||
|
finally:
|
||||||
|
finish_run(window, stuck)
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_transport_of_the_replay_view_is_not_the_device_reset(make_window):
|
||||||
|
"""The same controls, the other view: an idle transport and no device to zero."""
|
||||||
|
window = _window(make_window, "box", "replay")
|
||||||
|
try:
|
||||||
|
by_id = elements.manifest().by_id()
|
||||||
|
transport = elements.resolve(window, by_id["transport"])
|
||||||
|
reset = elements.resolve(window, by_id["reset"])
|
||||||
|
assert not transport.isChecked()
|
||||||
|
assert transport.text() == t("toolbar.playback"), transport.text()
|
||||||
|
assert not reset.isEnabled(), (
|
||||||
|
"reset is offered where there is no tracker to zero"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Draft a widget inventory of the Studio window, per view, as JSON.
|
||||||
|
|
||||||
|
**The manifest is authored and reviewed, not generated.** ``docs/ui-elements.json`` is a
|
||||||
|
spec: it states which elements the window is *supposed* to have, so that a test can fail
|
||||||
|
when the code and the spec disagree. A generated file cannot do that — regenerating it
|
||||||
|
would silently accept whatever the code happens to contain, which is exactly the drift the
|
||||||
|
manifest exists to catch. This tool is therefore a **drafting aid**: run it to see the real
|
||||||
|
tree, then curate the output by hand into the manifest (choose stable ids, drop style
|
||||||
|
plumbing, write the one-line purpose, and record what is deliberately not modelled). Never
|
||||||
|
copy its output over the manifest.
|
||||||
|
|
||||||
|
Two further consequences of the same rule, so the tool is not misread as a verifier:
|
||||||
|
|
||||||
|
* it reports what *is* in the tree, including widgets the manifest deliberately leaves out
|
||||||
|
(styles such as ``contentPanel``, decorative labels, the settings dialog's copy);
|
||||||
|
* the ``visible_named_widgets`` list it prints is the raw vocabulary to account for — every
|
||||||
|
name on screen in that view. A name listed there is not a defect: the manifest accounts for
|
||||||
|
it either as an element identity or as a declared stylesheet token, and what is left over is
|
||||||
|
the ``views.<view>.unlisted_named_widgets`` drift the test compares.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python tools/ui_elements_dump.py # every view, box sensor
|
||||||
|
python tools/ui_elements_dump.py --sensor finger # the 3D pane exists here
|
||||||
|
python tools/ui_elements_dump.py --view live --out draft.json
|
||||||
|
|
||||||
|
The layout of one view's output matches the manifest: a list of element records
|
||||||
|
(``id``/``objectName``/``class``/``path``/``parent``/``visible``/``purpose``) plus the
|
||||||
|
``visible_named_widgets`` list for that view.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
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")
|
||||||
|
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
|
||||||
|
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)
|
||||||
|
|
||||||
|
#: How deep the attribute walk follows containers. ``mesh3d_panel.view.panel.preset_combo``
|
||||||
|
#: is depth 4, so four levels plus one is enough; a deeper tree is a signal to add a path
|
||||||
|
#: by hand rather than to raise this silently.
|
||||||
|
MAX_DEPTH = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _is_object(value) -> bool:
|
||||||
|
from PySide6.QtCore import QObject
|
||||||
|
|
||||||
|
return isinstance(value, QObject)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_widget(value) -> bool:
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
return isinstance(value, QWidget)
|
||||||
|
|
||||||
|
|
||||||
|
def attribute_paths(root) -> dict[int, str]:
|
||||||
|
"""Map ``id(obj)`` to a dotted attribute path from ``root``.
|
||||||
|
|
||||||
|
Only the *first* path found for an object is kept, shortest first, because a widget
|
||||||
|
reachable two ways (``window.status_chip`` and ``window.toolbar.status_chip``) needs one
|
||||||
|
canonical name and the shorter one is the better spec. Qt's own getters are seeded by
|
||||||
|
hand: ``menuBar()`` and ``statusBar()`` are not instance attributes.
|
||||||
|
"""
|
||||||
|
from PySide6.QtWidgets import QMenuBar, QStatusBar, QWidget
|
||||||
|
|
||||||
|
paths: dict[int, str] = {id(root): ""}
|
||||||
|
|
||||||
|
def record(value, path: str) -> bool:
|
||||||
|
known = paths.get(id(value))
|
||||||
|
if known is not None and len(known) <= len(path):
|
||||||
|
return False
|
||||||
|
paths[id(value)] = path
|
||||||
|
return True
|
||||||
|
|
||||||
|
def walk(node, prefix: str, depth: int) -> None:
|
||||||
|
if depth > MAX_DEPTH:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
attributes = list(vars(node).items())
|
||||||
|
except TypeError:
|
||||||
|
return
|
||||||
|
for name, value in attributes:
|
||||||
|
if name.startswith("__"):
|
||||||
|
continue
|
||||||
|
child_path = f"{prefix}.{name}" if prefix else name
|
||||||
|
if _is_object(value):
|
||||||
|
if getattr(value, "parent", None) is None and not _is_widget(value):
|
||||||
|
# A controller, a timer or a signal holder: not part of the tree.
|
||||||
|
continue
|
||||||
|
if record(value, child_path):
|
||||||
|
walk(value, child_path, depth + 1)
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
for index, item in enumerate(value):
|
||||||
|
if not _is_object(item):
|
||||||
|
continue
|
||||||
|
item_path = f"{child_path}[{index}]"
|
||||||
|
if record(item, item_path):
|
||||||
|
walk(item, item_path, depth + 1)
|
||||||
|
|
||||||
|
record(root, "")
|
||||||
|
walk(root, "", 0)
|
||||||
|
# The two Qt getters that are not attributes: seeded so their children get real paths.
|
||||||
|
for getter, name in ((root.menuBar, "menuBar()"), (root.statusBar, "statusBar()")):
|
||||||
|
try:
|
||||||
|
child = getter()
|
||||||
|
except Exception: # noqa: BLE001 - a plain QWidget has neither of them
|
||||||
|
child = None
|
||||||
|
if child is None:
|
||||||
|
continue
|
||||||
|
if record(child, name):
|
||||||
|
walk(child, name, 1)
|
||||||
|
# Any other widget Qt owns that the walk did not reach, so the dump is complete. The
|
||||||
|
# fallback is recorded only where no attribute path was found: an empty path is the
|
||||||
|
# shortest of all and would otherwise overwrite every real one.
|
||||||
|
for child in root.findChildren(QWidget):
|
||||||
|
paths.setdefault(id(child), "")
|
||||||
|
for child in root.findChildren(QMenuBar) + root.findChildren(QStatusBar):
|
||||||
|
paths.setdefault(id(child), "")
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_path(widget, paths: dict[int, str]) -> str:
|
||||||
|
node = widget.parent() if hasattr(widget, "parent") else None
|
||||||
|
while node is not None:
|
||||||
|
path = paths.get(id(node))
|
||||||
|
if path is not None:
|
||||||
|
return path
|
||||||
|
node = node.parent() if hasattr(node, "parent") else None
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def dump_view(window, view: str) -> dict:
|
||||||
|
"""One view's element records, in the manifest's shape."""
|
||||||
|
from orisys_studio.ui import elements as elements_mod
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
window.layout_ctl.apply_preset(view)
|
||||||
|
for _ in range(3):
|
||||||
|
QApplication.processEvents()
|
||||||
|
|
||||||
|
paths = attribute_paths(window)
|
||||||
|
records = []
|
||||||
|
for widget in elements_mod.descendants(window):
|
||||||
|
path = paths.get(id(widget), "")
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"path": path,
|
||||||
|
"objectName": widget.objectName() or None,
|
||||||
|
"class": type(widget).__name__,
|
||||||
|
"parent": _parent_path(widget, paths),
|
||||||
|
"visible": bool(widget.isVisible()),
|
||||||
|
"purpose": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
records.sort(key=lambda item: (item["path"], item["class"]))
|
||||||
|
return {
|
||||||
|
"view": view,
|
||||||
|
"preset": window.layout_ctl.view_preset,
|
||||||
|
"elements": records,
|
||||||
|
"visible_named_widgets": sorted(elements_mod.visible_named_widgets(window)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument(
|
||||||
|
"--view",
|
||||||
|
action="append",
|
||||||
|
default=None,
|
||||||
|
help="view to dump (repeatable; default: all three)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--sensor", default="box", help="sensor type for the window")
|
||||||
|
parser.add_argument("--theme", default="dark", help="theme for the window")
|
||||||
|
parser.add_argument("--out", default=None, help="write here instead of stdout")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
from orisys_studio.app_window import StudioMainWindow
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
window = StudioMainWindow(
|
||||||
|
prefs={
|
||||||
|
"language": "zh_CN",
|
||||||
|
"theme": args.theme,
|
||||||
|
"sensor_type": args.sensor,
|
||||||
|
"source_mode": "camera",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
window.resize(1440, 880)
|
||||||
|
window.show()
|
||||||
|
app.processEvents()
|
||||||
|
try:
|
||||||
|
views = args.view or ["live", "replay", "calibration"]
|
||||||
|
payload = {
|
||||||
|
"window": {
|
||||||
|
"id": "main_window",
|
||||||
|
"class": type(window).__name__,
|
||||||
|
"objectName": window.objectName() or None,
|
||||||
|
},
|
||||||
|
"sensor": args.sensor,
|
||||||
|
"theme": args.theme,
|
||||||
|
"views": {view: dump_view(window, view) for view in views},
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
text = json.dumps(payload, indent=2, ensure_ascii=False)
|
||||||
|
if args.out:
|
||||||
|
Path(args.out).write_text(text + "\n", encoding="utf-8")
|
||||||
|
print(f"wrote {args.out}")
|
||||||
|
else:
|
||||||
|
print(text)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user