move controller connect to popup dialog

This commit is contained in:
ykcheungab
2026-09-24 21:36:24 +08:00
parent 4dad413d29
commit 6effe74172
13 changed files with 773 additions and 99 deletions
+12 -2
View File
@@ -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
+10
View File
@@ -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",
+10
View File
@@ -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": "连接方式",
+5
View File
@@ -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 <task>` then
`RUN "<file>",<task>`, on the task from `bas_task` in `preference.json`
(default 4 — the task the vendor reference uses; stopping another task restarts
@@ -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:
@@ -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()
@@ -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()
+61 -16
View File
@@ -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(
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.rail_content,
)
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()
+1 -1
View File
@@ -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"),
+188
View File
@@ -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()
+130 -52
View File
@@ -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,12 +1329,18 @@ 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
if with_rail:
draw_left_rail(
painter, palette, s, QRect(rect.x() + MARGIN, body_top + MARGIN, RAIL_LEFT, body_h - 2 * MARGIN)
)
@@ -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)):
+96 -17
View File
@@ -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
+25
View File
@@ -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: