move controller connect to popup dialog
This commit is contained in:
+12
-2
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "连接方式",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
@@ -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)):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user