diff --git a/apps/autocal/README.md b/apps/autocal/README.md index 1fd465f..0063daa 100644 --- a/apps/autocal/README.md +++ b/apps/autocal/README.md @@ -157,13 +157,22 @@ between threads. The UI talks to it through signals only. Both work with no display, no controller and no DLL: ```powershell -python .\apps\autocal\tools\panel_check.py # 170 layout/theme/locale/gating checks +python .\apps\autocal\tools\panel_check.py # layout/theme/locale/gating checks python .\apps\autocal\tools\panel_snapshot.py --demo --theme dark --out shot.png python .\apps\autocal\tools\panel_snapshot.py --compare --out tabs.png # one image per tab +python .\apps\autocal\tools\panel_snapshot.py --setup-dialog bus --out setup.png python .\apps\autocal\tools\layout_mockup.py --out layout.png # proposed layout, drawn python -m pytest apps\autocal\tests # offscreen suite ``` +Connection and bus initialisation are used once per machine start-up, so they live +in a **modeless device-setup dialog** (`ui/device_setup.py`) rather than the rail: +the rail carries what is read while working — the axis readout and the collapsed +SDO/RTEX monitor. The dialog opens from the status strip's 设备设置 button, from the +connection/bus/init chips (each opens the tab that explains it) or from +文件 → 设备设置, and the strip keeps reporting the live state whether it is open or +shut. + `preview_snapshot.py` is the one tool that can photograph the **3D pane**, which `panel_snapshot.py` cannot: the offscreen platform has no OpenGL context at all, so the pane is empty in those shots. It needs the real platform plugin and a visible @@ -175,7 +184,8 @@ composited in. What the view draws, and what each colour means, is in proposed single-page layout with the shared theme tokens and imports nothing from the app, so the layout can be reviewed and marked up without building the panels. It draws the full page plus previews of the other workspace tabs in one -canvas. +canvas, and `--page setup` draws the device-setup dialog over the page it opens +from. `panel_check.py` builds the real window in both themes at 1440×900 and 1920×1080, asserts that no widget escapes its parent, that motion is diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index bc26dd9..27e0bfc 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -11,6 +11,7 @@ }, "menu": { "file": "File", + "device_setup": "Device setup…", "open_bas": "Download BAS init program…", "reveal_config": "Show config in Explorer…", "quit": "Quit", @@ -27,6 +28,15 @@ "help": "Help", "about": "About AutoCal" }, + "setup": { + "open": "Device setup", + "title": "Device setup", + "hint": "Connecting and bringing the bus up are checked once per machine start-up, so they live here. The status strip keeps reporting the result, and this window can stay open while you work.", + "tab_connection": "Connection", + "tab_bus": "Bus initialisation", + "close": "Close", + "chip_hint": "Open device setup — {name}" + }, "connection": { "title": "Controller connection", "mode": "Connection", diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index b6a423b..5df3e6b 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -11,6 +11,7 @@ }, "menu": { "file": "文件", + "device_setup": "设备设置…", "open_bas": "下载 BAS 初始化程序…", "reveal_config": "在资源管理器中显示配置…", "quit": "退出", @@ -27,6 +28,15 @@ "help": "帮助", "about": "关于 AutoCal" }, + "setup": { + "open": "设备设置", + "title": "设备设置", + "hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。", + "tab_connection": "连接", + "tab_bus": "总线初始化", + "close": "关闭", + "chip_hint": "点击打开设备设置 — {name}" + }, "connection": { "title": "连接控制器", "mode": "连接方式", diff --git a/apps/autocal/docs/workflow.md b/apps/autocal/docs/workflow.md index d86c5c8..ef5bd0c 100644 --- a/apps/autocal/docs/workflow.md +++ b/apps/autocal/docs/workflow.md @@ -67,6 +67,11 @@ or timeout → read `BUS_TYPE`, `Bus_TotalAxisnum`, and the node count. Step 2 i conditional because a controller usually still has the program in ROM from the last session, and a download is a bus re-scan the operator did not ask for. +Both controls live in the **device setup dialog** (设备设置), opened from the status +strip's button, its connection/bus/init chips, or 文件 → 设备设置: they are used once +per machine start-up, so the rail keeps only what is read while working. The strip +continues to report connection, bus and init state while the dialog is open or shut. + **Re-initialising without a download:** `STOPTASK ` then `RUN "",`, on the task from `bas_task` in `preference.json` (default 4 — the task the vendor reference uses; stopping another task restarts diff --git a/apps/autocal/src/orisys_autocal/ui/brand_bar.py b/apps/autocal/src/orisys_autocal/ui/brand_bar.py index d79ac56..ede14df 100644 --- a/apps/autocal/src/orisys_autocal/ui/brand_bar.py +++ b/apps/autocal/src/orisys_autocal/ui/brand_bar.py @@ -7,7 +7,7 @@ controller is reachable. from __future__ import annotations -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QPixmap from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget @@ -27,6 +27,11 @@ _TONE_TOKENS = { class BrandBar(QFrame): """The identity strip, with a state chip that reports the connection.""" + #: The connection chip was clicked. It is the most prominent statement of the + #: connection state, so it is also the obvious way to go and change it — the + #: controls themselves live in the device-setup dialog. + connectionChipClicked = Signal() + def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None: super().__init__(parent) self.setObjectName("brandBar") @@ -67,10 +72,21 @@ class BrandBar(QFrame): self.state_chip = QLabel(self) self.state_chip.setObjectName("stateChip") + self.state_chip.setCursor(Qt.CursorShape.PointingHandCursor) layout.addWidget(self.state_chip) self.retranslate() + def mouseReleaseEvent(self, event) -> None: # noqa: N802 - Qt naming + """A click anywhere on the chip opens the device setup. + + On the bar rather than on the label: a QLabel only receives a mouse event + where its own text is, so clicking the chip's padding would do nothing. + """ + if event.button() == Qt.MouseButton.LeftButton and self.state_chip.underMouse(): + self.connectionChipClicked.emit() + super().mouseReleaseEvent(event) + # ----------------------------------------------------------------- content def retranslate(self) -> None: @@ -78,6 +94,7 @@ class BrandBar(QFrame): self.name.setText(t("brand.name")) self.detail.setText(t("brand.detail")) self.product.setText(t("app.title")) + self.state_chip.setToolTip(t("setup.chip_hint", name=t("connection.title"))) self.set_connected(self._connected) def set_theme(self, theme: str) -> None: diff --git a/apps/autocal/src/orisys_autocal/ui/device_setup.py b/apps/autocal/src/orisys_autocal/ui/device_setup.py new file mode 100644 index 0000000..6a81cbc --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/device_setup.py @@ -0,0 +1,157 @@ +"""The device setup: connection and bus initialisation, behind one button. + +Both groups are touched once per machine start-up — reach the controller, download +the bus program, watch the bus come up — and then not again for the rest of the +session. In the left rail they were spending ~460 px of its height on that, above +the axis readout that is read all day, which is what made the rail scroll. + +They live in a **modeless dialog** rather than a modal one on purpose: connecting and +downloading are asynchronous (the poller runs the state machine, the status strip +reports it), and an operator watching a bus scan go wrong wants to see the strip and +the axis readout while it happens. Modeless also means the dialog can be opened from +several places — the strip's button, its state chips, the File menu — without any of +them having to block. + +The dialog owns the two panels; the window keeps a reference to each +(``window.connection_panel`` / ``window.bus_panel``) because it is the window that +wires them to the controller, and because the tools and checks reach for them there. +""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QLabel, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from orisys_app_shared.i18n import t + +from ..motion import ConnectionSpec +from .bus_panel import BusPanel +from .connection_panel import ConnectionPanel + +#: Tab identity -> locale key, in the order they are shown. The order is the work: +#: reach the machine, then bring its bus up. +TABS: tuple[tuple[str, str], ...] = ( + ("connection", "setup.tab_connection"), + ("bus", "setup.tab_bus"), +) + + +class DeviceSetupDialog(QDialog): + """Connection and bus initialisation, the once-per-start controls.""" + + def __init__( + self, + spec: ConnectionSpec, + bas_path: str = "", + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("deviceSetup") + # Modeless: see the module docstring. ``show()`` raises it when already open. + self.setModal(False) + self.setSizeGripEnabled(True) + + column = QVBoxLayout(self) + column.setContentsMargins(10, 10, 10, 10) + column.setSpacing(8) + + self.hint_label = QLabel(self) + self.hint_label.setObjectName("hintLabel") + self.hint_label.setWordWrap(True) + column.addWidget(self.hint_label) + + self.tabs = QTabWidget(self) + self.tabs.setObjectName("setupTabs") + self.connection_panel = ConnectionPanel(spec, self.tabs) + self.bus_panel = BusPanel(bas_path, self.tabs) + for key, locale_key in TABS: + self.tabs.addTab(self.panel(key), "") + column.addWidget(self.tabs, 1) + + self.buttons = QDialogButtonBox(self) + self.close_button = self.buttons.addButton( + "", QDialogButtonBox.ButtonRole.RejectRole + ) + self.close_button.clicked.connect(self.close) + column.addWidget(self.buttons) + + self.retranslate() + # Sized to the taller tab (bus initialisation: the file row, four readouts and + # two buttons), so the dialog opens without a band of empty panel under the + # controls. It is resizable, and the size is remembered. + self.resize(520, 470) + + # ----------------------------------------------------------------- content + + def panel(self, key: str) -> QWidget: + """One of the two panels, by tab key.""" + return self.connection_panel if key == "connection" else self.bus_panel + + def open_for(self, tab: str | None = None) -> None: + """Show the dialog, raising it if it is already up, and pick a tab. + + Raising rather than ignoring the second request: the button and the chips all + lead here, and a click that appears to do nothing reads as a broken button. + """ + key = tab if tab in {name for name, _ in TABS} else None + if key is not None: + self.select_tab(key) + self.show() + self.raise_() + self.activateWindow() + panel = self.panel(key) if key is not None else None + if panel is not None: + panel.setFocus() + + def select_tab(self, key: str) -> None: + for index, (name, _locale_key) in enumerate(TABS): + if name == key: + self.tabs.setCurrentIndex(index) + return + + def current_tab(self) -> str: + index = max(0, self.tabs.currentIndex()) + return TABS[index][0] if index < len(TABS) else TABS[0][0] + + # ------------------------------------------------------------- persistence + + def settings(self) -> dict: + """What is worth remembering between sessions: size and last tab. + + Not the connection state: that belongs to the controller, and a dialog that + claimed to remember being connected would be lying after a restart. + """ + size = self.size() + return {"size": [int(size.width()), int(size.height())], "tab": self.current_tab()} + + def apply_settings(self, stored: object) -> None: + if not isinstance(stored, dict): + return + size = stored.get("size") + if isinstance(size, list) and len(size) == 2: + try: + width, height = (int(value) for value in size) + except (TypeError, ValueError): + return + if width > 0 and height > 0: + self.resize(width, height) + tab = stored.get("tab") + if isinstance(tab, str): + self.select_tab(tab) + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.setWindowTitle(t("setup.title")) + self.hint_label.setText(t("setup.hint")) + for index, (_key, locale_key) in enumerate(TABS): + self.tabs.setTabText(index, t(locale_key)) + self.close_button.setText(t("setup.close")) + self.connection_panel.retranslate() + self.bus_panel.retranslate() diff --git a/apps/autocal/src/orisys_autocal/ui/status_strip.py b/apps/autocal/src/orisys_autocal/ui/status_strip.py index 2f9db21..7ee8559 100644 --- a/apps/autocal/src/orisys_autocal/ui/status_strip.py +++ b/apps/autocal/src/orisys_autocal/ui/status_strip.py @@ -20,7 +20,7 @@ from __future__ import annotations import time -from PySide6.QtCore import Signal +from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -66,7 +66,17 @@ _FAILED_INTERVALS = 10.0 class _Chip(QLabel): - """A small toned label: the strip is read at a glance, not studied.""" + """A small toned label: the strip is read at a glance, not studied. + + Clickable, because the state it shows is also the way to change it: the + connection, bus and init controls live in the device-setup dialog, so a chip that + reports "not connected" is the natural place to click. Its *text* stays clean + (no decoration) so the strip still reads as a status row; the pointing-hand + cursor and the tooltip carry the affordance, and the labelled button beside them + is the primary way in. + """ + + clicked = Signal() def __init__(self, theme: str, parent: QWidget | None = None) -> None: super().__init__(parent) @@ -75,6 +85,18 @@ class _Chip(QLabel): self._tone = "muted" self.set_tone("muted") + def mouseReleaseEvent(self, event) -> None: # noqa: N802 - Qt naming + if event.button() == Qt.MouseButton.LeftButton and self.isEnabled(): + self.clicked.emit() + super().mouseReleaseEvent(event) + + def set_clickable(self, clickable: bool, name: str = "") -> None: + """Make the chip open something, or go back to being a plain readout.""" + self.setCursor( + Qt.CursorShape.PointingHandCursor if clickable else Qt.CursorShape.ArrowCursor + ) + self.setToolTip(t("setup.chip_hint", name=name) if clickable else "") + def set_theme(self, theme: str) -> None: self._theme = theme self.set_tone(self._tone) @@ -93,6 +115,9 @@ class StatusStrip(QFrame): pauseRequested = Signal() stopAllRequested = Signal() + #: Open the device setup, optionally on a named tab (``connection`` / ``bus``). + setupRequested = Signal() + chipSetupRequested = Signal(str) def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None: super().__init__(parent) @@ -133,6 +158,24 @@ class StatusStrip(QFrame): self.action_label.setObjectName("stripAction") layout.addWidget(self.action_label) + # The three state chips double as the way into the device setup: each opens + # the tab that explains it. Wired here rather than by the window so the pairing + # lives with the chips it belongs to. + for chip, tab in ( + (self.chip_connection, "connection"), + (self.chip_bus, "bus"), + (self.chip_init, "bus"), + ): + chip.set_clickable(True) + chip.clicked.connect(lambda tab=tab: self.chipSetupRequested.emit(tab)) + + # Before the run-screen buttons, because it is the one control here that is + # about the machine rather than about the job. + self.setup_button = QPushButton(self) + self.setup_button.setProperty("rail", True) + self.setup_button.clicked.connect(self.setupRequested) + layout.addWidget(self.setup_button) + self.pause_button = QPushButton(self) self.pause_button.setProperty("rail", True) self.pause_button.setCheckable(True) @@ -268,8 +311,15 @@ class StatusStrip(QFrame): def retranslate(self) -> None: self.title.setText(t("status.motion")) + self.setup_button.setText(t("setup.open")) self._refresh_pause_text() self.stop_button.setText(t("axes.stop_all")) + for chip, name in ( + (self.chip_connection, t("connection.title")), + (self.chip_bus, t("bus.title")), + (self.chip_init, t("bus.title")), + ): + chip.set_clickable(True, name) self.set_snapshot(self._snapshot) self.refresh_health() diff --git a/apps/autocal/src/orisys_autocal/ui/window.py b/apps/autocal/src/orisys_autocal/ui/window.py index be9a6ab..a5bfd50 100644 --- a/apps/autocal/src/orisys_autocal/ui/window.py +++ b/apps/autocal/src/orisys_autocal/ui/window.py @@ -56,8 +56,8 @@ from .axis_panel import AxisPanel from .axis_readout import AxisReadoutPanel from .brand_bar import BrandBar from .bus_data_panel import BusDataPanel -from .bus_panel import BusPanel, default_bas_dir -from .connection_panel import ConnectionPanel +from .bus_panel import default_bas_dir +from .device_setup import DeviceSetupDialog from .sensor_panel import FingerPanel from .sequence_panel import SequencePanel from .status_strip import RunBar, StatusStrip @@ -73,7 +73,11 @@ logger = logging.getLogger(__name__) #: gives the workspace that 240 px back at every window size, which is the #: difference between a workable sequence page and a cramped one at the minimum #: window width. -MAIN_SPLITTER_SIZES = (330, 1560) +#: +#: Narrower than it was (330): the rail now holds the axis readout and the collapsed +#: bus-data group only — connection and bus initialisation moved into the device +#: setup dialog — so the workspace gets the difference. +MAIN_SPLITTER_SIZES = (300, 1590) def _triple(value: object, length: int) -> tuple[float, ...]: @@ -138,6 +142,9 @@ class AutoCalMainWindow(QMainWindow): self._build_poller() self._restore_geometry() + # Its size and last tab, from the previous session. Applied here rather than + # in ``_build_ui`` because it is stored state, like the window geometry. + self.device_setup.apply_settings(self.prefs.get("device_setup")) self.retranslate() # Load the remembered bundle, so the finger debugger comes up working. @@ -174,31 +181,39 @@ class AutoCalMainWindow(QMainWindow): layout.addWidget(body, stretch=1) # ----------------------------------------------------------------- rail - # One rail, not two. It holds setup, the axis readout and the collapsed - # bus data, and it scrolls: at 900 px high those three groups do not fit, - # and a rail that clips its own controls is worse than one that scrolls. + # One rail, not two, and it holds only what is read while working: the axis + # readout and the collapsed bus-data monitor. Connection and bus + # initialisation moved into the device-setup dialog — they are touched once + # per machine start-up and were pushing the readout below the fold. The rail + # still scrolls, because a rail that clips its own controls is worse than one + # that scrolls. self.rail_content = QWidget() left = QVBoxLayout(self.rail_content) left.setContentsMargins(0, 0, 0, 0) left.setSpacing(8) connection = self.prefs.get("connection") or {} - self.connection_panel = ConnectionPanel( - ConnectionSpec( - mode=str(connection.get("mode") or "eth"), - ip=str(connection.get("ip") or ""), - com_id=int(connection.get("com_id") or 0), - card_num=int(connection.get("card_num") or 0), - local_string=str(connection.get("local_string") or "0"), - ), - self.rail_content, + self.spec = ConnectionSpec( + mode=str(connection.get("mode") or "eth"), + ip=str(connection.get("ip") or ""), + com_id=int(connection.get("com_id") or 0), + card_num=int(connection.get("card_num") or 0), + local_string=str(connection.get("local_string") or "0"), ) - self.bus_panel = BusPanel(str(self.prefs.get("bas_path") or ""), self.rail_content) + # Connection and bus initialisation live in the device-setup dialog: they are + # touched once per machine start-up, and in the rail they were pushing the + # axis readout below the fold. The dialog is built here and shown on request, + # so the two panels always exist — the window wires them to the controller, + # and the checks and tools reach for them here. + self.device_setup = DeviceSetupDialog( + self.spec, str(self.prefs.get("bas_path") or ""), self + ) + self.connection_panel = self.device_setup.connection_panel + self.bus_panel = self.device_setup.bus_panel + self.axis_readout = AxisReadoutPanel( MotionService().axis_count, self._theme, self.rail_content ) - left.addWidget(self.connection_panel) - left.addWidget(self.bus_panel) left.addWidget(self.axis_readout) # Bus data (SDO/RTEX) is commissioning-only, so it is collapsed by @@ -228,7 +243,7 @@ class AutoCalMainWindow(QMainWindow): self.left_rail.setWidgetResizable(True) self.left_rail.setFrameShape(QScrollArea.Shape.NoFrame) self.left_rail.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.left_rail.setMinimumWidth(300) + self.left_rail.setMinimumWidth(280) # ------------------------------------------------------------ workspace params = { @@ -303,6 +318,13 @@ class AutoCalMainWindow(QMainWindow): self.bus_data_panel.rtexWriteRequested.connect(self._on_rtex_write) self.status_strip.stopAllRequested.connect(self._on_stop_all) self.status_strip.pauseRequested.connect(self._on_pause_polling) + # The three ways into the device setup: the strip's button, the state chips + # (each opening the tab that explains it), and the brand bar's connection + # chip. All of them land on one method, so there is one definition of "show + # the setup". + self.status_strip.setupRequested.connect(self._show_device_setup) + self.status_strip.chipSetupRequested.connect(self._show_device_setup) + self.brand_bar.connectionChipClicked.connect(self._show_device_setup_connection) # The readout rail's own batch buttons, connected once here. They were # connected from inside the finger screen's stack-changed handler, which # meant every edit on that screen added another copy. @@ -318,6 +340,16 @@ class AutoCalMainWindow(QMainWindow): self.finger_panel.pickRequested.connect(self._on_pick_point) self.finger_panel.stackChanged.connect(self._on_finger_stack_changed) + # ----------------------------------------------------------- device setup + + def _show_device_setup(self, tab: str | None = None) -> None: + """Open the connection/bus dialog, optionally on one of its tabs.""" + self.device_setup.open_for(tab) + + def _show_device_setup_connection(self) -> None: + """The brand bar's chip sends no argument; open on the connection tab.""" + self.device_setup.open_for("connection") + # ---------------------------------------------------------- finger debug def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE): @@ -621,6 +653,8 @@ class AutoCalMainWindow(QMainWindow): menu_bar = self.menuBar() self.menu_file = menu_bar.addMenu("") + self.act_device_setup = QAction("", self) + self.act_device_setup.triggered.connect(self._show_device_setup_connection) self.act_download_bas = QAction("", self) self.act_download_bas.triggered.connect(self._on_download_bas) self.act_reveal_config = QAction("", self) @@ -629,6 +663,8 @@ class AutoCalMainWindow(QMainWindow): self.act_reveal_outputs.triggered.connect(self._on_reveal_outputs) self.act_quit = QAction("", self) self.act_quit.triggered.connect(self.close) + self.menu_file.addAction(self.act_device_setup) + self.menu_file.addSeparator() for action in ( self.act_download_bas, self.act_reveal_config, @@ -1039,8 +1075,9 @@ class AutoCalMainWindow(QMainWindow): self.brand_bar.retranslate() self.status_strip.retranslate() self.run_bar.retranslate() - self.connection_panel.retranslate() - self.bus_panel.retranslate() + # One call into the dialog, which retranslates its own title, tabs and both + # panels: they are no longer part of the window's rail. + self.device_setup.retranslate() self.bus_data_group.setTitle(t("busdata.group")) self.bus_data_panel.retranslate() self.axis_panel.retranslate() @@ -1050,6 +1087,7 @@ class AutoCalMainWindow(QMainWindow): self.workspace.retranslate() self.menu_file.setTitle(t("menu.file")) + self.act_device_setup.setText(t("menu.device_setup")) self.act_download_bas.setText(t("menu.open_bas")) self.act_reveal_config.setText(t("menu.reveal_config")) self.act_reveal_outputs.setText(t("menu.outputs")) @@ -1113,6 +1151,10 @@ class AutoCalMainWindow(QMainWindow): ] self.prefs["main_splitter_sizes"] = [int(v) for v in self.main_splitter.sizes()] self.prefs["bas_path"] = self.bus_panel.bas_path + # The setup dialog's size and last tab, but not its content: the connection + # state belongs to the controller, and remembering "connected" across a + # restart would be a claim the app cannot keep. + self.prefs["device_setup"] = self.device_setup.settings() self.prefs["units_per_mm"] = self.axis_panel._units_per_mm self.prefs["axis_params"] = { str(axis): _params_to_dict(params) @@ -1136,7 +1178,10 @@ class AutoCalMainWindow(QMainWindow): self.poller.shutdown() except Exception: # noqa: BLE001 - closing must always finish logger.exception("Shutdown reported a problem") + # The dialog is a separate top-level window: closing the main window must not + # leave it behind, and its settings have to be read before it is destroyed. self._persist_prefs() + self.device_setup.close() event.accept() diff --git a/apps/autocal/tests/test_binding_purity.py b/apps/autocal/tests/test_binding_purity.py index 64309c6..3310b35 100644 --- a/apps/autocal/tests/test_binding_purity.py +++ b/apps/autocal/tests/test_binding_purity.py @@ -68,7 +68,7 @@ _SCOPED_ENUMS = { "QMessageBox": ("StandardButton", "Icon"), "QDialog": ("DialogCode",), "QAbstractItemView": ("SelectionMode", "EditTrigger"), - "QDialogButtonBox": ("StandardButton",), + "QDialogButtonBox": ("StandardButton", "ButtonRole"), "QPainter": ("RenderHint", "CompositionMode"), "QImage": ("Format",), "QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"), diff --git a/apps/autocal/tests/test_device_setup.py b/apps/autocal/tests/test_device_setup.py new file mode 100644 index 0000000..2af69c4 --- /dev/null +++ b/apps/autocal/tests/test_device_setup.py @@ -0,0 +1,188 @@ +"""The device-setup dialog: connection and bus initialisation, out of the rail. + +Both groups are touched once per machine start-up. In the rail they cost ~460 px of +height above the axis readout, which is read all day — so they moved into a modeless +dialog opened from the status strip, its state chips, or the File menu. + +What these tests protect: + +* the rail no longer carries them, and the dialog is not open at start-up; +* every entry point works and lands on the right tab; +* the window's own handlers still receive the panels' signals, from inside the + dialog — the connect and download code paths did not move, only the widgets did; +* the dialog survives a language change and remembers its size and tab. +""" + +from __future__ import annotations + +import pytest + +from orisys_app_shared.i18n import set_locale, t + +from orisys_autocal.motion import ConnectionSpec +from orisys_autocal.ui.device_setup import DeviceSetupDialog + + +def test_the_rail_no_longer_holds_the_startup_panels(make_window): + """The point of the change: the rail is the run screen, not the setup form.""" + window = make_window() + try: + rail = window.rail_content.findChildren(object) + names = {type(child).__name__ for child in rail} + assert "AxisReadoutPanel" in names + assert "ConnectionPanel" not in names + assert "BusPanel" not in names + # And they are in the dialog, once each. (The parent is Qt's internal + # stacked widget, so ask about ancestry rather than the direct parent.) + dialog = window.device_setup + assert window.connection_panel is dialog.connection_panel + assert window.bus_panel is dialog.bus_panel + assert dialog.tabs.isAncestorOf(window.connection_panel) + assert dialog.tabs.isAncestorOf(window.bus_panel) + finally: + window.close() + + +def test_the_dialog_is_hidden_until_it_is_asked_for(make_window): + window = make_window() + try: + assert not window.device_setup.isVisible() + window._show_device_setup() # noqa: SLF001 + assert window.device_setup.isVisible() + # Showing it again raises rather than ignores: a second click that appears + # to do nothing reads as a broken button. + window._show_device_setup("bus") # noqa: SLF001 + assert window.device_setup.isVisible() + assert window.device_setup.current_tab() == "bus" + finally: + window.close() + + +def test_the_strip_button_opens_the_setup(make_window): + window = make_window() + try: + window.status_strip.setup_button.click() + assert window.device_setup.isVisible() + assert window.status_strip.setup_button.text() == t("setup.open") + finally: + window.close() + + +@pytest.mark.parametrize( + "chip, tab", + [("chip_connection", "connection"), ("chip_bus", "bus"), ("chip_init", "bus")], +) +def test_the_state_chips_open_the_tab_they_report(make_window, chip, tab): + """A chip that says "not connected" is the natural place to click.""" + window = make_window() + try: + getattr(window.status_strip, chip).clicked.emit() + assert window.device_setup.isVisible() + assert window.device_setup.current_tab() == tab + finally: + window.close() + + +def test_the_file_menu_opens_the_setup(make_window): + window = make_window() + try: + window.act_device_setup.trigger() + assert window.device_setup.isVisible() + assert window.device_setup.current_tab() == "connection" + assert window.act_device_setup.text() == t("menu.device_setup") + finally: + window.close() + + +def test_connecting_from_the_dialog_reaches_the_window(make_window): + """The widgets moved; the code path did not. + + The connect button lives in the dialog now, so this drives the *button* rather + than emitting the signal directly: it is the signal wiring across the new + parent that could have been lost. + """ + window = make_window() + try: + seen = [] + window.connection_panel.connectRequested.connect(seen.append) + + panel = window.connection_panel + panel.mode_combo.setCurrentIndex(panel.mode_combo.findData("demo")) + window.device_setup.open_for("connection") + panel.open_button.click() + + assert seen, "the connection request never left the dialog" + assert isinstance(seen[0], ConnectionSpec) + assert seen[0].mode == "demo" + finally: + window.close() + + +def test_downloading_the_bas_from_the_dialog_reaches_the_window(make_window): + window = make_window() + try: + # A path *must* be set first: with none, the window opens a modal file + # chooser, which offscreen is a hang rather than a test. + from orisys_autocal.ui.bus_panel import default_bas_dir + + window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas")) + + seen = [] + window.bus_panel.downloadRequested.connect(lambda: seen.append(True)) + window.device_setup.open_for("bus") + window.bus_panel.download_button.click() + assert seen, "the download request never left the dialog" + finally: + window.close() + + +def test_the_dialog_survives_a_language_change(make_window): + window = make_window() + try: + window._set_language("en_US") # noqa: SLF001 + assert window.device_setup.windowTitle() == t("setup.title") + assert window.device_setup.tabs.tabText(1) == t("setup.tab_bus") + assert window.status_strip.setup_button.text() == t("setup.open") + # The panels inside are retranslated too, not just the dialog's own labels. + assert window.connection_panel.open_button.text() == t("connection.open") + window._set_language("zh_CN") # noqa: SLF001 + set_locale("zh_CN") + finally: + window.close() + + +def test_the_dialog_remembers_its_size_and_tab(make_window): + window = make_window() + try: + window.device_setup.select_tab("bus") + window.device_setup.resize(640, 480) + stored = window.device_setup.settings() + assert stored["tab"] == "bus" + assert stored["size"] == [640, 480] + + fresh = DeviceSetupDialog(ConnectionSpec(mode="eth", ip="10.0.0.1")) + try: + fresh.apply_settings(stored) + assert fresh.size().width() == 640 + assert fresh.current_tab() == "bus" + # Anything malformed is ignored, not raised on: this comes from a file + # a human may have edited. + fresh.apply_settings({"size": ["wide", 2], "tab": 7}) + fresh.apply_settings(None) + finally: + fresh.deleteLater() + finally: + window.close() + + +def test_opening_the_setup_does_not_touch_the_connection(make_window): + """Opening a dialog is not a command: it must not connect or disconnect.""" + window = make_window() + try: + window.device_setup.open_for("connection") + assert not window._last_snapshot.connected # noqa: SLF001 + window.device_setup.close() + assert window.connection_panel.close_button.isEnabled() is False + assert window.connection_panel.open_button.isEnabled() is True + finally: + window.close() diff --git a/apps/autocal/tools/layout_mockup.py b/apps/autocal/tools/layout_mockup.py index eaf8fec..db856a8 100644 --- a/apps/autocal/tools/layout_mockup.py +++ b/apps/autocal/tools/layout_mockup.py @@ -63,7 +63,10 @@ BRAND_H = 36 STRIP_H = 44 RUNBAR_H = 40 STATUS_H = 26 -RAIL_LEFT = 330 +#: Matches ``MAIN_SPLITTER_SIZES`` in the window: the rail narrowed when the two +#: start-up panels moved into the device-setup dialog, and the mockup has to show the +#: same proportions or it stops being a preview of anything. +RAIL_LEFT = 300 WORKSPACE_W = PAGE_W - RAIL_LEFT - 3 * MARGIN PREVIEW_W, PREVIEW_H = 640, 340 @@ -110,6 +113,23 @@ STRINGS = { "nodes": "节点数量", "axes_count": "轴数量", "busdata_group": "总线数据 (SDO / RTEX) ▶", + "setup_open": "设备设置", + "setup_title": "设备设置", + "setup_hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。", + "setup_tabs": ["连接", "总线初始化"], + "setup_rows": [ + ["连接方式", "以太网 ▾"], + ["IP", "192.168.0.11"], + ["状态", "已连接"], + ["BAS 文件", "…\\bas\\ECAT.bas"], + ["初始化状态", "初始化成功"], + ["总线类型", "EtherCAT"], + ["节点数量", "5"], + ["轴数量", "5"], + ], + "setup_connect": "连接", + "setup_disconnect": "断开连接", + "setup_close": "关闭", "tabs": ["轴调试", "测试序列", "手指调试", "传感器", "表面分布", "标定点"], "readout_title": "轴状态", "enabled": "已使能", @@ -247,6 +267,23 @@ STRINGS = { "nodes": "Nodes", "axes_count": "Axes", "busdata_group": "Bus data (SDO / RTEX) ▶", + "setup_open": "Device setup", + "setup_title": "Device setup", + "setup_hint": "Connecting and bringing the bus up are checked once per machine start-up, so they live here. The status strip keeps reporting the result.", + "setup_tabs": ["Connection", "Bus initialisation"], + "setup_rows": [ + ["Connection", "Ethernet ▾"], + ["IP", "192.168.0.11"], + ["State", "Connected"], + ["BAS file", "…\\bas\\ECAT.bas"], + ["Init state", "Initialised"], + ["Bus type", "EtherCAT"], + ["Nodes", "5"], + ["Axes", "5"], + ], + "setup_connect": "Connect", + "setup_disconnect": "Disconnect", + "setup_close": "Close", "tabs": ["Axis debug", "Test sequence", "Finger debug", "Sensor", "Surface map", "Calibration points"], "readout_title": "Axis state", "enabled": "Enabled", @@ -543,13 +580,16 @@ def draw_status_strip(painter: QPainter, palette: Palette, s: dict, top: int) -> _text( painter, - QRect(PAGE_W - 600, top, 380, STRIP_H), + QRect(PAGE_W - 600, top, 300, STRIP_H), "上次动作: 已使能轴0 / last action: enable axis 0", palette, size=10, muted=True, align=Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, ) + # The device-setup button, before the run-screen buttons: connection and bus + # initialisation live behind it now instead of in the rail. + _button(painter, QRect(PAGE_W - 316, top + 10, 100, 24), s["setup_open"], palette) _button(painter, QRect(PAGE_W - 210, top + 10, 96, 24), s["pause"], palette) _button(painter, QRect(PAGE_W - 106, top + 10, 92, 24), s["stop_all"], palette, danger=True) @@ -565,53 +605,13 @@ def draw_status_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> N def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None: - conn = QRect(rect.x(), rect.y(), rect.width(), 210) - inner = _panel(painter, conn, palette) - _box_title(painter, conn, s["conn_title"], palette) - y = inner.y() + 6 - _text(painter, QRect(inner.x(), y, 76, 22), s["conn_mode"], palette, size=10, muted=True) - _field(painter, QRect(inner.x() + 78, y, inner.width() - 78, 22), s["conn_mode_value"] + " ▾", palette) - y += 30 - _text(painter, QRect(inner.x(), y, 30, 22), s["conn_ip"], palette, size=10, muted=True) - _field(painter, QRect(inner.x() + 32, y, 140, 22), "192.168.0.11", palette) - _button(painter, QRect(inner.x() + 180, y, 88, 22), s["scan"], palette) - y += 32 - _button(painter, QRect(inner.x(), y, 104, 26), s["connect"], palette, primary=True) - _button(painter, QRect(inner.x() + 112, y, 108, 26), s["disconnect"], palette) - y += 36 - _text(painter, QRect(inner.x(), y, inner.width(), 20), s["connected"], palette, size=10, muted=True) - y += 24 - _text(painter, QRect(inner.x(), y, inner.width(), 20), "demo (simulated platform)", palette, size=9, muted=True) + """The rail as it is now: only what is read while working. - bus = QRect(rect.x(), rect.y() + 222, rect.width(), 250) - inner = _panel(painter, bus, palette) - _box_title(painter, bus, s["bus_title"], palette) - y = inner.y() + 6 - _text(painter, QRect(inner.x(), y, 60, 22), s["bus_file"], palette, size=10, muted=True) - _field(painter, QRect(inner.x() + 62, y, 140, 22), "…\\bas\\ECAT.bas", palette) - _button(painter, QRect(inner.x() + 208, y, 66, 22), s["choose"], palette) - y += 30 - _text(painter, QRect(inner.x(), y, 74, 20), s["bus_state"], palette, size=10, muted=True) - _text(painter, QRect(inner.x() + 76, y, 180, 20), s["bus_ok"], palette, size=10) - y += 24 - _text(painter, QRect(inner.x(), y, 74, 20), s["bus_type"], palette, size=10, muted=True) - _text(painter, QRect(inner.x() + 76, y, 180, 20), s["chip_bus"], palette, size=10) - y += 24 - _text(painter, QRect(inner.x(), y, 90, 20), s["nodes"], palette, size=10, muted=True) - _text(painter, QRect(inner.x() + 92, y, 40, 20), "5", palette, size=10) - _text(painter, QRect(inner.x() + 146, y, 70, 20), s["axes_count"], palette, size=10, muted=True) - _text(painter, QRect(inner.x() + 218, y, 40, 20), "5", palette, size=10) - y += 32 - _button(painter, QRect(inner.x(), y, 146, 26), s["download"], palette, primary=True) - _button(painter, QRect(inner.x() + 154, y, 104, 26), s["reinit"], palette) - - group = QRect(rect.x(), rect.y() + 484, rect.width(), 32) - _panel(painter, group.adjusted(0, 0, 0, -8), palette) - _text(painter, QRect(group.x() + 12, group.y(), group.width() - 24, 30), s["busdata_group"], palette, size=10, muted=True) - - # The axis readout lives in this rail now, not in a second one on the right: - # two thin rails cost the workspace 240 px at every window size. - readout = QRect(rect.x(), rect.y() + 526, rect.width(), 268) + Connection and bus initialisation are in the device-setup dialog, so the rail + starts with the axis readout instead of burying it under ~460 px of start-up + controls. + """ + readout = QRect(rect.x(), rect.y(), rect.width(), 268) inner = _panel(painter, readout, palette) _box_title(painter, readout, s["readout_title"], palette) y = inner.y() + 6 @@ -631,6 +631,54 @@ def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> _button(painter, QRect(inner.x(), y + 40, inner.width(), 28), s["stop_all"], palette, danger=True) _button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["clear_alarms"], palette) + group = QRect(rect.x(), rect.y() + 280, rect.width(), 32) + _panel(painter, group.adjusted(0, 0, 0, -8), palette) + _text(painter, QRect(group.x() + 12, group.y(), group.width() - 24, 30), s["busdata_group"], palette, size=10, muted=True) + + +def draw_setup_dialog(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None: + """The device-setup dialog: connection on one tab, bus initialisation on the other. + + Drawn as its own panel so the proposal can be reviewed in the same image as the + page it came out of. + """ + dialog = QRect(rect.x(), rect.y(), rect.width(), 400) + inner = _panel(painter, dialog, palette) + _box_title(painter, dialog, s["setup_title"], palette) + _text( + painter, + QRect(inner.x(), inner.y(), inner.width(), 34), + s["setup_hint"], + palette, + size=9, + muted=True, + ) + + tabs = QRect(inner.x(), inner.y() + 38, inner.width(), inner.height() - 74) + tab_w = 130 + for index, label in enumerate(s["setup_tabs"]): + tab = QRect(tabs.x() + index * tab_w, tabs.y(), tab_w, 26) + active = index == 0 + painter.fillRect(tab, palette.window if active else palette.panel) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(tab.adjusted(0, 0, -1, -1)) + if active: + painter.setPen(QPen(palette.accent, 2)) + painter.drawLine(tab.bottomLeft(), tab.bottomRight()) + _text(painter, tab, label, palette, size=10, bold=active, align=Qt.AlignmentFlag.AlignCenter) + + # The rows are both tabs' contents stacked, in order: the connection fields and + # the bus readouts, which is what the two tabs hold between them. + body = _panel(painter, tabs.adjusted(0, 26, 0, 0), palette) + y = body.y() + 6 + for label, value in s["setup_rows"]: + _text(painter, QRect(body.x(), y, 96, 22), label, palette, size=10, muted=True) + _text(painter, QRect(body.x() + 104, y, body.width() - 104, 22), value, palette, size=10) + y += 24 + _button(painter, QRect(body.x(), y + 6, 110, 26), s["setup_connect"], palette, primary=True) + _button(painter, QRect(body.x() + 118, y + 6, 118, 26), s["setup_disconnect"], palette) + _button(painter, QRect(dialog.right() - 110, dialog.bottom() - 34, 96, 26), s["setup_close"], palette) + def draw_tabs(painter: QPainter, palette: Palette, s: dict, workspace: QRect, active: str) -> QRect: tab_h = 30 @@ -1256,6 +1304,22 @@ TAB_PAINTERS = { #: Tab index -> painter key, for the tab strip labels. TAB_KEYS = ("axes", "sequence", "finger", "sensor", "surface", "points") +#: Pages that are not workspace tabs. The device setup is a dialog, so it is drawn as +#: a page of its own: it has to be reviewable like everything else. +EXTRA_PAGES = ("setup",) + + +def draw_setup_page(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None: + """The device-setup dialog over the page it was opened from.""" + painter.fillRect(rect, palette.window) + draw_page(painter, palette, s, rect, active="axes", with_chrome=True, with_rail=True) + draw_setup_dialog( + painter, + palette, + s, + QRect(rect.center().x() - 300, rect.center().y() - 200, 600, 400), + ) + def draw_page( painter: QPainter, @@ -1265,15 +1329,21 @@ def draw_page( *, active: str = "axes", with_chrome: bool = True, + with_rail: bool = True, ) -> None: - """The whole page: chrome, strip, run bar, rail, workspace, status bar.""" + """The whole page: chrome, strip, run bar, rail, workspace, status bar. + + ``with_rail`` only exists for the setup page, which draws this page as the + background behind the dialog and does not want to draw the rail twice. + """ painter.fillRect(rect, palette.window) body_top = rect.y() + MENU_H + BRAND_H + STRIP_H + RUNBAR_H body_h = rect.height() - (MENU_H + BRAND_H + STRIP_H + RUNBAR_H) - STATUS_H - draw_left_rail( - painter, palette, s, QRect(rect.x() + MARGIN, body_top + MARGIN, RAIL_LEFT, body_h - 2 * MARGIN) - ) + if with_rail: + draw_left_rail( + painter, palette, s, QRect(rect.x() + MARGIN, body_top + MARGIN, RAIL_LEFT, body_h - 2 * MARGIN) + ) workspace = QRect( rect.x() + 2 * MARGIN + RAIL_LEFT, @@ -1294,6 +1364,14 @@ def draw_page( draw_status_bar(painter, palette, s, rect.bottom() - STATUS_H) +def draw_full_page(painter: QPainter, palette: Palette, s: dict, rect: QRect, key: str) -> None: + """One page of the proposal, by key — a workspace tab or the setup dialog.""" + if key in EXTRA_PAGES: + draw_setup_page(painter, palette, s, rect) + return + draw_page(painter, palette, s, rect, active=key) + + def draw_run_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None: """The run strip: phase, progress and the three controls.""" painter.fillRect(QRect(0, top, PAGE_W, RUNBAR_H), palette.window) @@ -1356,7 +1434,7 @@ def main() -> int: default=TAB_KEYS[0], # Derived, not listed: a literal here silently rejects any tab added to # TAB_KEYS, which is how the finger page first went missing. - choices=(*TAB_KEYS, "all"), + choices=(*TAB_KEYS, *EXTRA_PAGES, "all"), help="which page to draw; 'all' writes one full-size image per page", ) args = parser.parse_args() @@ -1367,7 +1445,7 @@ def main() -> int: base = Path(args.out).resolve() base.parent.mkdir(parents=True, exist_ok=True) - pages = TAB_KEYS if args.page == "all" else (args.page,) + pages = (*TAB_KEYS, *EXTRA_PAGES) if args.page == "all" else (args.page,) failures = 0 for key in pages: target = base if args.page != "all" else base.with_name(f"{base.stem}_{key}{base.suffix or '.png'}") @@ -1376,7 +1454,7 @@ def main() -> int: painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) try: - draw_page(painter, palette, s, QRect(0, 0, args.width, args.height), active=key) + draw_full_page(painter, palette, s, QRect(0, 0, args.width, args.height), key) finally: painter.end() if pixmap.save(str(target)): diff --git a/apps/autocal/tools/panel_check.py b/apps/autocal/tools/panel_check.py index 1acf06e..9360d95 100644 --- a/apps/autocal/tools/panel_check.py +++ b/apps/autocal/tools/panel_check.py @@ -132,28 +132,30 @@ def main() -> int: for key in window.workspace.page_keys(): window.workspace.set_current_key(key) app.processEvents() - oversize = [] - for child in _visible_descendants(window): - if _inside_scroll_area(child): - continue # a rail's content is meant to exceed its viewport - parent = child.parentWidget() - if parent is None or not parent.isVisible(): - continue - geometry = child.geometry() - if ( - geometry.width() > parent.width() + 1 - or geometry.height() > parent.height() + 1 - ): - oversize.append( - f"{type(child).__name__} {geometry.width()}x{geometry.height()}" - f" > {parent.width()}x{parent.height()}" - ) checker.that( - not oversize, f"{theme} {width}x{height} tab={key}: {oversize}" + not _overflowing(window), + f"{theme} {width}x{height} tab={key}: {_overflowing(window)}", ) window.workspace.set_current_key("axes") print(f" {width}x{height}: every tab fits") + # The device setup is a separate top-level window now, so the sweep has + # to be run over it as well: a dialog whose own controls do not fit is + # exactly the defect the sweep exists for, one window further out. + window._show_device_setup() # noqa: SLF001 - the documented entry point + app.processEvents() + for tab in ("connection", "bus"): + window.device_setup.select_tab(tab) + app.processEvents() + checker.that( + not _overflowing(window.device_setup), + f"{theme} {width}x{height} setup tab={tab}: " + f"{_overflowing(window.device_setup)}", + ) + window.device_setup.close() + app.processEvents() + print(f" {width}x{height}: the device setup fits") + # ---- the permanent shell ---------------------------------------- checker.that( window.workspace.page_keys() @@ -243,6 +245,58 @@ def main() -> int: window.left_rail.widget() is window.rail_content, "the setup rail is not scrollable", ) + # The rail carries what is read while working. Connection and bus + # initialisation moved into the device-setup dialog: back in the rail they + # pushed the axis readout below the fold. + rail_children = { + type(child).__name__ for child in window.rail_content.findChildren(object) + } + checker.that( + "AxisReadoutPanel" in rail_children, + f"the axis readout is not in the rail: {sorted(rail_children)}", + ) + checker.that( + "BusDataPanel" in rail_children, + "the bus-data monitor is not in the rail", + ) + checker.that( + "ConnectionPanel" not in rail_children and "BusPanel" not in rail_children, + f"a start-up panel is still in the rail: {sorted(rail_children)}", + ) + checker.that( + not window.device_setup.isVisible(), + "the device setup is open before anyone asked for it", + ) + checker.that( + window.device_setup.connection_panel is window.connection_panel + and window.device_setup.bus_panel is window.bus_panel, + "the window's panel references are not the ones in the dialog", + ) + # Three ways in, all of them the operator's, all landing on one method. + window.status_strip.setup_button.click() + app.processEvents() + checker.that( + window.device_setup.isVisible(), + "the setup button did not open the device setup", + ) + window.device_setup.close() + window.status_strip.chip_bus.clicked.emit() + app.processEvents() + checker.that( + window.device_setup.isVisible() + and window.device_setup.current_tab() == "bus", + "the bus chip did not open the bus tab", + ) + window.device_setup.close() + window.brand_bar.connectionChipClicked.emit() + app.processEvents() + checker.that( + window.device_setup.isVisible() + and window.device_setup.current_tab() == "connection", + "the brand-bar chip did not open the connection tab", + ) + window.device_setup.close() + app.processEvents() checker.that( window.run_bar.isVisible(), "the run bar is not visible", @@ -415,6 +469,31 @@ def main() -> int: return checker.report() +def _overflowing(root) -> list[str]: + """Visible descendants that are larger than the widget that contains them. + + Scroll-area content is exempt: a rail's content is meant to exceed its viewport. + Everything else that does not fit its parent is a clipped control. + """ + oversize = [] + for child in _visible_descendants(root): + if _inside_scroll_area(child): + continue + parent = child.parentWidget() + if parent is None or not parent.isVisible(): + continue + geometry = child.geometry() + if ( + geometry.width() > parent.width() + 1 + or geometry.height() > parent.height() + 1 + ): + oversize.append( + f"{type(child).__name__} {geometry.width()}x{geometry.height()}" + f" > {parent.width()}x{parent.height()}" + ) + return oversize + + def _inside_scroll_area(widget) -> bool: """Whether ``widget`` is scroll-area content rather than a laid-out child.""" from PySide6.QtWidgets import QAbstractScrollArea diff --git a/apps/autocal/tools/panel_snapshot.py b/apps/autocal/tools/panel_snapshot.py index c8e93c0..8441a4d 100644 --- a/apps/autocal/tools/panel_snapshot.py +++ b/apps/autocal/tools/panel_snapshot.py @@ -50,6 +50,12 @@ def main() -> int: action="store_true", help="drive a simulated session so the shot shows live values", ) + parser.add_argument( + "--setup-dialog", + default="", + choices=("", "connection", "bus"), + help="photograph the device-setup dialog on this tab instead of the window", + ) args = parser.parse_args() width, height = (int(part) for part in args.size.lower().split("x")) @@ -89,6 +95,25 @@ def main() -> int: window._on_move_abs(0, 1000.0) _wait_for(app, lambda: window._last_snapshot.axis(0).dpos != 0, 4.0) + if args.setup_dialog: + # The dialog is a separate top-level window, so it is grabbed on its own + # rather than inside the page shot. + window._show_device_setup(args.setup_dialog) # noqa: SLF001 + app.processEvents() + output = Path(args.out).resolve() + target = window.device_setup + if not target.grab().save(str(output)): + print(f"could not write {output}", file=sys.stderr) + return 1 + print( + f"wrote {output} ({target.width()}x{target.height()}, theme={args.theme}, " + f"tab={args.setup_dialog})" + ) + target.close() + window.close() + app.processEvents() + return 0 + app.processEvents() if args.compare: