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
@@ -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()
+66 -21
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(
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()