From 737f365749dbce5eefd77b20d79bcabea633badd Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Tue, 20 Oct 2026 21:43:06 -0500 Subject: [PATCH 01/25] feat(pi): touch pan and an edit lock on the node graph --- src/glider/gui/node_graph/graph_view.py | 87 +++++++++++- tests/unit/gui/node_graph/__init__.py | 0 .../gui/node_graph/test_graph_touch_lock.py | 131 ++++++++++++++++++ 3 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 tests/unit/gui/node_graph/__init__.py create mode 100644 tests/unit/gui/node_graph/test_graph_touch_lock.py diff --git a/src/glider/gui/node_graph/graph_view.py b/src/glider/gui/node_graph/graph_view.py index 6c951965..a24e044b 100644 --- a/src/glider/gui/node_graph/graph_view.py +++ b/src/glider/gui/node_graph/graph_view.py @@ -30,7 +30,7 @@ QWidget, ) -from glider.gui.styles import colors, radius +from glider.gui.styles import colors, radius, restyle if TYPE_CHECKING: from glider.core.flow_engine import FlowEngine @@ -138,6 +138,31 @@ def __init__(self, parent: QWidget): layout.addWidget(self.fit_btn) self.adjustSize() + # The edit-lock toggle (touch mode only). It belongs to the viewport, not + # to this widget, because it sits in the opposite corner; the view + # positions it. It carries no inline stylesheet: touch.qss styles it by + # object name and by the dynamic ``locked`` property. + self.lock_btn = QPushButton(parent) + self.lock_btn.setObjectName("graphLockButton") + self.lock_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.set_locked_look(False) + self.lock_btn.hide() + + def set_locked_look(self, locked: bool) -> None: + """Make the lock button say what tapping it will do next.""" + self.lock_btn.setProperty("locked", locked) + self.lock_btn.setText( + "View only \u00b7 tap to edit" if locked else "Editing \u00b7 tap to lock" + ) + restyle(self.lock_btn) + self.lock_btn.adjustSize() + + def set_button_size(self, side: int) -> None: + """Resize the zoom/fit buttons (32 for a mouse, 44 for a fingertip).""" + for btn in (self.zoom_in_btn, self.zoom_out_btn, self.fit_btn): + btn.setFixedSize(side, side) + self.adjustSize() + @staticmethod def _make_button(label: str, tooltip: str) -> QPushButton: btn = QPushButton(label) @@ -189,6 +214,7 @@ class NodeGraphView(QGraphicsView): str, int, str, int, str ) # from_node, from_port, to_node, to_port, conn_type connection_deleted = pyqtSignal(str) # connection_id + locked_changed = pyqtSignal(bool) # emitted only when the lock actually flips def __init__(self, parent: QWidget | None = None): super().__init__(parent) @@ -213,6 +239,8 @@ def __init__(self, parent: QWidget | None = None): self._connection_start_port = None self._connection_start_node = None self._selected_nodes: list[str] = [] + # Edit lock: when set the graph is look-and-pan only (see set_locked). + self._locked = False # Zoom settings self._zoom = 1.0 @@ -228,6 +256,7 @@ def __init__(self, parent: QWidget | None = None): self._overlay.zoom_in_btn.clicked.connect(self.zoom_in) self._overlay.zoom_out_btn.clicked.connect(self.zoom_out) self._overlay.fit_btn.clicked.connect(self.fit_to_screen) + self._overlay.lock_btn.clicked.connect(lambda: self.set_locked(not self._locked)) self._overlay.show() self._reposition_overlay() @@ -248,6 +277,43 @@ def set_flow_engine(self, engine: "FlowEngine") -> None: """Set the flow engine for node management.""" self._flow_engine = engine + def set_touch_mode(self, enabled: bool) -> None: + """Switch between mouse and fingertip interaction. + + Touch mode pans on a one-finger drag over empty canvas (a mouse keeps its + rubber band), makes the zoom buttons finger-sized and shows the edit-lock + toggle. Desktop is the default and is left exactly as it was. + """ + self.setDragMode( + QGraphicsView.DragMode.ScrollHandDrag + if enabled + else QGraphicsView.DragMode.RubberBandDrag + ) + self._overlay.set_button_size(44 if enabled else 32) + self._overlay.lock_btn.setVisible(enabled) + self._reposition_overlay() + + def set_locked(self, locked: bool) -> None: + """Lock the graph to look-and-pan, or unlock it for editing. + + A locked view takes no item interaction (no node drags, selection or + wiring), no library drops, no Delete and no context menu. Zoom, fit, Home + and the middle-button pan stay. Emits ``locked_changed`` on a change. + """ + if locked == self._locked: + return + self._locked = locked + self.setInteractive(not locked) + self._overlay.set_locked_look(locked) + self.locked_changed.emit(locked) + + def is_locked(self) -> bool: + return self._locked + + def set_lock_enabled(self, enabled: bool) -> None: + """Enable or grey out the lock toggle (e.g. while a run is in progress).""" + self._overlay.lock_btn.setEnabled(enabled) + @property def nodes(self) -> dict[str, "NodeItem"]: """Get all node items.""" @@ -518,7 +584,7 @@ def resizeEvent(self, event) -> None: self._reposition_overlay() def _reposition_overlay(self) -> None: - """Move the floating zoom/fit controls into the bottom-right corner.""" + """Move the zoom/fit controls to the bottom-right and the lock to the top-left.""" if not hasattr(self, "_overlay") or self._overlay is None: return margin = 12 @@ -529,6 +595,7 @@ def _reposition_overlay(self) -> None: x = viewport.width() - overlay_size.width() - margin y = viewport.height() - overlay_size.height() - margin self._overlay.move(max(margin, x), max(margin, y)) + self._overlay.lock_btn.move(margin, margin) def mousePressEvent(self, event: QMouseEvent) -> None: """Handle mouse press for panning and connection creation.""" @@ -543,7 +610,7 @@ def mousePressEvent(self, event: QMouseEvent) -> None: from glider.gui.node_graph.port_item import PortItem - if isinstance(item, PortItem): + if isinstance(item, PortItem) and not self._locked: # Start connection from this port self._start_connection_from_port(item) event.accept() @@ -673,6 +740,9 @@ def keyPressEvent(self, event: QKeyEvent) -> None: if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace) and not isinstance( self._scene.focusItem(), QGraphicsProxyWidget ): + if self._locked: + event.ignore() + return # Delete selected connections first from glider.gui.node_graph.connection_item import ConnectionItem @@ -700,6 +770,8 @@ def keyPressEvent(self, event: QKeyEvent) -> None: def contextMenuEvent(self, event) -> None: """Show context menu.""" + if self._locked: + return menu = QMenu(self) # Add node submenu @@ -759,6 +831,9 @@ def _delete_selected(self) -> None: # Drag and drop handlers def dragEnterEvent(self, event: QDragEnterEvent) -> None: """Handle drag enter event.""" + if self._locked: + event.ignore() + return if event.mimeData().hasText(): text = event.mimeData().text() if text.startswith("node:"): @@ -768,6 +843,9 @@ def dragEnterEvent(self, event: QDragEnterEvent) -> None: def dragMoveEvent(self, event: QDragMoveEvent) -> None: """Handle drag move event.""" + if self._locked: + event.ignore() + return if event.mimeData().hasText(): text = event.mimeData().text() if text.startswith("node:"): @@ -777,6 +855,9 @@ def dragMoveEvent(self, event: QDragMoveEvent) -> None: def dropEvent(self, event: QDropEvent) -> None: """Handle drop event to create a new node.""" + if self._locked: + event.ignore() + return if event.mimeData().hasText(): text = event.mimeData().text() if text.startswith("node:"): diff --git a/tests/unit/gui/node_graph/__init__.py b/tests/unit/gui/node_graph/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/gui/node_graph/test_graph_touch_lock.py b/tests/unit/gui/node_graph/test_graph_touch_lock.py new file mode 100644 index 00000000..3f4f17f4 --- /dev/null +++ b/tests/unit/gui/node_graph/test_graph_touch_lock.py @@ -0,0 +1,131 @@ +"""Touch pan and the edit lock on the node graph (Pi touchscreen Runner layout).""" + +import pytest +from PyQt6.QtCore import QMimeData, QPoint, QPointF, Qt +from PyQt6.QtGui import QContextMenuEvent, QDropEvent, QKeyEvent, QMouseEvent +from PyQt6.QtWidgets import QGraphicsView, QMenu + +from glider.gui.node_graph.graph_view import NodeGraphView + + +@pytest.fixture +def view(qtbot): + v = NodeGraphView() + qtbot.addWidget(v) + return v + + +def test_desktop_default_is_unchanged(view): + assert view.dragMode() == QGraphicsView.DragMode.RubberBandDrag + assert view.isInteractive() and not view.is_locked() + assert not view._overlay.lock_btn.isVisibleTo(view) + assert view._overlay.zoom_in_btn.width() == 32 + + +def test_touch_mode_pans_with_one_finger_and_shows_the_lock(view): + view.set_touch_mode(True) + assert view.dragMode() == QGraphicsView.DragMode.ScrollHandDrag + assert view._overlay.lock_btn.isVisibleTo(view) + assert view._overlay.zoom_in_btn.width() >= 44 + view.set_touch_mode(False) + assert view.dragMode() == QGraphicsView.DragMode.RubberBandDrag + assert not view._overlay.lock_btn.isVisibleTo(view) + assert view._overlay.zoom_in_btn.width() == 32 + + +def test_lock_makes_the_view_look_only(qtbot, view): + with qtbot.waitSignal(view.locked_changed) as sig: + view.set_locked(True) + assert sig.args == [True] + assert not view.isInteractive() + view.set_locked(False) + assert view.isInteractive() + + +def test_locked_changed_fires_only_on_a_change(qtbot, view): + with qtbot.assertNotEmitted(view.locked_changed): + view.set_locked(False) + + +def test_lock_button_says_what_a_tap_will_do(view): + btn = view._overlay.lock_btn + assert btn.objectName() == "graphLockButton" + assert btn.property("locked") is False + assert "lock" in btn.text() + view.set_locked(True) + assert btn.property("locked") is True + assert "edit" in btn.text() + + +def test_locked_view_refuses_library_drops(qtbot, view): + view.set_locked(True) + mime = QMimeData() + mime.setText("node:Delay") + drop = QDropEvent( + QPointF(10, 10), + Qt.DropAction.CopyAction, + mime, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + with qtbot.assertNotEmitted(view.node_created): + view.dropEvent(drop) + assert not drop.isAccepted() + + +def test_unlocked_view_still_takes_library_drops(qtbot, view): + mime = QMimeData() + mime.setText("node:Delay") + drop = QDropEvent( + QPointF(10, 10), + Qt.DropAction.CopyAction, + mime, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + with qtbot.waitSignal(view.node_created): + view.dropEvent(drop) + + +def test_locked_view_ignores_delete(qtbot, view, monkeypatch): + view.set_locked(True) + view._selected_nodes = ["n1"] + monkeypatch.setattr(view, "remove_node", lambda _id: None) + key = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Delete, Qt.KeyboardModifier.NoModifier) + with qtbot.assertNotEmitted(view.node_deleted): + view.keyPressEvent(key) + + +def test_locked_view_has_no_context_menu(view, monkeypatch): + shown = [] + monkeypatch.setattr(QMenu, "exec", lambda self, *a: shown.append(self)) + view.set_locked(True) + view.contextMenuEvent(QContextMenuEvent(QContextMenuEvent.Reason.Mouse, QPoint(5, 5))) + assert shown == [] + + +def test_lock_button_toggles_and_can_be_disabled(qtbot, view): + view.set_touch_mode(True) + view.set_locked(True) + qtbot.mouseClick(view._overlay.lock_btn, Qt.MouseButton.LeftButton) + assert not view.is_locked() + view.set_lock_enabled(False) + assert not view._overlay.lock_btn.isEnabled() + + +@pytest.mark.parametrize("locked, wiring", [(False, True), (True, False)]) +def test_pressing_a_port_wires_only_while_unlocked(view, locked, wiring): + node = view.add_node("n1", "Delay", 0, 0) + port = node.add_output_port("out") + view.set_locked(locked) + pos = QPointF(view.mapFromScene(port.get_scene_center())) + press = QMouseEvent( + QMouseEvent.Type.MouseButtonPress, + pos, + view.viewport().mapToGlobal(pos), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + view.mousePressEvent(press) + assert view._connecting is wiring From 3219cf76d6396b5f55249ca1ddc1525693c30709 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Tue, 20 Oct 2026 21:48:12 -0500 Subject: [PATCH 02/25] feat(pi): tap a library node to drop it in the middle of the graph --- src/glider/gui/panels/node_library_panel.py | 36 +++++++------ .../unit/gui/panels/test_node_library_tap.py | 50 +++++++++++++++++++ 2 files changed, 67 insertions(+), 19 deletions(-) create mode 100644 tests/unit/gui/panels/test_node_library_tap.py diff --git a/src/glider/gui/panels/node_library_panel.py b/src/glider/gui/panels/node_library_panel.py index ef749577..ef45985e 100644 --- a/src/glider/gui/panels/node_library_panel.py +++ b/src/glider/gui/panels/node_library_panel.py @@ -115,15 +115,21 @@ class NodeLibraryPanel(QWidget): status_message = pyqtSignal(str, int) # message, timeout_ms - def __init__(self, session_fn, graph_view: "NodeGraphView", parent=None): + def __init__( + self, session_fn, graph_view: "NodeGraphView", parent=None, *, tap_to_add: bool = False + ): """ Args: session_fn: Callable that returns the current ExperimentSession (or None) graph_view: NodeGraphView instance for adding nodes + tap_to_add: Touch layout. A tap on a node button still drops the node in the + middle of the graph (a click always has), but is refused while the graph + is locked to view-only. """ super().__init__(parent) self._session_fn = session_fn self._graph_view = graph_view + self._tap_to_add = tap_to_add # Zone configuration reference (set externally) self._zone_config = None @@ -220,7 +226,7 @@ def _setup_ui(self): for node_type, node_name, tooltip in nodes: node_btn = DraggableNodeButton(node_type, node_name, category) node_btn.setToolTip(tooltip) - node_btn.clicked.connect(lambda checked, nt=node_type: self._add_node_to_center(nt)) + node_btn.clicked.connect(lambda _=False, t=node_type: self._on_node_tapped(t)) self._apply_node_btn_style(node_btn, color) category_layout.addWidget(node_btn) @@ -303,9 +309,7 @@ def refresh_flow_functions(self) -> None: btn = DraggableNodeButton(f"FunctionCall:{start_node_id}", func_name, "Functions") btn.setToolTip(f"Call function '{func_name}'") btn.clicked.connect( - lambda checked, nid=start_node_id, name=func_name: self._add_function_call_node( - nid, name - ) + lambda _=False, t=f"FunctionCall:{start_node_id}": self._on_node_tapped(t) ) self._apply_node_btn_style(btn, colors.LIB_FUNCTIONS) self._flow_functions_layout.addWidget(btn) @@ -339,7 +343,7 @@ def refresh_plugin_nodes(self) -> None: description = getattr(definition, "description", "") or "" btn = DraggableNodeButton(node_type, label, "Plugins") btn.setToolTip(f"{description}\n\nFrom plugin: {entries[node_type]}".strip()) - btn.clicked.connect(lambda checked, nt=node_type: self._add_node_to_center(nt)) + btn.clicked.connect(lambda _=False, t=node_type: self._on_node_tapped(t)) self._apply_node_btn_style(btn, colors.LIB_PLUGINS) self._plugin_nodes_layout.addWidget(btn) @@ -367,7 +371,9 @@ def refresh_zones(self, zone_config=None) -> None: f"Monitor zone '{zone.name}' for object occupancy\n" f"Outputs: Occupied (bool), Object Count (int), On Enter, On Exit" ) - btn.clicked.connect(lambda checked, zid=zone.id: self._add_zone_node(zid)) + btn.clicked.connect( + lambda _=False, t=f"ZoneInput:{zone.id}": self._on_node_tapped(t) + ) self._apply_node_btn_style(btn, colors.LIB_ZONES) self._zones_layout.addWidget(btn) else: @@ -473,11 +479,6 @@ def _setup_custom_category( parent_layout.addWidget(category_widget) - def _add_zone_node(self, zone_id: str) -> None: - """Add a zone input node to the graph.""" - center = self._graph_view.mapToScene(self._graph_view.viewport().rect().center()) - self._graph_view.node_created.emit(f"ZoneInput:{zone_id}", center.x(), center.y()) - def _detect_graph_functions(self) -> list: """Detect complete function definitions in the graph.""" from glider.core.graph_functions import list_graph_functions @@ -491,12 +492,9 @@ def _detect_graph_functions(self) -> list: if info.has_end ] - def _add_function_call_node(self, start_node_id: str, func_name: str) -> None: - """Add a FunctionCall node to the graph.""" - center = self._graph_view.mapToScene(self._graph_view.viewport().rect().center()) - self._graph_view.node_created.emit(f"FunctionCall:{start_node_id}", center.x(), center.y()) - - def _add_node_to_center(self, node_type: str) -> None: - """Add a node to the center of the graph view.""" + def _on_node_tapped(self, node_type: str) -> None: + """Drop a node in the middle of the visible graph (a button click).""" + if self._tap_to_add and self._graph_view.is_locked(): + return center = self._graph_view.mapToScene(self._graph_view.viewport().rect().center()) self._graph_view.node_created.emit(node_type, center.x(), center.y()) diff --git a/tests/unit/gui/panels/test_node_library_tap.py b/tests/unit/gui/panels/test_node_library_tap.py new file mode 100644 index 00000000..3706c733 --- /dev/null +++ b/tests/unit/gui/panels/test_node_library_tap.py @@ -0,0 +1,50 @@ +from types import SimpleNamespace + +from PyQt6.QtCore import Qt + +from glider.gui.node_graph.graph_view import NodeGraphView +from glider.gui.panels.node_library_panel import DraggableNodeButton, NodeLibraryPanel + + +def _panel(qtbot, node_type="Delay", **kw): + graph = NodeGraphView() + qtbot.addWidget(graph) + panel = NodeLibraryPanel(session_fn=lambda: None, graph_view=graph, **kw) + qtbot.addWidget(panel) + return graph, panel, _button(panel, node_type) + + +def _button(panel, node_type): + return next(b for b in panel.findChildren(DraggableNodeButton) if b._node_type == node_type) + + +def test_tap_adds_the_node_at_the_visible_centre(qtbot): + graph, _panel_, button = _panel(qtbot, tap_to_add=True) + with qtbot.waitSignal(graph.node_created) as sig: + qtbot.mouseClick(button, Qt.MouseButton.LeftButton) + centre = graph.mapToScene(graph.viewport().rect().center()) + assert sig.args == ["Delay", centre.x(), centre.y()] + + +def test_click_still_adds_the_node_on_desktop(qtbot): + # Clicking a library button already dropped the node mid-graph before the + # Pi layout existed; the default (desktop) behaviour must stay that way. + graph, _p, button = _panel(qtbot) + with qtbot.waitSignal(graph.node_created) as sig: + qtbot.mouseClick(button, Qt.MouseButton.LeftButton) + assert sig.args[0] == "Delay" + + +def test_tap_is_refused_while_the_graph_is_locked(qtbot): + graph, _p, button = _panel(qtbot, tap_to_add=True) + graph.set_locked(True) + with qtbot.assertNotEmitted(graph.node_created): + qtbot.mouseClick(button, Qt.MouseButton.LeftButton) + + +def test_zone_button_taps_its_own_zone_type(qtbot): + graph, panel, _b = _panel(qtbot, tap_to_add=True) + panel.refresh_zones(SimpleNamespace(zones=[SimpleNamespace(id="z1", name="Nest")])) + with qtbot.waitSignal(graph.node_created) as sig: + qtbot.mouseClick(_button(panel, "ZoneInput:z1"), Qt.MouseButton.LeftButton) + assert sig.args[0] == "ZoneInput:z1" From 12c54250a15e94316b458a2b53002cb4063ce6d2 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Tue, 20 Oct 2026 21:49:13 -0500 Subject: [PATCH 03/25] refactor(pi): drop the tap_to_add flag; refuse taps whenever the graph is locked --- src/glider/gui/panels/node_library_panel.py | 15 ++++++--------- tests/unit/gui/panels/test_node_library_tap.py | 8 ++++---- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/glider/gui/panels/node_library_panel.py b/src/glider/gui/panels/node_library_panel.py index ef45985e..e40a6409 100644 --- a/src/glider/gui/panels/node_library_panel.py +++ b/src/glider/gui/panels/node_library_panel.py @@ -115,21 +115,15 @@ class NodeLibraryPanel(QWidget): status_message = pyqtSignal(str, int) # message, timeout_ms - def __init__( - self, session_fn, graph_view: "NodeGraphView", parent=None, *, tap_to_add: bool = False - ): + def __init__(self, session_fn, graph_view: "NodeGraphView", parent=None): """ Args: session_fn: Callable that returns the current ExperimentSession (or None) graph_view: NodeGraphView instance for adding nodes - tap_to_add: Touch layout. A tap on a node button still drops the node in the - middle of the graph (a click always has), but is refused while the graph - is locked to view-only. """ super().__init__(parent) self._session_fn = session_fn self._graph_view = graph_view - self._tap_to_add = tap_to_add # Zone configuration reference (set externally) self._zone_config = None @@ -493,8 +487,11 @@ def _detect_graph_functions(self) -> list: ] def _on_node_tapped(self, node_type: str) -> None: - """Drop a node in the middle of the visible graph (a button click).""" - if self._tap_to_add and self._graph_view.is_locked(): + """Drop a node in the middle of the visible graph (a button click or tap). + + Refused while the graph is locked to view-only. + """ + if self._graph_view.is_locked(): return center = self._graph_view.mapToScene(self._graph_view.viewport().rect().center()) self._graph_view.node_created.emit(node_type, center.x(), center.y()) diff --git a/tests/unit/gui/panels/test_node_library_tap.py b/tests/unit/gui/panels/test_node_library_tap.py index 3706c733..b793f239 100644 --- a/tests/unit/gui/panels/test_node_library_tap.py +++ b/tests/unit/gui/panels/test_node_library_tap.py @@ -19,7 +19,7 @@ def _button(panel, node_type): def test_tap_adds_the_node_at_the_visible_centre(qtbot): - graph, _panel_, button = _panel(qtbot, tap_to_add=True) + graph, _panel_, button = _panel(qtbot) with qtbot.waitSignal(graph.node_created) as sig: qtbot.mouseClick(button, Qt.MouseButton.LeftButton) centre = graph.mapToScene(graph.viewport().rect().center()) @@ -28,7 +28,7 @@ def test_tap_adds_the_node_at_the_visible_centre(qtbot): def test_click_still_adds_the_node_on_desktop(qtbot): # Clicking a library button already dropped the node mid-graph before the - # Pi layout existed; the default (desktop) behaviour must stay that way. + # Pi layout existed; desktop never locks, so that must stay that way. graph, _p, button = _panel(qtbot) with qtbot.waitSignal(graph.node_created) as sig: qtbot.mouseClick(button, Qt.MouseButton.LeftButton) @@ -36,14 +36,14 @@ def test_click_still_adds_the_node_on_desktop(qtbot): def test_tap_is_refused_while_the_graph_is_locked(qtbot): - graph, _p, button = _panel(qtbot, tap_to_add=True) + graph, _p, button = _panel(qtbot) graph.set_locked(True) with qtbot.assertNotEmitted(graph.node_created): qtbot.mouseClick(button, Qt.MouseButton.LeftButton) def test_zone_button_taps_its_own_zone_type(qtbot): - graph, panel, _b = _panel(qtbot, tap_to_add=True) + graph, panel, _b = _panel(qtbot) panel.refresh_zones(SimpleNamespace(zones=[SimpleNamespace(id="z1", name="Nest")])) with qtbot.waitSignal(graph.node_created) as sig: qtbot.mouseClick(_button(panel, "ZoneInput:z1"), Qt.MouseButton.LeftButton) From 8151df882bd618ae50f575056ee28be286087978 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Tue, 20 Oct 2026 22:09:26 -0500 Subject: [PATCH 04/25] feat(pi): experiment page as chips and full-width sections at 480 px --- src/glider/gui/dialogs/experiment_dialog.py | 32 +++++- src/glider/gui/dialogs/lab_setup_dialog.py | 8 +- src/glider/gui/dialogs/zone_dialog.py | 36 +++++- src/glider/gui/main_window.py | 9 +- src/glider/gui/panels/experiment_page.py | 106 +++++++++++++----- .../unit/gui/test_experiment_page_compact.py | 100 +++++++++++++++++ 6 files changed, 258 insertions(+), 33 deletions(-) create mode 100644 tests/unit/gui/test_experiment_page_compact.py diff --git a/src/glider/gui/dialogs/experiment_dialog.py b/src/glider/gui/dialogs/experiment_dialog.py index c4fc33e4..55458c77 100644 --- a/src/glider/gui/dialogs/experiment_dialog.py +++ b/src/glider/gui/dialogs/experiment_dialog.py @@ -46,6 +46,9 @@ #: to travel the width of a 27-inch display from a label to its field. EMBEDDED_FORM_MAX_WIDTH = 900 +#: Qt's ``QWIDGETSIZE_MAX``: what ``setMaximumWidth`` takes to mean "no limit". +_NO_MAX_WIDTH = 16777215 + class ExperimentDialog(QDialog): """ @@ -168,6 +171,7 @@ def _setup_ui(self) -> None: # Subjects group self._subjects_group = QGroupBox("Subjects") subjects_layout = QVBoxLayout(self._subjects_group) + self._subjects_layout = subjects_layout subjects_layout.setSpacing(8) subjects_layout.setContentsMargins(12, 20, 12, 12) @@ -199,6 +203,7 @@ def _setup_ui(self) -> None: # Subject buttons btn_layout = QHBoxLayout() + self._subject_buttons = btn_layout btn_layout.setSpacing(6) self._add_btn = QPushButton("Add") @@ -265,7 +270,7 @@ def _setup_ui(self) -> None: main_layout.addWidget(button_box) - def detach_sections(self) -> tuple[QWidget, QWidget]: + def detach_sections(self, *, compact: bool = False) -> tuple[QWidget, QWidget]: """Take the two halves out and hand each back as its own page. Returns ``(metadata, subjects)`` -- the experiment-info form and the @@ -278,11 +283,19 @@ def detach_sections(self) -> tuple[QWidget, QWidget]: the point -- the alternative was two new widgets reimplementing a form and a table that already work. + ``compact`` is the Pi's 480px screen; see :meth:`_fit_480`. + **Call this at most once per instance, and never on one you also mean to** ``exec()``. It empties the dialog: what is left is a window with a scroll area containing nothing. """ self.setWindowFlags(Qt.WindowType.Widget) + # The empty shell is left behind as a child of the Experiment page, and + # a child that was never explicitly hidden is shown whenever its parent + # next is. Reparenting hides it only if the parent is visible at that + # moment, and in runner mode the page is not yet: the shell then came + # up as a 600px blank panel over both sections. + self.hide() self._button_box.hide() # The macOS style defaults QFormLayout to FieldsStayAtSizeHint and @@ -298,8 +311,25 @@ def detach_sections(self) -> tuple[QWidget, QWidget]: # but the subject table is a table, and a table wants the width. self._info_group.setMaximumWidth(EMBEDDED_FORM_MAX_WIDTH) + if compact: + self._fit_480() + return self._as_page(self._info_group), self._as_page(self._subjects_group) + def _fit_480(self) -> None: + """Make the two pages fit a 480px screen at touch size. + + The buttons' fixed widths were picked for the desktop font, and at + touch padding "Browse" and "Remove" are clipped, so they size to their + labels instead. Four buttons at that size are wider than the screen, + so Set Active drops to a row of its own. + """ + for button in (self._browse_dir_btn, self._add_btn, self._edit_btn, self._remove_btn): + button.setMinimumWidth(0) + button.setMaximumWidth(_NO_MAX_WIDTH) + self._subject_buttons.removeWidget(self._set_active_btn) + self._subjects_layout.insertWidget(2, self._set_active_btn) # under the button row + @staticmethod def _as_page(group: QWidget) -> QWidget: """One group box, reparented into a scroll area that fills a section. diff --git a/src/glider/gui/dialogs/lab_setup_dialog.py b/src/glider/gui/dialogs/lab_setup_dialog.py index ca120c43..26d29350 100644 --- a/src/glider/gui/dialogs/lab_setup_dialog.py +++ b/src/glider/gui/dialogs/lab_setup_dialog.py @@ -258,11 +258,15 @@ def _setup_ui(self) -> None: self.skip_button.clicked.connect(self.reject) layout.addWidget(buttons) - def embed(self) -> "LabSetupDialog": + def embed(self, *, compact: bool = False) -> "LabSetupDialog": """Turn this form into a plain widget for the Experiment tab. Returns ``self`` so it can be added to a layout in one expression. + ``compact`` drops the dialog's 480x560 minimum: on the Pi's 480px + screen a vertical scroll bar takes some of that width, and a widget + that insists on 480 of it is clipped on the right instead. + *Skip* goes -- it means "close without saving", and there is nothing to close. *Done* stays, relabelled, because it is not a dialog button at all: it is the only thing that writes the vocabulary file, and the form @@ -278,6 +282,8 @@ def embed(self) -> "LabSetupDialog": self.setWindowFlags(Qt.WindowType.Widget) self.skip_button.hide() self.done_button.setText("Save Lab Vocabulary") + if compact: + self.setMinimumSize(0, 0) return self def accept(self) -> None: # noqa: D102 - see embed() diff --git a/src/glider/gui/dialogs/zone_dialog.py b/src/glider/gui/dialogs/zone_dialog.py index aac70e16..ffbae729 100644 --- a/src/glider/gui/dialogs/zone_dialog.py +++ b/src/glider/gui/dialogs/zone_dialog.py @@ -13,8 +13,9 @@ import cv2 import numpy as np from PyQt6.QtCore import QPoint, Qt, pyqtSignal -from PyQt6.QtGui import QColor, QImage, QMouseEvent, QPixmap +from PyQt6.QtGui import QColor, QImage, QMouseEvent, QPixmap, QResizeEvent from PyQt6.QtWidgets import ( + QBoxLayout, QButtonGroup, QColorDialog, QDialog, @@ -92,6 +93,18 @@ def __init__(self, parent=None): self._image_rect = None # Actual image area within widget self._close_threshold = 15 # Pixels - snap to close polygon + #: Re-fit the frame to the widget whenever it is resized. Off: the + #: frame is scaled once, to the size the widget has when it is set, + #: which is what the desktop window always did. The embedded compact + #: editor turns it on -- it is built before layout gives the preview + #: its real, much smaller, size, and would otherwise show a crop. + self.refit_on_resize = False + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + if self.refit_on_resize: + self._update_display() + def set_frame(self, frame: np.ndarray) -> None: """Set the current frame to display.""" self._frame = frame.copy() @@ -409,6 +422,7 @@ def __init__( self._color_index = len(zone_config.zones) % len(DEFAULT_COLORS) self._custom_color: tuple[int, int, int] | None = None self._pending_zone: Zone | None = None + self._compact = False # set by embed(compact=True) self._setup_ui() self._update_table() @@ -432,6 +446,7 @@ def _setup_ui(self) -> None: self.setMinimumSize(1000, 700) layout = QHBoxLayout(self) + self._main_layout = layout # Left side - preview left_layout = QVBoxLayout() @@ -753,7 +768,10 @@ def _update_table(self) -> None: # Delete button delete_btn = QPushButton("X") - delete_btn.setFixedWidth(40) + if not self._compact: + # Desktop font. At touch padding 40px is narrower than the + # button's own padding and the X disappears. + delete_btn.setFixedWidth(40) delete_btn.clicked.connect(lambda checked, idx=i: self._remove_zone(idx)) self._table.setCellWidget(i, 3, delete_btn) @@ -828,11 +846,16 @@ def _clear_zones(self) -> None: self._preview._update_display() self.zones_changed.emit() - def embed(self) -> "ZoneDialog": + def embed(self, *, compact: bool = False) -> "ZoneDialog": """Turn this editor into a plain widget for the Experiment tab. Returns ``self`` so it can be added to a layout in one expression. + ``compact`` is the Pi's 480px screen: the preview goes above the + controls instead of beside them, and the 1000x700 / 640x480 minimums + that make a comfortable desktop window go, so the editor is as wide as + the screen and the page scrolls to reach the rest. + ``Qt.WindowType.Widget`` stops Qt giving it a window of its own; the OK/Cancel pair goes because neither means anything here. *Cancel* is the sharper of the two: this editor mutates the caller's @@ -846,6 +869,13 @@ def embed(self) -> "ZoneDialog": self.setWindowFlags(Qt.WindowType.Widget) self._ok_btn.hide() self._cancel_dialog_btn.hide() + if compact: + self._compact = True + self._main_layout.setDirection(QBoxLayout.Direction.TopToBottom) + self.setMinimumSize(0, 0) + self._preview.setMinimumSize(240, 180) + self._preview.refit_on_resize = True + self._update_table() # rows built in __init__ have the desktop delete button return self def get_zone_configuration(self) -> ZoneConfiguration: diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index 9c1855cf..a763dcb3 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -756,6 +756,7 @@ def _create_experiment_page(self) -> None: "plugins": self._build_plugins_section, }, parent=self, + compact=self._view_manager.is_runner_mode, ) def _experiment_detail_pages(self) -> tuple[QWidget, QWidget]: @@ -789,7 +790,9 @@ def _experiment_detail_pages(self) -> tuple[QWidget, QWidget]: # dropping this reference would collect the editor out from under # its own widgets. self._experiment_details = dialog - self._experiment_detail_widgets = dialog.detach_sections() + self._experiment_detail_widgets = dialog.detach_sections( + compact=self._view_manager.is_runner_mode + ) return self._experiment_detail_widgets def _reset_experiment_page(self) -> None: @@ -816,7 +819,7 @@ def _build_zones_section(self) -> QWidget: camera_manager=self._core.camera_manager, zone_config=self._zone_config, parent=self._experiment_page, - ).embed() + ).embed(compact=self._view_manager.is_runner_mode) editor.zones_changed.connect(self._on_zones_changed) return editor @@ -839,7 +842,7 @@ def _build_vocabulary_section(self) -> QWidget: return LabSetupDialog( parent=self._experiment_page, is_touch_mode=self._view_manager.is_runner_mode, - ).embed() + ).embed(compact=self._view_manager.is_runner_mode) def _on_zones_changed(self) -> None: """Push the current zone set to everything that reads it. diff --git a/src/glider/gui/panels/experiment_page.py b/src/glider/gui/panels/experiment_page.py index b3f05630..a49e8d06 100644 --- a/src/glider/gui/panels/experiment_page.py +++ b/src/glider/gui/panels/experiment_page.py @@ -21,7 +21,10 @@ would be several screens deep and the preview would routinely be off-screen while you drew into it. A rail on the left switches between them, each entry carrying a glyph painted by :class:`_SectionGlyphEngine` so the four are -distinguishable at a glance rather than by reading four labels. +distinguishable at a glance rather than by reading four labels. On the Pi's +480px screen the rail does not fit beside an editor, so ``compact=True`` swaps +it for a row of text chips across the top and gives each section the full width +below, scrolling vertically. **Sections are built on first visit, and dropped when the session changes.** Two reasons, and the second is the load-bearing one: @@ -54,6 +57,7 @@ QFrame, QHBoxLayout, QLabel, + QScrollArea, QSizePolicy, QStackedWidget, QToolButton, @@ -107,28 +111,42 @@ class ExperimentPage(QWidget): section per session; a builder that raises costs its own section a placeholder and leaves the rest of the page working. parent: Standard Qt parent. + compact: Lay the page out for the Pi's 480px portrait screen: a row of + section chips across the top instead of the 168px rail, and each + section in a vertical scroll area of its own so it is the width of + the screen and never wider. Off by default; the desktop page is + the same widget tree it always was. """ def __init__( self, builders: dict[str, Callable[[], QWidget]], parent: QWidget | None = None, + *, + compact: bool = False, ) -> None: super().__init__(parent) self.setObjectName("experimentPage") + self._compact = compact self._builders = dict(builders) self._built: dict[str, QWidget] = {} + # What actually sits in the stack for each built section: the section + # itself, or in compact mode the scroll area it was put in. + self._holders: dict[str, QWidget] = {} self._buttons: dict[str, QToolButton] = {} - row = QHBoxLayout(self) - row.setContentsMargins(0, 0, 0, 0) - row.setSpacing(0) - - row.addWidget(self._build_rail()) + if compact: + layout: QHBoxLayout | QVBoxLayout = QVBoxLayout(self) + layout.addWidget(self._build_chips()) + else: + layout = QHBoxLayout(self) + layout.addWidget(self._build_rail()) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) self._stack = QStackedWidget(self) self._stack.setObjectName("experimentSections") - row.addWidget(self._stack, 1) + layout.addWidget(self._stack, 1) # Index 0 is a permanent placeholder the stack falls back to while a # section is being built (and if one fails). Without it, the first @@ -182,6 +200,31 @@ def _build_rail(self) -> QWidget: column.addStretch(1) return rail + def _build_chips(self) -> QWidget: + """The compact page's section switcher: one chip per section, in a row. + + Text only, no glyphs: at 480px five chips and their icons do not share + a row, and the label is what you read anyway. + """ + bar = QFrame(self) + bar.setObjectName("experimentChips") + + row = QHBoxLayout(bar) + row.setContentsMargins(8, 8, 8, 8) + row.setSpacing(6) + + for key in SECTION_KEYS: + chip = QToolButton(bar) + chip.setObjectName("experimentChip") + chip.setText(SECTION_LABELS[key]) + chip.setCheckable(True) + chip.setAutoExclusive(True) + chip.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + chip.clicked.connect(lambda _checked, k=key: self.show_section(k)) + row.addWidget(chip) + self._buttons[key] = chip + return bar + # --------------------------------------------------------------- sections def show_section(self, key: str) -> None: @@ -199,15 +242,12 @@ def show_section(self, key: str) -> None: button.setChecked(True) button.blockSignals(was_blocked) - widget = self._built.get(key) - if widget is None: - widget = self._build_section(key) - if widget is None: - self._stack.setCurrentWidget(self._placeholder) - return - self._stack.setCurrentWidget(widget) + if key not in self._built: + self._build_section(key) + holder = self._holders.get(key) + self._stack.setCurrentWidget(holder if holder is not None else self._placeholder) - def _build_section(self, key: str) -> QWidget | None: + def _build_section(self, key: str) -> None: """Run the builder for ``key`` once, and remember the result. A builder that raises is caught and reported into the placeholder @@ -224,10 +264,21 @@ def _build_section(self, key: str) -> QWidget | None: f"The {SECTION_LABELS[key].replace('&&', '&')} section could not be opened.\n" "See the log for details." ) - return None - self._stack.addWidget(widget) + return + holder = widget + if self._compact: + # Never wider than the screen: a section that wants more width than + # the viewport is clipped (horizontal bar off), so each section has + # to fit 480px on its own, and taller than the screen scrolls. + holder = QScrollArea(self) + holder.setObjectName("experimentSection") + holder.setWidgetResizable(True) + holder.setFrameShape(QFrame.Shape.NoFrame) + holder.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + holder.setWidget(widget) + self._stack.addWidget(holder) self._built[key] = widget - return widget + self._holders[key] = holder def reset(self) -> None: """Drop every built section so the next visit rebuilds against the @@ -238,11 +289,12 @@ def reset(self) -> None: hold camera frames and Qt signal connections to session objects that are about to be replaced. """ - for key, widget in list(self._built.items()): - self._stack.removeWidget(widget) - widget.setParent(None) - widget.deleteLater() - del self._built[key] + for holder in list(self._holders.values()): + self._stack.removeWidget(holder) + holder.setParent(None) + holder.deleteLater() + self._built.clear() + self._holders.clear() self._stack.setCurrentWidget(self._placeholder) self._placeholder.setText("") @@ -262,9 +314,13 @@ def current_section(self) -> str: return self._current def built_sections(self) -> dict[str, QWidget]: - """The sections built so far, by key. For tests.""" + """The sections built so far, by key. For tests. + + The sections themselves, also in compact mode where each sits in a + scroll area: that wrapper's size hint says nothing about the content. + """ return dict(self._built) def rail_buttons(self) -> dict[str, QToolButton]: - """The rail buttons by key. For tests.""" + """The rail buttons (the chips, when compact) by key. For tests.""" return dict(self._buttons) diff --git a/tests/unit/gui/test_experiment_page_compact.py b/tests/unit/gui/test_experiment_page_compact.py new file mode 100644 index 00000000..26f4bdfb --- /dev/null +++ b/tests/unit/gui/test_experiment_page_compact.py @@ -0,0 +1,100 @@ +"""The Experiment tab on the Pi's 480 px screen: chips on top, sections full width.""" + +from __future__ import annotations + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QApplication, QFrame, QLabel, QScrollArea, QWidget + +from glider.gui.dialogs.zone_dialog import ZoneDialog +from glider.gui.panels.experiment_page import SECTION_KEYS, ExperimentPage +from glider.vision.zones import Zone, ZoneConfiguration, ZoneShape + + +def _width_needed(section: QWidget) -> int: + """What a section needs, counting what its own scroll areas hold. + + A section that wraps itself in a scroll area reports a tiny + ``minimumSizeHint``, so the widgets inside are measured too; and an explicit + ``setMinimumSize`` counts, which ``minimumSizeHint`` ignores. + """ + inside = [s.widget() for s in section.findChildren(QScrollArea) if s.widget() is not None] + return max(max(w.minimumSizeHint().width(), w.minimumWidth()) for w in [section, *inside]) + + +def test_compact_page_has_chips_above_the_sections(qtbot): + page = ExperimentPage({k: (lambda k=k: QLabel(k)) for k in SECTION_KEYS}, compact=True) + qtbot.addWidget(page) + assert page.findChild(QFrame, "experimentRail") is None + chips = page.findChild(QFrame, "experimentChips") + assert chips is not None + assert list(page.rail_buttons()) == list(SECTION_KEYS) + page.rail_buttons()["zones"].click() + assert page.current_section() == "zones" + + +def test_compact_sections_scroll_vertically_only(qtbot): + page = ExperimentPage({k: (lambda k=k: QLabel(k)) for k in SECTION_KEYS}, compact=True) + qtbot.addWidget(page) + page.show_section("mice") + section = page.built_sections()["mice"] # the section itself, not its wrapper + assert isinstance(section, QLabel) + wrapper = section.parentWidget().parentWidget() + assert isinstance(wrapper, QScrollArea) + assert wrapper.widgetResizable() + assert wrapper.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff + + +def test_every_pi_section_fits_480(qtbot, main_window_factory): + window = main_window_factory(desktop_mode=False) + window.resize(480, 800) + window.show() + window._show_experiment_tab() + page = window._experiment_page + assert page.minimumSizeHint().width() <= 480 # the chips + for key in SECTION_KEYS: + page.show_section(key) + QApplication.processEvents() + built = page.built_sections().get(key) + if built is None: # a builder that legitimately fails offscreen (no camera) + continue + assert built.minimumSizeHint().width() <= 480, key + assert _width_needed(built) <= 480, key + + +def test_detached_dialog_shell_stays_hidden_on_the_pi(qtbot, main_window_factory): + """The emptied ExperimentDialog is a child of the page; shown, it covers both sections.""" + window = main_window_factory(desktop_mode=False) + window.resize(480, 800) + window.show() + window._show_experiment_tab() + window._experiment_page.show_section("metadata") + QApplication.processEvents() + assert window._experiment_details.isHidden() + + +def _one_zone() -> ZoneConfiguration: + config = ZoneConfiguration() + zone = Zone( + id="a", + name="Nest", + shape=ZoneShape.RECTANGLE, + vertices=[(0.1, 0.1), (0.5, 0.5)], + color=(0, 255, 0), + ) + config.add_zone(zone) + return config + + +def test_compact_zone_editor_is_a_column_and_keeps_its_delete_label(qtbot): + editor = ZoneDialog(camera_manager=None, zone_config=_one_zone()).embed(compact=True) + qtbot.addWidget(editor) + assert _width_needed(editor) <= 480 + # Fixed at 40px it is narrower than the touch button's own padding. + assert editor._table.cellWidget(0, 3).maximumWidth() > 40 + + +def test_desktop_zone_editor_embed_is_unchanged(qtbot): + editor = ZoneDialog(camera_manager=None, zone_config=_one_zone()).embed() + qtbot.addWidget(editor) + assert editor.minimumWidth() == 1000 + assert editor._table.cellWidget(0, 3).maximumWidth() == 40 From 4d6d261b3f7dd31464875a459c8152fc715c36a0 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Tue, 20 Oct 2026 22:26:34 -0500 Subject: [PATCH 05/25] feat(pi): touch mode for the status strip and tab bar --- src/glider/gui/shell/status_strip.py | 141 +++++++++++++++-- src/glider/gui/shell/tab_bar.py | 31 ++++ tests/unit/gui/shell/test_touch_chrome.py | 180 ++++++++++++++++++++++ 3 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 tests/unit/gui/shell/test_touch_chrome.py diff --git a/src/glider/gui/shell/status_strip.py b/src/glider/gui/shell/status_strip.py index 32847575..e4306671 100644 --- a/src/glider/gui/shell/status_strip.py +++ b/src/glider/gui/shell/status_strip.py @@ -63,14 +63,33 @@ it is painted, not stencilled from a file, and every paint reads ``self.palette()`` fresh rather than a colour captured once -- the same reason the arrow it replaced never needed a colour of its own. + +**Touch mode is a reversible flag, off by default.** On the Pi (480 px wide, no +menu bar, no title bar) the strip is the first row of the header, beside a gear +button. :meth:`StatusStrip.set_touch_mode` drops what a finger cannot use and a +480 px row cannot afford -- the panel toggles and the ``Ctrl K`` hint -- shows +the dirty marker (a frameless window has no title bar to carry the modified +mark), and lets long names elide instead of widening the window. It only flips +visibility, a minimum width and the gaps between parts; ``set_touch_mode(False)`` +puts every one of them back, so the desktop strip is exactly what it was. """ from __future__ import annotations from collections.abc import Iterable, Sequence -from PyQt6.QtCore import QPointF, QRect, QRectF, QSize, Qt, pyqtSignal -from PyQt6.QtGui import QIcon, QIconEngine, QPainter, QPainterPath, QPalette, QPen, QPixmap +from PyQt6.QtCore import QEvent, QObject, QPointF, QRect, QRectF, QSize, Qt, pyqtSignal +from PyQt6.QtGui import ( + QIcon, + QIconEngine, + QMouseEvent, + QPainter, + QPainterPath, + QPaintEvent, + QPalette, + QPen, + QPixmap, +) from PyQt6.QtWidgets import ( QFrame, QHBoxLayout, @@ -126,6 +145,19 @@ #: name back gets the name. _DIRTY_MARK = "— edited" +#: Narrowest an eliding name may get in touch mode, in pixels. Deliberately tiny: +#: it is only the floor the layout can squeeze to, reached when the dirty marker, +#: a running pill and five boards all compete for 480 px, and the pill and marker +#: do not shrink. With room to spare the names take it. +_TOUCH_NAME_MIN = 32 +_TOUCH_DEVICE_MIN = 12 + +#: ``(side margin, gap between the strip's parts, gap between device chips)`` in +#: pixels, on desktop and in touch mode. Touch packs tighter because the same +#: worst case has to share 480 px with whatever styling the Pi's stylesheet adds. +_GAPS = (12, 12, 10) +_TOUCH_GAPS = (8, 6, 6) + def _fit_aspect(box: QRectF, ratio: float) -> QRectF: """*box*, inset on one axis so its width:height ratio is exactly *ratio*. @@ -319,6 +351,37 @@ def _turn_arrow(self) -> None: self.setAccessibleName(f"{self._side.capitalize()} panel, {state_word}") +class _ElidedLabel(QLabel): + """A label that can paint "abc…" in place of text wider than it is. + + The text itself is never shortened, so :meth:`text` stays the full string for + anyone reading it back. What changes is the layout's view of it: a QLabel + refuses to be narrower than its text, so :meth:`set_elide_min` sets an + explicit minimum width (which outranks the size hint) and starts painting the + elided form; ``None`` undoes both. + """ + + def __init__(self, text: str, parent: QWidget | None = None) -> None: + super().__init__(text, parent) + self._elide = False + + def set_elide_min(self, width: int | None) -> None: + self._elide = width is not None + self.setMinimumWidth(width or 0) + self.update() + + def paintEvent(self, event: QPaintEvent | None) -> None: # noqa: N802 - Qt override + if not self._elide: + super().paintEvent(event) + return + painter = QPainter(self) + self.drawFrame(painter) + area = self.contentsRect() + painter.setPen(self.palette().color(QPalette.ColorRole.WindowText)) + text = self.fontMetrics().elidedText(self.text(), Qt.TextElideMode.ElideRight, area.width()) + painter.drawText(area, int(self.alignment()), text) + + def _as_device(device: Sequence[str]) -> tuple[str, str, str]: """``(name, state)`` or ``(name, state, detail)`` as a full triple. @@ -349,6 +412,8 @@ class StatusStrip(QFrame): left_toggled = pyqtSignal() right_toggled = pyqtSignal() palette_requested = pyqtSignal() + #: The pill was tapped. Emitted in any mode; only the Pi header listens. + pill_clicked = pyqtSignal() def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) @@ -359,19 +424,20 @@ def __init__(self, parent: QWidget | None = None) -> None: self._run_state = "idle" self._run_detail = "" + self._touch = False + self._is_dirty = False + self._desktop_visible: dict[QWidget, bool] = {} self._device_names: list[str] = [] self._chips: list[QFrame] = [] self._dots: list[QLabel] = [] self._names: list[QLabel] = [] - outer = QHBoxLayout(self) - outer.setContentsMargins(12, 0, 12, 0) - outer.setSpacing(12) + outer = self._outer = QHBoxLayout(self) self._left_toggle = self._build_toggle("left", self.left_toggled) outer.addWidget(self._left_toggle) - self._name = QLabel(_UNNAMED, self) + self._name = _ElidedLabel(_UNNAMED, self) self._name.setObjectName("statusStripExperiment") outer.addWidget(self._name) @@ -392,6 +458,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._pill.setObjectName("statusStripPill") self._pill.setProperty("state", "idle") self._pill.setVisible(False) + self._pill.installEventFilter(self) outer.addWidget(self._pill) outer.addStretch(1) @@ -401,7 +468,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._devices.setFrameShape(QFrame.Shape.NoFrame) self._device_layout = QHBoxLayout(self._devices) self._device_layout.setContentsMargins(0, 0, 0, 0) - self._device_layout.setSpacing(10) + self._set_gaps(_GAPS) outer.addWidget(self._devices) self._palette_hint = QToolButton(self) @@ -425,6 +492,41 @@ def _build_toggle(self, side: str, signal) -> _PanelToggle: button.clicked.connect(lambda _checked=False: signal.emit()) return button + # ------------------------------------------------------------ touch mode + + def _set_gaps(self, gaps: tuple[int, int, int]) -> None: + margin, gap, device_gap = gaps + self._outer.setContentsMargins(margin, 0, margin, 0) + self._outer.setSpacing(gap) + self._device_layout.setSpacing(device_gap) + + def set_touch_mode(self, enabled: bool) -> None: + """Fit the strip to the Pi header, or back to the desktop. + + Touch mode hides the panel toggles and the ``Ctrl K`` hint, shows the + dirty marker, and lets the experiment name and the device names elide so + a long name and five boards still fit in 480 px. Reversible: ``False`` + restores every one of them, the hidden controls to whatever visibility + they had when touch mode was switched on. + """ + enabled = bool(enabled) + if enabled != self._touch: + self._touch = enabled + # Put back what the desktop had, not "shown": the window hides the + # panel toggles on every page but the Dashboard, and forcing them + # visible here would undo that on the way out. + for widget in (self._left_toggle, self._right_toggle, self._palette_hint): + if enabled: + self._desktop_visible[widget] = not widget.isHidden() + widget.setVisible(False) + else: + widget.setVisible(self._desktop_visible.pop(widget, True)) + self._set_gaps(_TOUCH_GAPS if enabled else _GAPS) + self._dirty.setVisible(self._touch and self._is_dirty) + self._name.set_elide_min(_TOUCH_NAME_MIN if self._touch else None) + for label in self._names: + label.set_elide_min(_TOUCH_DEVICE_MIN if self._touch else None) + # ------------------------------------------------------------ experiment def set_experiment(self, name: str, dirty: bool = False) -> None: @@ -435,12 +537,15 @@ def set_experiment(self, name: str, dirty: bool = False) -> None: session rather than a broken one. """ self._name.setText(name.strip() or _UNNAMED) - # Deliberately not shown. The marker is kept and kept up to date so + # Not shown on desktop. The marker is kept and kept up to date so # dirty_label() still reports honestly to anything that asks, but a # strip that appends "- edited" to a name the moment you type in it is # telling you something you already know. Nothing is lost: _check_save - # still stops a close, a New or an Open over unsaved work. - self._dirty.setVisible(False) + # still stops a close, a New or an Open over unsaved work. Touch mode + # is the exception: a frameless kiosk window has no title-bar modified + # mark, so this is the only place the Pi can say "unsaved". + self._is_dirty = bool(dirty) + self._dirty.setVisible(self._touch and self._is_dirty) def name_label(self) -> QLabel: """The label carrying the experiment name.""" @@ -500,6 +605,18 @@ def pill(self) -> QLabel: """The run-state pill.""" return self._pill + def eventFilter(self, watched: QObject | None, event: QEvent | None) -> bool: # noqa: N802 + """Turn a left-button release over the pill into :attr:`pill_clicked`.""" + if ( + watched is self._pill + and isinstance(event, QMouseEvent) + and event.type() == QEvent.Type.MouseButtonRelease + and event.button() == Qt.MouseButton.LeftButton + and self._pill.rect().contains(event.position().toPoint()) + ): + self.pill_clicked.emit() + return super().eventFilter(watched, event) + # ---------------------------------------------------------------- devices def set_devices(self, devices: Iterable[Sequence[str]]) -> None: @@ -551,8 +668,10 @@ def _rebuild(self, items: list[tuple[str, str, str]]) -> None: dot.setFixedSize(_DOT, _DOT) layout.addWidget(dot) - label = QLabel(name, chip) + label = _ElidedLabel(name, chip) label.setObjectName("statusStripDeviceName") + if self._touch: + label.set_elide_min(_TOUCH_DEVICE_MIN) layout.addWidget(label) self._apply_device(chip, dot, name, raw, detail) diff --git a/src/glider/gui/shell/tab_bar.py b/src/glider/gui/shell/tab_bar.py index e10f8fb4..b6f3935c 100644 --- a/src/glider/gui/shell/tab_bar.py +++ b/src/glider/gui/shell/tab_bar.py @@ -22,6 +22,11 @@ Status belongs on :class:`~glider.gui.shell.status_strip.StatusStrip`, one row below, which is built to carry it. +**Touch mode** (:meth:`ShellTabBar.set_touch_mode`, off by default) is the Pi's +version of the row: no Analyze tab, and the other three share the full width +instead of huddling in the middle. It is reversible, and ``False`` restores the +centred desktop row. + **No colour is set from Python here**, following the rest of ``shell/``: every part carries an ``objectName`` and ``desktop.qss`` owns the appearance. """ @@ -33,6 +38,7 @@ QButtonGroup, QFrame, QHBoxLayout, + QSizePolicy, QToolButton, QWidget, ) @@ -86,6 +92,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.setMinimumHeight(TAB_BAR_HEIGHT) row = QHBoxLayout(self) + self._row = row row.setContentsMargins(8, 0, 8, 0) row.setSpacing(4) row.addStretch(1) @@ -110,9 +117,33 @@ def __init__(self, parent: QWidget | None = None) -> None: self._buttons[key] = button row.addStretch(1) + self._desktop_policy = self._buttons[TAB_KEYS[0]].sizePolicy() + self._touch_policy = QSizePolicy( + QSizePolicy.Policy.Expanding, self._desktop_policy.verticalPolicy() + ) self._buttons[TAB_KEYS[0]].setChecked(True) self._current = TAB_KEYS[0] + # ------------------------------------------------------------ touch mode + + def set_touch_mode(self, enabled: bool) -> None: + """Drop Analyze and stretch the other tabs across the row, or undo it. + + The two end stretches go to zero and the tabs take stretch one, so the + tabs are the only thing that can take the width (a zero-stretch spacer + still expands, and would otherwise claim a share of it); Expanding and + equal stretch make the shares equal. + """ + for key, button in self._buttons.items(): + if key == "analyze": + button.setVisible(not enabled) + else: + button.setSizePolicy(self._touch_policy if enabled else self._desktop_policy) + spacer, tab = (0, 1) if enabled else (1, 0) + last = self._row.count() - 1 + for index in range(last + 1): + self._row.setStretch(index, spacer if index in (0, last) else tab) + # ------------------------------------------------------------- selection def _on_clicked(self, key: str) -> None: diff --git a/tests/unit/gui/shell/test_touch_chrome.py b/tests/unit/gui/shell/test_touch_chrome.py new file mode 100644 index 00000000..3cd303f1 --- /dev/null +++ b/tests/unit/gui/shell/test_touch_chrome.py @@ -0,0 +1,180 @@ +"""Touch mode for the status strip and the tab bar. + +The Pi's two-row header reuses both widgets, so each grows a reversible +``set_touch_mode``. These tests pin what it changes on the Pi (480 px wide) and +that switching it back off restores the desktop look. +""" + +from __future__ import annotations + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QLabel + +from glider.gui.shell.status_strip import StatusStrip +from glider.gui.shell.tab_bar import ShellTabBar + +#: The header's first row, less the gear button beside the strip. +_STRIP_BUDGET = 480 - 44 + +_FIVE_BOARDS = [(f"board_{i}", "ok", "") for i in range(5)] + + +def _shown(qtbot) -> StatusStrip: + """A strip that is on screen, so Qt re-lays it out when a flag flips. + + An unshown widget never gets the layout requests that carry a child's new + size up to its parents, so a test that flips touch mode and then measures + would read the size from before the flip. + """ + strip = StatusStrip() + qtbot.addWidget(strip) + with qtbot.waitExposed(strip): + strip.show() + return strip + + +def test_touch_strip_hides_desktop_controls_and_shows_dirty(qtbot): + strip = StatusStrip() + qtbot.addWidget(strip) + strip.set_touch_mode(True) + strip.set_experiment("box_pi_side", dirty=True) + assert not strip.left_toggle().isVisibleTo(strip) + assert not strip.right_toggle().isVisibleTo(strip) + assert not strip.palette_hint().isVisibleTo(strip) + assert strip.dirty_label().isVisibleTo(strip) + + +def test_touch_strip_fits_480_with_a_long_name_and_five_boards(qtbot): + strip = StatusStrip() + qtbot.addWidget(strip) + strip.set_touch_mode(True) + strip.set_experiment("x" * 60) + strip.set_devices(_FIVE_BOARDS) + assert strip.minimumSizeHint().width() <= _STRIP_BUDGET + + +def test_touch_strip_fits_480_while_recording_and_edited(qtbot): + """The worst case: dirty marker and a running pill beside five boards.""" + strip = StatusStrip() + qtbot.addWidget(strip) + strip.set_touch_mode(True) + strip.set_experiment("x" * 60, dirty=True) + strip.set_run_state("recording", "12:41") + strip.set_devices(_FIVE_BOARDS) + assert strip.minimumSizeHint().width() <= _STRIP_BUDGET + + +def test_touch_mode_reaches_boards_that_were_already_listed(qtbot): + strip = _shown(qtbot) + strip.set_experiment("x" * 60, dirty=True) + strip.set_run_state("recording", "12:41") + strip.set_devices(_FIVE_BOARDS) + strip.set_touch_mode(True) + qtbot.wait(0) + assert strip.minimumSizeHint().width() <= _STRIP_BUDGET + + +def test_touch_strip_keeps_full_names_for_anything_that_reads_them(qtbot): + strip = StatusStrip() + qtbot.addWidget(strip) + strip.set_touch_mode(True) + strip.set_experiment("x" * 60) + strip.set_devices([("a_rather_long_board_name", "ok", "")]) + assert strip.name_label().text() == "x" * 60 + assert strip.device_names() == ["a_rather_long_board_name"] + assert "a_rather_long_board_name" in strip.device_chips()[0].toolTip() + + +def test_touch_strip_is_reversible(qtbot): + strip = _shown(qtbot) + strip.set_experiment("x" * 60, dirty=True) + strip.set_devices(_FIVE_BOARDS) + desktop_width = strip.minimumSizeHint().width() + + strip.set_touch_mode(True) + qtbot.wait(0) + assert strip.minimumSizeHint().width() < desktop_width + strip.set_touch_mode(False) + qtbot.wait(0) + + assert strip.left_toggle().isVisibleTo(strip) + assert strip.right_toggle().isVisibleTo(strip) + assert strip.palette_hint().isVisibleTo(strip) + assert not strip.dirty_label().isVisibleTo(strip) # desktop never shows it + assert strip.minimumSizeHint().width() == desktop_width + + +def test_leaving_touch_mode_keeps_a_toggle_the_window_had_hidden(qtbot): + """MainWindow hides the panel toggles off the Dashboard; the way out of + touch mode must not show them there.""" + strip = StatusStrip() + qtbot.addWidget(strip) + strip.left_toggle().setVisible(False) + + strip.set_touch_mode(True) + strip.set_touch_mode(True) # twice must not record "hidden" as the desktop state + strip.set_touch_mode(False) + + assert not strip.left_toggle().isVisibleTo(strip) + assert strip.right_toggle().isVisibleTo(strip) + assert strip.palette_hint().isVisibleTo(strip) + + +def test_boards_built_in_touch_mode_lose_their_floor_on_the_way_back(qtbot): + strip = StatusStrip() + qtbot.addWidget(strip) + strip.set_touch_mode(True) + strip.set_devices(_FIVE_BOARDS) + names = strip.findChildren(QLabel, "statusStripDeviceName") + assert len(names) == 5 + assert all(label.minimumWidth() > 0 for label in names) + + strip.set_touch_mode(False) + + assert all(label.minimumWidth() == 0 for label in names) + assert strip.name_label().minimumWidth() == 0 + + +def test_pill_click_emits(qtbot): + strip = StatusStrip() + qtbot.addWidget(strip) + strip.set_run_state("recording", "12:41") + with qtbot.waitSignal(strip.pill_clicked): + qtbot.mouseClick(strip.pill(), Qt.MouseButton.LeftButton) + + +def test_only_a_left_click_on_the_pill_emits(qtbot): + strip = StatusStrip() + qtbot.addWidget(strip) + strip.set_run_state("recording", "12:41") + with qtbot.assertNotEmitted(strip.pill_clicked): + qtbot.mouseClick(strip.pill(), Qt.MouseButton.RightButton) + + +def test_touch_tab_bar_drops_analyze_and_fills(qtbot): + bar = ShellTabBar() + qtbot.addWidget(bar) + bar.resize(480, 46) + bar.set_touch_mode(True) + bar.show() + buttons = bar.buttons() + assert not buttons["analyze"].isVisibleTo(bar) + widths = {buttons[k].width() for k in ("experiment", "dashboard", "run")} + assert max(widths) - min(widths) <= 2 + assert sum(buttons[k].width() for k in ("experiment", "dashboard", "run")) >= 480 - 2 * 8 - 8 + bar.set_touch_mode(False) + assert buttons["analyze"].isVisibleTo(bar) + + +def test_tab_bar_touch_mode_is_reversible(qtbot): + bar = ShellTabBar() + qtbot.addWidget(bar) + bar.resize(900, 46) + bar.show() + before = {k: (b.geometry(), b.sizePolicy()) for k, b in bar.buttons().items()} + + bar.set_touch_mode(True) + bar.set_touch_mode(False) + bar.layout().activate() + + assert {k: (b.geometry(), b.sizePolicy()) for k, b in bar.buttons().items()} == before From 9f9ff33bf7d48a5f238595b7225b8afc5056db1a Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Tue, 20 Oct 2026 22:38:59 -0500 Subject: [PATCH 06/25] feat(pi): run page, device grid and notice bar widgets --- src/glider/gui/dashboard/panels/field_tags.py | 36 +++- .../gui/dashboard/panels/run_control_panel.py | 14 +- src/glider/gui/dashboard/panels/run_header.py | 11 +- src/glider/gui/runner/device_grid.py | 183 ++++++++++++++++++ src/glider/gui/runner/notice_bar.py | 48 +++++ src/glider/gui/runner/run_page.py | 78 ++++++++ .../gui/dashboard/panels/test_field_tags.py | 54 ++++++ .../panels/test_run_control_panel.py | 29 ++- .../gui/dashboard/panels/test_run_header.py | 30 ++- tests/unit/gui/runner/test_device_grid.py | 87 +++++++++ tests/unit/gui/runner/test_notice_bar.py | 59 ++++++ tests/unit/gui/runner/test_run_page.py | 129 ++++++++++++ .../unit/gui/test_device_card_link_status.py | 14 +- 13 files changed, 747 insertions(+), 25 deletions(-) create mode 100644 src/glider/gui/runner/device_grid.py create mode 100644 src/glider/gui/runner/notice_bar.py create mode 100644 src/glider/gui/runner/run_page.py create mode 100644 tests/unit/gui/runner/test_device_grid.py create mode 100644 tests/unit/gui/runner/test_notice_bar.py create mode 100644 tests/unit/gui/runner/test_run_page.py diff --git a/src/glider/gui/dashboard/panels/field_tags.py b/src/glider/gui/dashboard/panels/field_tags.py index 9299e85d..18d13a89 100644 --- a/src/glider/gui/dashboard/panels/field_tags.py +++ b/src/glider/gui/dashboard/panels/field_tags.py @@ -4,6 +4,9 @@ Return or focus loss commits (``value_edited``), Escape cancels. Locking, a field change and ``commit_pending`` commit an open edit first, so typed text is never lost. Locked while a run is live. + +``columns=n`` lays the tags in an n-column grid instead of one row (the Pi's +480 px screen), with the trailing label on its own full-width row. """ from __future__ import annotations @@ -11,7 +14,7 @@ from dataclasses import dataclass from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget +from PyQt6.QtWidgets import QGridLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget _MAX_VALUE_CHARS = 40 @@ -37,19 +40,25 @@ def keyPressEvent(self, event): # noqa: N802 (Qt override) class FieldTags(QWidget): value_edited = pyqtSignal(str, str) - def __init__(self, parent=None): + def __init__(self, parent=None, *, columns: int = 0): super().__init__(parent) + self._columns = columns self._fields: list[TagField] = [] self._chips: dict[str, QPushButton] = {} self._editor: _Editor | None = None self._editing_key = "" self._locked = False - self._row = QHBoxLayout(self) + self._row: QGridLayout | QHBoxLayout = QGridLayout(self) if columns else QHBoxLayout(self) self._row.setContentsMargins(0, 0, 0, 0) self._row.setSpacing(6) self._trailing = QLabel() self._trailing.setProperty("textRole", "muted") self._trailing.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + if columns: + # A run-folder path has no spaces to wrap at; let it clip (the + # tooltip has the whole text) rather than widen the 480 px page. + self._trailing.setWordWrap(True) + self._trailing.setMinimumWidth(1) # --- public API --- @@ -95,7 +104,7 @@ def _rebuild(self) -> None: w.setParent(None) w.deleteLater() self._chips.clear() - for f in self._fields: + for i, f in enumerate(self._fields): chip = QPushButton(self._chip_text(f)) chip.setProperty("fieldTag", True) chip.setProperty("need", f.required and not f.value) @@ -104,8 +113,15 @@ def _rebuild(self) -> None: chip.setCursor(Qt.CursorShape.IBeamCursor) chip.clicked.connect(lambda _=False, key=f.key: self._open_editor(key)) self._chips[f.key] = chip - self._row.addWidget(chip) - self._row.addWidget(self._trailing, 1) + if isinstance(self._row, QGridLayout): + self._row.addWidget(chip, *divmod(i, self._columns)) + else: + self._row.addWidget(chip) + if isinstance(self._row, QGridLayout): + rows = -(-len(self._fields) // self._columns) + self._row.addWidget(self._trailing, rows, 0, 1, self._columns) + else: + self._row.addWidget(self._trailing, 1) @staticmethod def _chip_text(f: TagField) -> str: @@ -128,9 +144,13 @@ def _open_editor(self, key: str) -> None: # editingFinished covers Return and focus loss. editor.editingFinished.connect(lambda: self._commit(key)) editor.cancelled.connect(self._close_editor) - index = self._row.indexOf(chip) chip.hide() - self._row.insertWidget(index, editor) + if isinstance(self._row, QGridLayout): + # The editor takes the chip's grid cell. + row, col = self._row.getItemPosition(self._row.indexOf(chip))[:2] + self._row.addWidget(editor, row, col) + else: + self._row.insertWidget(self._row.indexOf(chip), editor) self._editor = editor self._editing_key = key editor.setFocus() diff --git a/src/glider/gui/dashboard/panels/run_control_panel.py b/src/glider/gui/dashboard/panels/run_control_panel.py index da90a745..3e9f6683 100644 --- a/src/glider/gui/dashboard/panels/run_control_panel.py +++ b/src/glider/gui/dashboard/panels/run_control_panel.py @@ -44,9 +44,13 @@ class RunControlPanel(QWidget): # READY / NOT READY pill so it never contradicts a disabled Start. readiness_changed = pyqtSignal(bool) - def __init__(self, core: "GliderCore", parent=None): + def __init__(self, core: "GliderCore", parent=None, *, touch: bool = False): + """``touch``: the Pi's run box. The page shows the clock itself, so this + one hides its own label (``elapsed_updated`` still fires), and the + hints name the ⚙ menu rather than the desktop's Hardware menu.""" super().__init__(parent) self._core = core + self._touch = touch self._experiment_start_time: float | None = None self._state_name = "IDLE" @@ -68,6 +72,8 @@ def _setup_ui(self): self._timer = QLabel("00:00.00") self._timer.setProperty("timer", True) + if self._touch: + self._timer.hide() layout.addWidget(self._timer) self.elapsed_updated.connect(self._timer.setText) @@ -197,7 +203,11 @@ def _refresh_run_readiness(self) -> None: if missing: self._not_ready_hint.setText(f"Fill in {missing} to start.") elif not r.board_ready: - self._not_ready_hint.setText("Connect the board to start (Hardware > Connect All).") + self._not_ready_hint.setText( + "Connect a board to start (⚙ › Connect boards…)." + if self._touch + else "Connect the board to start (Hardware > Connect All)." + ) else: self._not_ready_hint.setText("Add a Start Experiment node to the flow to start.") self._not_ready_hint.setVisible(not ready and not live) diff --git a/src/glider/gui/dashboard/panels/run_header.py b/src/glider/gui/dashboard/panels/run_header.py index c39935fa..3c2867cd 100644 --- a/src/glider/gui/dashboard/panels/run_header.py +++ b/src/glider/gui/dashboard/panels/run_header.py @@ -51,9 +51,12 @@ class RunHeader(QWidget): show_presets_folder_requested = pyqtSignal() run_values_changed = pyqtSignal() - def __init__(self, core, parent=None): + def __init__(self, core, parent=None, *, touch: bool = False): + """``touch``: the Pi's header. No title or preset picker; the field tags + sit in two columns.""" super().__init__(parent) self._core = core + self._touch = touch self._state_name = "IDLE" self._missing: str | None = None self._ready = True # board + flow, fed by RunControlPanel.readiness_changed @@ -79,6 +82,8 @@ def __init__(self, core, parent=None): self._picker.setMenu(self._menu) top.addWidget(self._title) top.addWidget(self._picker) + if touch: + self._picker.hide() # Takes the slack, so the title, ▾ and pill keep their size hints. top.addStretch() self._run_label = QLabel() @@ -89,7 +94,7 @@ def __init__(self, core, parent=None): top.addWidget(self._pill) col.addLayout(top) - self._tags = FieldTags() + self._tags = FieldTags(columns=2 if touch else 0) self._tags.value_edited.connect(self._on_tag_edited) col.addWidget(self._tags) @@ -105,7 +110,7 @@ def refresh(self) -> None: self._title.blockSignals(False) self._fit_title() preset = self._is_preset() - self._title.setVisible(not preset) + self._title.setVisible(not preset and not self._touch) self._picker.setText(f"{_type(md) or md.name} ▾" if preset else "▾") self._tags.set_fields(self.tag_fields()) self._apply_state() diff --git a/src/glider/gui/runner/device_grid.py b/src/glider/gui/runner/device_grid.py new file mode 100644 index 00000000..0ec3ceb7 --- /dev/null +++ b/src/glider/gui/runner/device_grid.py @@ -0,0 +1,183 @@ +"""The Pi Run page's device status cards: two columns in a vertical scroll area. + +Each card is a name, a type, and a state badge over a link word ("Ready", +"Disconnected"...). The badge repaints on a timer while a run is live +(``set_live``), and once more when the run ends. The two colours come from +``device_state_display`` / ``link_status_color`` at paint time, so they are the +one inline style left here; everything else is styled by the ``deviceCard`` +property and the object names. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QScrollArea, + QScroller, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from glider.core.config import get_config +from glider.gui.device_status import device_state_display, link_status_color, link_status_text +from glider.gui.styles import colors, radius + +if TYPE_CHECKING: + from glider.core.glider_core import GliderCore + +_COLUMNS = 2 +_EMPTY_TEXT = "No devices yet. Add a board on the Dashboard › Hardware tab." + + +class DeviceCardGrid(QWidget): + def __init__(self, core: GliderCore, parent=None): + super().__init__(parent) + self._core = core + self._cards: dict[str, QWidget] = {} + + self._empty = QLabel(_EMPTY_TEXT) + self._empty.setWordWrap(True) + self._empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._empty.setProperty("textRole", "muted") + self._grid = QGridLayout() + self._grid.setSpacing(8) + for col in range(_COLUMNS): + self._grid.setColumnStretch(col, 1) + + body = QWidget() + column = QVBoxLayout(body) + column.setContentsMargins(0, 0, 0, 0) + column.setSpacing(8) + column.addWidget(self._empty) + column.addLayout(self._grid) + column.addStretch() + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.Shape.NoFrame) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._scroll.setWidget(body) + # Let the page behind show through, as the old runner list did. + self._scroll.viewport().setAutoFillBackground(False) + body.setAutoFillBackground(False) + QScroller.grabGesture( + self._scroll.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture + ) + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(self._scroll) + + self._poll = QTimer(self) + self._poll.setInterval(get_config().timing.device_refresh_interval_ms) + self._poll.timeout.connect(self.update_states) + + self.refresh_devices() + + # --- public API --- + + def cards(self) -> dict[str, QWidget]: + return self._cards + + def refresh_devices(self) -> None: + """Rebuild the cards from the hardware manager's devices.""" + for card in self._cards.values(): + card.setParent(None) + card.deleteLater() + self._cards.clear() + devices = self._core.hardware_manager.devices + self._empty.setVisible(not devices) + for i, (device_id, device) in enumerate(devices.items()): + card = self._create_card(device_id, device) + self._grid.addWidget(card, *divmod(i, _COLUMNS)) + self._cards[device_id] = card + + def update_states(self) -> None: + """Repaint each card's state badge and link word.""" + for device_id, card in self._cards.items(): + device = self._core.hardware_manager.get_device(device_id) + if device is not None: + self._paint(card, device) + + def set_live(self, live: bool) -> None: + """Poll the states while a run is live; repaint once when it ends.""" + if live: + self._poll.start() + else: + self._poll.stop() + self.update_states() + + # --- internals --- + + def _create_card(self, device_id: str, device) -> QWidget: + card = QWidget() + card.setProperty("deviceCard", True) + card.setFixedHeight(80) + layout = QHBoxLayout(card) + layout.setContentsMargins(12, 8, 12, 8) + layout.setSpacing(8) + + info = QVBoxLayout() + info.setSpacing(2) + for text, name in ( + (device_id, "deviceCardName"), + (getattr(device, "device_type", "Unknown"), "deviceCardType"), + ): + label = QLabel(text) + label.setObjectName(name) + label.setToolTip(text) + # Clip a long name rather than let it widen the column. + label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + info.addWidget(label) + layout.addLayout(info, 1) + + # 80 for both: the link words ("Disconnected", "Reconnecting…") are + # longer than the "Ready"/"---" a narrower box was sized for, and a + # status that paints as "Disconnec" is not one anybody can act on. + status = QWidget() + status.setFixedSize(80, 50) + status_layout = QVBoxLayout(status) + status_layout.setContentsMargins(0, 0, 0, 0) + status_layout.setSpacing(2) + card._state_label = QLabel() + card._ready_label = QLabel() + for label in (card._state_label, card._ready_label): + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + status_layout.addWidget(label) + layout.addWidget(status) + + self._paint(card, device) + return card + + @staticmethod + def _paint(card: QWidget, device) -> None: + # link_state, not _initialized: "has this been set up" is a question a + # peripheral that walked out of range never stops answering yes to, and + # this card repaints on a timer, so it asserted a green Ready while the + # status strip showed a red dot for the same device. + link = getattr(device, "link_state", None) + text, color, font_size = device_state_display(device) + card._state_label.setText(text) + card._state_label.setStyleSheet(f""" + QLabel {{ + background-color: {color}; + color: {colors.TEXT_PRIMARY}; + font-size: {font_size}; + font-weight: bold; + border-radius: {radius.MEDIUM}px; + padding: 4px 8px; + border: none; + line-height: 1.2; + }} + """) + card._ready_label.setText(link_status_text(link)) + card._ready_label.setStyleSheet( + f"font-size: 10px; color: {link_status_color(link)};" + " background: transparent; border: none;" + ) diff --git a/src/glider/gui/runner/notice_bar.py b/src/glider/gui/runner/notice_bar.py new file mode 100644 index 00000000..8c855872 --- /dev/null +++ b/src/glider/gui/runner/notice_bar.py @@ -0,0 +1,48 @@ +"""A one-line-or-two notice bar for the Pi's header. + +Shows a short title and message at a level (``info`` | ``warning`` | +``critical``), styled by the ``level`` property. A tap dismisses it. A lower +level never replaces a visible ``critical`` notice: an "UNSAFE STATE" warning +has to stay up until somebody has seen it, not be buried by a "Saved". +""" + +from __future__ import annotations + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QMouseEvent +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel + +_RANK = {"info": 0, "warning": 1, "critical": 2} + + +class NoticeBar(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("piNoticeBar") + self.setProperty("level", "info") + self._label = QLabel() + self._label.setWordWrap(True) + self._label.setTextFormat(Qt.TextFormat.PlainText) + row = QHBoxLayout(self) + row.addWidget(self._label) + self.hide() + + def show_notice(self, title: str, message: str, level: str) -> None: + if self.property("level") == "critical" and not self.isHidden() and _RANK[level] < 2: + return + self._label.setText(f"{title}: {message}") + self.setProperty("level", level) + self.style().unpolish(self) + self.style().polish(self) + self.show() + + def text(self) -> str: + return self._label.text() + + def dismiss(self) -> None: + self.hide() + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802 (Qt override) + if event.button() == Qt.MouseButton.LeftButton: + self.dismiss() + super().mouseReleaseEvent(event) diff --git a/src/glider/gui/runner/run_page.py b/src/glider/gui/runner/run_page.py new file mode 100644 index 00000000..2240eb2d --- /dev/null +++ b/src/glider/gui/runner/run_page.py @@ -0,0 +1,78 @@ +"""The Pi's Run page: header, big clock, device cards, camera slot, run controls. + +Self-contained. MainWindow owns the session, the core and the camera; it feeds +this page ``update_state`` / ``refresh``, drops a camera widget into +``camera_slot`` and listens to the four forwarded run signals. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget + +from glider.gui.dashboard.panels.run_control_panel import RunControlPanel +from glider.gui.dashboard.panels.run_header import RunHeader +from glider.gui.runner.device_grid import DeviceCardGrid + +if TYPE_CHECKING: + from glider.core.glider_core import GliderCore + + +class PiRunPage(QWidget): + start_requested = pyqtSignal() + stop_requested = pyqtSignal() + pause_requested = pyqtSignal() + resume_requested = pyqtSignal() + + def __init__(self, core: GliderCore, parent=None): + super().__init__(parent) + self.setObjectName("piRunPage") + + self.header = RunHeader(core, touch=True) + self.clock = QLabel("00:00.00") + self.clock.setObjectName("piRunClock") + self.clock.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.clock.setProperty("live", False) + self.devices = DeviceCardGrid(core) + self.camera_slot = QWidget() + QVBoxLayout(self.camera_slot).setContentsMargins(0, 0, 0, 0) + self.camera_slot.hide() + self.control = RunControlPanel(core, touch=True) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + layout.addWidget(self.header) + layout.addWidget(self.clock) + layout.addWidget(self.devices, 1) + layout.addWidget(self.camera_slot) + layout.addWidget(self.control) + + self.control.elapsed_updated.connect(self.clock.setText) + self.control.readiness_changed.connect(self.header.set_ready) + # The control computed its first readiness before this connection. + self.header.set_ready(self.control.is_ready()) + self.control.start_requested.connect(self.start_requested) + self.control.stop_requested.connect(self.stop_requested) + self.control.pause_requested.connect(self.pause_requested) + self.control.resume_requested.connect(self.resume_requested) + + def update_state(self, state_name: str) -> None: + self.header.update_state(state_name) + self.control.update_state(state_name) + live = state_name == "RUNNING" + self.devices.set_live(live) + self.clock.setProperty("live", live) + self.clock.style().unpolish(self.clock) + self.clock.style().polish(self.clock) + + def refresh(self) -> None: + """The session changed: reread the title block, readiness and devices.""" + self.header.refresh() + self.control.refresh() + self.devices.refresh_devices() + + def set_camera_visible(self, visible: bool) -> None: + self.camera_slot.setVisible(visible) diff --git a/tests/unit/gui/dashboard/panels/test_field_tags.py b/tests/unit/gui/dashboard/panels/test_field_tags.py index 4b8f89f9..9a50b153 100644 --- a/tests/unit/gui/dashboard/panels/test_field_tags.py +++ b/tests/unit/gui/dashboard/panels/test_field_tags.py @@ -99,3 +99,57 @@ def test_rebuild_leaves_only_current_chips(tags): tags.set_fields([TagField("a", "A"), TagField("b", "B"), TagField("c", "C")]) tags.set_fields([TagField("a", "A")]) assert len(tags.findChildren(QPushButton)) == 1 + + +def _three(qtbot): + w = FieldTags(columns=2) + qtbot.addWidget(w) + w.set_fields( + [ + TagField("dam_id", "Dam ID", "", True), + TagField("pnd", "Postnatal day"), + TagField("litter", "Litter size"), + ] + ) + return w + + +def test_two_column_grid_keeps_editing(qtbot): + w = _three(qtbot) + grid = w.layout() + assert grid.getItemPosition(grid.indexOf(w.chip("litter")))[:2] == (1, 0) + w.chip("dam_id").click() + assert w.editor() is not None + assert grid.getItemPosition(grid.indexOf(w.editor()))[:2] == (0, 0) + + +@pytest.mark.parametrize("key, cell", [("pnd", (0, 1)), ("litter", (1, 0))]) +def test_the_editor_takes_the_clicked_chips_cell(qtbot, key, cell): + w = _three(qtbot) + w.chip(key).click() + grid = w.layout() + assert grid.getItemPosition(grid.indexOf(w.editor()))[:2] == cell + + +def test_two_column_grid_puts_the_trailing_label_on_its_own_row(qtbot): + w = _three(qtbot) + grid = w.layout() + assert grid.getItemPosition(grid.indexOf(w._trailing)) == (2, 0, 1, 2) + assert grid.getItemPosition(grid.indexOf(w.chip("pnd")))[:2] == (0, 1) + + +def test_grid_survives_a_rebuild_and_a_cancelled_edit(qtbot): + w = _three(qtbot) + w.chip("pnd").click() + qtbot.keyClick(w.editor(), Qt.Key.Key_Escape) + assert w.editor() is None and w.chip("pnd").isVisibleTo(w) + w.set_fields([TagField("only", "Only one")]) + grid = w.layout() + assert grid.getItemPosition(grid.indexOf(w.chip("only")))[:2] == (0, 0) + assert grid.getItemPosition(grid.indexOf(w._trailing)) == (1, 0, 1, 2) + + +def test_default_is_still_a_single_row(qtbot, tags): + from PyQt6.QtWidgets import QHBoxLayout + + assert isinstance(tags.layout(), QHBoxLayout) diff --git a/tests/unit/gui/dashboard/panels/test_run_control_panel.py b/tests/unit/gui/dashboard/panels/test_run_control_panel.py index 13d4cf54..48987b21 100644 --- a/tests/unit/gui/dashboard/panels/test_run_control_panel.py +++ b/tests/unit/gui/dashboard/panels/test_run_control_panel.py @@ -6,7 +6,7 @@ pytestmark = pytest.mark.usefixtures("qtbot") -def _panel(qtbot, mock_core, *, board=False, has_start=False, name="Exp"): +def _panel(qtbot, mock_core, *, board=False, has_start=False, name="Exp", touch=False): mock_core.hardware_manager.is_any_board_connected = lambda: board session = ExperimentSession() nodes = [NodeConfig(id="s", node_type="StartExperiment")] if has_start else [] @@ -14,7 +14,7 @@ def _panel(qtbot, mock_core, *, board=False, has_start=False, name="Exp"): session.metadata.name = name mock_core.session = session mock_core.last_flow_duration_s = None - p = RunControlPanel(mock_core) + p = RunControlPanel(mock_core, touch=touch) qtbot.addWidget(p) return p @@ -193,3 +193,28 @@ def test_run_clock_uses_the_ui_font_with_steady_digits(qtbot, mock_core): metrics = panel._timer.fontMetrics() widths = {metrics.horizontalAdvance(d) for d in "0123456789"} assert len(widths) == 1, widths + + +def test_touch_panel_hides_its_clock_and_names_the_gear(qtbot, mock_core): + panel = _panel(qtbot, mock_core, board=False, has_start=True, touch=True) + mock_core.session.hardware.boards.append(object()) # a board is configured, none connected + panel.refresh() + assert not panel._timer.isVisibleTo(panel) + assert "⚙ › Connect boards" in panel._not_ready_hint.text() + assert panel._not_ready_hint.text() == "Connect a board to start (⚙ › Connect boards…)." + assert panel._not_ready_hint.isVisibleTo(panel) + + +def test_touch_panel_still_publishes_the_elapsed_time(qtbot, mock_core): + panel = _panel(qtbot, mock_core, touch=True) + with qtbot.waitSignal(panel.elapsed_updated, timeout=1000) as blocker: + panel._set_timer_display(12.34) + assert blocker.args == ["00:12.34"] + + +def test_desktop_panel_keeps_its_clock_and_hint(qtbot, mock_core): + panel = _panel(qtbot, mock_core, board=False, has_start=True) + mock_core.session.hardware.boards.append(object()) # a board is configured + panel.refresh() + assert panel._timer.isVisibleTo(panel) + assert "Hardware > Connect All" in panel._not_ready_hint.text() diff --git a/tests/unit/gui/dashboard/panels/test_run_header.py b/tests/unit/gui/dashboard/panels/test_run_header.py index 49a085ba..ce70cc61 100644 --- a/tests/unit/gui/dashboard/panels/test_run_header.py +++ b/tests/unit/gui/dashboard/panels/test_run_header.py @@ -6,13 +6,13 @@ pytestmark = pytest.mark.usefixtures("qtbot") -def _header(qtbot, mock_core, **meta): +def _header(qtbot, mock_core, touch=False, **meta): session = ExperimentSession() for k, v in meta.items(): setattr(session.metadata, k, v) session._mark_clean() mock_core.session = session - h = RunHeader(mock_core) + h = RunHeader(mock_core, touch=touch) qtbot.addWidget(h) return h @@ -256,3 +256,29 @@ def test_a_user_preset_file_is_checked_not_the_built_in(qtbot, mock_core, tmp_pa checked = [a for a in h._menu.actions() if a.isChecked()] assert len(checked) == 1 assert h._menu.actions().index(checked[0]) > texts.index("Open Field") + + +def test_touch_header_hides_title_and_picker(qtbot, mock_core): + h = _header(qtbot, mock_core, touch=True) + assert not h._title.isVisibleTo(h) and not h._picker.isVisibleTo(h) + + +def test_touch_header_stays_without_title_after_a_refresh(qtbot, mock_core): + h = _header(qtbot, mock_core, touch=True, name="OF 3") + h.refresh() + assert not h._title.isVisibleTo(h) + + +def test_touch_header_lays_tags_out_in_two_columns(qtbot, mock_core): + from PyQt6.QtWidgets import QGridLayout + + h = _header(qtbot, mock_core, touch=True) + assert isinstance(h._tags.layout(), QGridLayout) + + +def test_desktop_header_keeps_title_picker_and_one_row(qtbot, mock_core): + from PyQt6.QtWidgets import QHBoxLayout + + h = _header(qtbot, mock_core) + assert h._title.isVisibleTo(h) and h._picker.isVisibleTo(h) + assert isinstance(h._tags.layout(), QHBoxLayout) diff --git a/tests/unit/gui/runner/test_device_grid.py b/tests/unit/gui/runner/test_device_grid.py new file mode 100644 index 00000000..74f27dac --- /dev/null +++ b/tests/unit/gui/runner/test_device_grid.py @@ -0,0 +1,87 @@ +from unittest.mock import MagicMock + +import pytest + +from glider.gui.runner.device_grid import DeviceCardGrid + +pytestmark = pytest.mark.usefixtures("qtbot") + +_EMPTY = "No devices yet. Add a board on the Dashboard › Hardware tab." + + +def _device(name): + device = MagicMock() + device.device_type = "DigitalOutput" + device._state = True + device.link_state = None + device.name = name + return device + + +def _grid(qtbot, core, n=3): + devices = {f"dev_{i}": _device(f"dev_{i}") for i in range(n)} + core.hardware_manager.devices = devices + core.hardware_manager.get_device = devices.get + grid = DeviceCardGrid(core) + qtbot.addWidget(grid) + grid.refresh_devices() + return grid + + +def test_cards_lay_out_in_two_columns(qtbot, mock_core): + grid = _grid(qtbot, mock_core, 3) + positions = [ + grid._grid.getItemPosition(grid._grid.indexOf(c))[:2] for c in grid.cards().values() + ] + assert positions == [(0, 0), (0, 1), (1, 0)] + assert list(grid.cards()) == ["dev_0", "dev_1", "dev_2"] + + +def test_refresh_rebuilds_without_leaving_stale_cards(qtbot, mock_core): + grid = _grid(qtbot, mock_core, 3) + del mock_core.hardware_manager.devices["dev_1"] + grid.refresh_devices() + assert list(grid.cards()) == ["dev_0", "dev_2"] + positions = [ + grid._grid.getItemPosition(grid._grid.indexOf(c))[:2] for c in grid.cards().values() + ] + assert positions == [(0, 0), (0, 1)] + + +def test_empty_state_names_the_hardware_tab(qtbot, mock_core): + grid = _grid(qtbot, mock_core, 0) + assert grid._empty.text() == _EMPTY + assert grid._empty.isVisibleTo(grid) + assert grid.cards() == {} + grid.refresh_devices() # idempotent + mock_core.hardware_manager.devices = {"d": _device("d")} + grid.refresh_devices() + assert not grid._empty.isVisibleTo(grid) + + +def test_cards_keep_their_state_labels_and_property(qtbot, mock_core): + grid = _grid(qtbot, mock_core, 1) + card = grid.cards()["dev_0"] + assert card.property("deviceCard") is True + assert card._state_label.text() == "HIGH" + assert card._ready_label.text() == "Unknown" + + +def test_state_poll_runs_only_while_live(qtbot, mock_core): + grid = _grid(qtbot, mock_core, 1) + card = next(iter(grid.cards().values())) + grid.set_live(True) + assert grid._poll.isActive() + assert grid._poll.interval() > 0 + + mock_core.hardware_manager.devices["dev_0"]._state = False + grid.set_live(False) # one last repaint on the way out + assert not grid._poll.isActive() + assert card._state_label.text() == "LOW" + + +def test_the_scroll_area_is_vertical_only(qtbot, mock_core): + from PyQt6.QtCore import Qt + + grid = _grid(qtbot, mock_core, 1) + assert grid._scroll.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff diff --git a/tests/unit/gui/runner/test_notice_bar.py b/tests/unit/gui/runner/test_notice_bar.py new file mode 100644 index 00000000..2fb3a9c0 --- /dev/null +++ b/tests/unit/gui/runner/test_notice_bar.py @@ -0,0 +1,59 @@ +import pytest +from PyQt6.QtCore import Qt + +from glider.gui.runner.notice_bar import NoticeBar + +pytestmark = pytest.mark.usefixtures("qtbot") + + +def test_hidden_until_a_notice_arrives(qtbot): + bar = NoticeBar() + qtbot.addWidget(bar) + assert bar.objectName() == "piNoticeBar" + assert bar.isHidden() + bar.show_notice("Saved", "box_pi_side.glider", "info") + assert not bar.isHidden() + assert bar.text() == "Saved: box_pi_side.glider" + assert bar.property("level") == "info" + + +def test_the_message_wraps(qtbot): + bar = NoticeBar() + qtbot.addWidget(bar) + bar.show_notice("Saved", "x", "info") + assert bar._label.wordWrap() + + +def test_critical_is_not_replaced_by_info(qtbot): + bar = NoticeBar() + qtbot.addWidget(bar) + bar.show_notice("UNSAFE STATE — Hardware may still be active", "Check outputs.", "critical") + bar.show_notice("Saved", "box_pi_side.glider", "info") + assert bar.text().startswith("UNSAFE STATE") + assert bar.property("level") == "critical" + bar.dismiss() + bar.show_notice("Saved", "x", "info") + assert bar.text() == "Saved: x" and not bar.isHidden() + + +def test_only_a_critical_notice_is_sticky(qtbot): + bar = NoticeBar() + qtbot.addWidget(bar) + bar.show_notice("Saved", "a", "info") + bar.show_notice("Heads up", "b", "warning") + assert bar.text() == "Heads up: b" and bar.property("level") == "warning" + bar.show_notice("UNSAFE", "c", "critical") + bar.show_notice("Heads up", "d", "warning") + assert bar.text() == "UNSAFE: c" + bar.show_notice("UNSAFE", "e", "critical") # the newer critical wins + assert bar.text() == "UNSAFE: e" + + +def test_a_left_tap_dismisses_and_a_right_tap_does_not(qtbot): + bar = NoticeBar() + qtbot.addWidget(bar) + bar.show_notice("Saved", "x", "info") + qtbot.mouseClick(bar, Qt.MouseButton.RightButton) + assert not bar.isHidden() + qtbot.mouseClick(bar, Qt.MouseButton.LeftButton) + assert bar.isHidden() diff --git a/tests/unit/gui/runner/test_run_page.py b/tests/unit/gui/runner/test_run_page.py new file mode 100644 index 00000000..062d698a --- /dev/null +++ b/tests/unit/gui/runner/test_run_page.py @@ -0,0 +1,129 @@ +import pytest +from PyQt6.QtWidgets import QVBoxLayout + +from glider.core.experiment_session import ExperimentSession, FlowConfig, NodeConfig, RunField +from glider.gui.runner.run_page import PiRunPage + +pytestmark = pytest.mark.usefixtures("qtbot") + + +def _real_session(core, *, board=True, start_node=True): + """A real session (the default mock's flow is not iterable as nodes).""" + session = ExperimentSession() + nodes = [NodeConfig(id="s", node_type="StartExperiment")] if start_node else [] + session._flow = FlowConfig(nodes=nodes) + core.session = session + core.hardware_manager.is_any_board_connected = lambda: board + core.last_flow_duration_s = None + return session + + +def _page(qtbot, core): + core.last_flow_duration_s = None + page = PiRunPage(core) + qtbot.addWidget(page) + return page + + +def test_clock_follows_the_control(qtbot, mock_core): + page = PiRunPage(mock_core) + qtbot.addWidget(page) + assert page.clock.objectName() == "piRunClock" + assert page.clock.text() == "00:00.00" + page.control.elapsed_updated.emit("01:02.03") + assert page.clock.text() == "01:02.03" + + +def test_required_field_blocks_start(qtbot, mock_core): + session = _real_session(mock_core) + session.metadata.run_fields = [RunField("dam_id", "Dam ID", True)] + page = _page(qtbot, mock_core) + assert not page.control._start_btn.isEnabled() + page.header._tags.value_edited.emit("dam_id", "D-12") + page.control.refresh() + assert page.control._start_btn.isEnabled() + + +def test_camera_slot_hidden_until_asked(qtbot, mock_core): + page = PiRunPage(mock_core) + qtbot.addWidget(page) + assert isinstance(page.camera_slot.layout(), QVBoxLayout) + assert page.camera_slot.layout().contentsMargins().top() == 0 + assert not page.camera_slot.isVisibleTo(page) + page.set_camera_visible(True) + assert page.camera_slot.isVisibleTo(page) + page.set_camera_visible(False) + assert not page.camera_slot.isVisibleTo(page) + + +def test_parts_are_in_the_documented_order(qtbot, mock_core): + page = _page(qtbot, mock_core) + assert page.objectName() == "piRunPage" + layout = page.layout() + parts = [layout.itemAt(i).widget() for i in range(layout.count())] + assert parts == [page.header, page.clock, page.devices, page.camera_slot, page.control] + assert layout.stretch(2) == 1 + + +def test_the_header_and_control_are_touch_variants(qtbot, mock_core): + page = _page(qtbot, mock_core) + assert not page.header._title.isVisibleTo(page.header) + assert not page.header._picker.isVisibleTo(page.header) + assert not page.control._timer.isVisibleTo(page.control) + + +def test_update_state_reaches_header_control_devices_and_clock(qtbot, mock_core): + _real_session(mock_core) + page = _page(qtbot, mock_core) + page.update_state("RUNNING") + assert page.header._pill.text() == "RUNNING" + assert page.control.is_live + assert page.devices._poll.isActive() + assert page.clock.property("live") is True + + page.update_state("STOPPED") + assert page.header._pill.text() == "READY" + assert not page.control.is_live + assert not page.devices._poll.isActive() + assert page.clock.property("live") is False + + +def test_clock_is_restyled_when_live_flips(qtbot, mock_core): + _real_session(mock_core) + page = _page(qtbot, mock_core) + page.setStyleSheet('QLabel#piRunClock[live="true"] { font-size: 41px; }') + page.clock.ensurePolished() + assert page.clock.font().pixelSize() != 41 + page.update_state("RUNNING") + page.clock.ensurePolished() + assert page.clock.font().pixelSize() == 41 + page.update_state("STOPPED") + page.clock.ensurePolished() + assert page.clock.font().pixelSize() != 41 + + +def test_readiness_feeds_the_header_pill(qtbot, mock_core): + session = _real_session(mock_core, board=False) + session.hardware.boards.append(object()) # a board is configured, none connected + page = _page(qtbot, mock_core) + assert page.header._pill.text() == "NOT READY" # synced at construction + mock_core.hardware_manager.is_any_board_connected = lambda: True + page.control.refresh() + assert page.header._pill.text() == "READY" + + +@pytest.mark.parametrize("name", ["start", "stop", "pause", "resume"]) +def test_control_signals_are_forwarded(qtbot, mock_core, name): + page = _page(qtbot, mock_core) + with qtbot.waitSignal(getattr(page, f"{name}_requested"), timeout=1000): + getattr(page.control, f"{name}_requested").emit() + + +def test_refresh_refreshes_all_three(qtbot, mock_core, monkeypatch): + page = _page(qtbot, mock_core) + calls = [] + monkeypatch.setattr(page.header, "refresh", lambda: calls.append("header")) + monkeypatch.setattr(page.control, "refresh", lambda: calls.append("control")) + monkeypatch.setattr(page.devices, "refresh_devices", lambda: calls.append("devices")) + page.refresh() + assert calls == ["header", "control", "devices"] diff --git a/tests/unit/gui/test_device_card_link_status.py b/tests/unit/gui/test_device_card_link_status.py index 4b41d4c2..0604302a 100644 --- a/tests/unit/gui/test_device_card_link_status.py +++ b/tests/unit/gui/test_device_card_link_status.py @@ -12,8 +12,6 @@ and not the other should fail here. """ -from unittest.mock import MagicMock - import pytest from glider.gui.styles import colors @@ -23,12 +21,12 @@ pytestmark = pytest.mark.usefixtures("qtbot") -def _runner_panel(qtbot, core): - from glider.gui.panels.runner_panel import RunnerPanel +def _device_grid(qtbot, core): + from glider.gui.runner.device_grid import DeviceCardGrid - panel = RunnerPanel(core, MagicMock()) + panel = DeviceCardGrid(core) qtbot.addWidget(panel) - return panel, panel._update_runner_device_states + return panel, panel.update_states def _controls_panel(qtbot, core): @@ -40,7 +38,7 @@ def _controls_panel(qtbot, core): PANELS = [ - pytest.param(_runner_panel, id="runner"), + pytest.param(_device_grid, id="device-grid"), pytest.param(_controls_panel, id="device-controls"), ] @@ -57,7 +55,7 @@ def _card(qtbot, core, device, build, state): panel, repaint = build(qtbot, core) if hasattr(panel, "refresh_devices"): panel.refresh_devices() - return panel, repaint, panel._runner_device_cards["stim"]._ready_label + return panel, repaint, panel.cards()["stim"]._ready_label panel.refresh() return panel, repaint, _RowLabel(panel._rows["stim"]) From f20ab89aa77159f5d89b86bbe17d675a2b169edb Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Tue, 20 Oct 2026 22:46:13 -0500 Subject: [PATCH 07/25] fix(pi): two-column field tags shrink and elide to fit 480 px --- src/glider/gui/dashboard/panels/field_tags.py | 39 ++++++++++- .../gui/dashboard/panels/test_field_tags.py | 67 +++++++++++++++++++ tests/unit/gui/runner/test_run_page.py | 16 +++++ 3 files changed, 120 insertions(+), 2 deletions(-) diff --git a/src/glider/gui/dashboard/panels/field_tags.py b/src/glider/gui/dashboard/panels/field_tags.py index 18d13a89..9ac92700 100644 --- a/src/glider/gui/dashboard/panels/field_tags.py +++ b/src/glider/gui/dashboard/panels/field_tags.py @@ -14,7 +14,17 @@ from dataclasses import dataclass from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import QGridLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget +from PyQt6.QtWidgets import ( + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QStyle, + QStyleOptionButton, + QWidget, +) _MAX_VALUE_CHARS = 40 @@ -37,6 +47,27 @@ def keyPressEvent(self, event): # noqa: N802 (Qt override) super().keyPressEvent(event) +class _ElidedChip(QPushButton): + """A grid-mode chip: takes whatever width its column gives it and elides + its text to fit, so a 40-character value cannot widen a 480 px page. The + full value stays in the tooltip.""" + + def __init__(self, text: str): + super().__init__(text) + self._full = text + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + + def resizeEvent(self, event): # noqa: N802 (Qt override) + super().resizeEvent(event) + self.ensurePolished() # the stylesheet sets the font and padding + opt = QStyleOptionButton() + self.initStyleOption(opt) + room = self.style().subElementRect(QStyle.SubElement.SE_PushButtonContents, opt, self) + shown = self.fontMetrics().elidedText(self._full, Qt.TextElideMode.ElideRight, room.width()) + if shown != self.text(): + self.setText(shown) + + class FieldTags(QWidget): value_edited = pyqtSignal(str, str) @@ -54,6 +85,9 @@ def __init__(self, parent=None, *, columns: int = 0): self._trailing = QLabel() self._trailing.setProperty("textRole", "muted") self._trailing.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + if isinstance(self._row, QGridLayout): + for col in range(columns): + self._row.setColumnStretch(col, 1) # columns split the width evenly if columns: # A run-folder path has no spaces to wrap at; let it clip (the # tooltip has the whole text) rather than widen the 480 px page. @@ -105,7 +139,8 @@ def _rebuild(self) -> None: w.deleteLater() self._chips.clear() for i, f in enumerate(self._fields): - chip = QPushButton(self._chip_text(f)) + text = self._chip_text(f) + chip = _ElidedChip(text) if self._columns else QPushButton(text) chip.setProperty("fieldTag", True) chip.setProperty("need", f.required and not f.value) chip.setProperty("locked", self._locked) diff --git a/tests/unit/gui/dashboard/panels/test_field_tags.py b/tests/unit/gui/dashboard/panels/test_field_tags.py index 9a50b153..fe768ee2 100644 --- a/tests/unit/gui/dashboard/panels/test_field_tags.py +++ b/tests/unit/gui/dashboard/panels/test_field_tags.py @@ -153,3 +153,70 @@ def test_default_is_still_a_single_row(qtbot, tags): from PyQt6.QtWidgets import QHBoxLayout assert isinstance(tags.layout(), QHBoxLayout) + + +_LABEL = {"a": "Experimenter", "b": "Postnatal day"} +_FULL = {"a": "Experimenter " + "A" * 40, "b": "Postnatal day " + "B" * 40} + + +def _long(qtbot, columns=2): + w = FieldTags(columns=columns) + qtbot.addWidget(w) + w.set_fields( + [ + TagField("a", "Experimenter", "A" * 40), + TagField("b", "Postnatal day", "B" * 40), + ] + ) + return w + + +def test_long_values_do_not_widen_the_two_column_grid(qtbot): + from glider.gui.styles import load_stylesheet + + w = _long(qtbot) + assert w.minimumSizeHint().width() <= 480 + w.setStyleSheet(load_stylesheet("touch")) + w.ensurePolished() + for chip in w.findChildren(type(w.chip("a"))): + chip.ensurePolished() + assert w.minimumSizeHint().width() <= 480 + + +def test_grid_chips_elide_to_their_column_and_keep_the_full_value_in_the_tooltip(qtbot): + w = _long(qtbot) + w.resize(300, 120) + w.show() + qtbot.waitExposed(w) + for key, ch in (("a", "A"), ("b", "B")): + chip = w.chip(key) + assert chip.width() < 160 # two columns share 300 px + assert chip.text().endswith("…") and chip.text() != _FULL[key] + assert chip.text().startswith(_LABEL[key]) # the label stays readable + assert chip.fontMetrics().horizontalAdvance(chip.text()) <= chip.width() + assert chip.toolTip() == ch * 40 + # Plenty of room: nothing is elided. + w.resize(1400, 120) + assert w.chip("a").text() == _FULL["a"] + + +def test_the_two_columns_split_the_width_evenly(qtbot): + w = _long(qtbot) + w.resize(400, 120) + w.show() + qtbot.waitExposed(w) + assert abs(w.chip("a").width() - w.chip("b").width()) <= 1 + # Still even with an editor open in one of them. + w.chip("a").click() + qtbot.waitUntil(lambda: w.editor().isVisible(), timeout=1000) + qtbot.waitUntil(lambda: w.editor().width() < 400, timeout=1000) + assert abs(w.editor().width() - w.chip("b").width()) <= 1 + assert w.width() == 400 + + +def test_row_mode_chips_are_not_elided(qtbot): + w = _long(qtbot, columns=0) + w.resize(200, 60) + w.show() + qtbot.waitExposed(w) + assert w.chip("a").text() == _FULL["a"] diff --git a/tests/unit/gui/runner/test_run_page.py b/tests/unit/gui/runner/test_run_page.py index 062d698a..b2bc2bb1 100644 --- a/tests/unit/gui/runner/test_run_page.py +++ b/tests/unit/gui/runner/test_run_page.py @@ -14,6 +14,7 @@ def _real_session(core, *, board=True, start_node=True): session._flow = FlowConfig(nodes=nodes) core.session = session core.hardware_manager.is_any_board_connected = lambda: board + core.hardware_manager.connected_board_description = lambda: "Arduino Uno" core.last_flow_duration_s = None return session @@ -127,3 +128,18 @@ def test_refresh_refreshes_all_three(qtbot, mock_core, monkeypatch): monkeypatch.setattr(page.devices, "refresh_devices", lambda: calls.append("devices")) page.refresh() assert calls == ["header", "control", "devices"] + + +def test_long_run_field_values_do_not_widen_the_page_past_480(qtbot, mock_core): + from glider.gui.styles import load_stylesheet + + session = _real_session(mock_core) + session.metadata.run_fields = [RunField("a", "Experimenter"), RunField("b", "Postnatal day")] + session.metadata.run_values = {"a": "A" * 40, "b": "B" * 40} + page = _page(qtbot, mock_core) + page.setStyleSheet(load_stylesheet("touch")) + page.resize(480, 800) + page.show() + qtbot.waitExposed(page) + assert page.minimumSizeHint().width() <= 480 + assert page.width() == 480 From dba01a8117d88a7a240cbb277f688fabde202b66 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Tue, 20 Oct 2026 22:55:52 -0500 Subject: [PATCH 08/25] feat(pi): graph-over-tabs dashboard and a lockable hardware panel --- src/glider/gui/panels/hardware_panel.py | 35 +++++++-- src/glider/gui/runner/pi_dashboard.py | 76 +++++++++++++++++++ tests/unit/gui/panels/test_hardware_panel.py | 66 ++++++++++++++++ tests/unit/gui/runner/test_pi_dashboard.py | 79 ++++++++++++++++++++ 4 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 src/glider/gui/runner/pi_dashboard.py create mode 100644 tests/unit/gui/runner/test_pi_dashboard.py diff --git a/src/glider/gui/panels/hardware_panel.py b/src/glider/gui/panels/hardware_panel.py index 7d62e745..12242ccf 100644 --- a/src/glider/gui/panels/hardware_panel.py +++ b/src/glider/gui/panels/hardware_panel.py @@ -69,6 +69,8 @@ def __init__( self._session_fn = session_fn self._run_async = run_async_fn self._show_add_buttons = show_add_buttons + self._editable = True + self._add_row: QWidget | None = None self._setup_ui() @property @@ -90,7 +92,9 @@ def _setup_ui(self): # Hardware buttons (hidden on the Runner/Pi UI, which cannot edit the # hardware map on-device). if self._show_add_buttons: - hw_btn_layout = QHBoxLayout() + self._add_row = QWidget() + hw_btn_layout = QHBoxLayout(self._add_row) + hw_btn_layout.setContentsMargins(0, 0, 0, 0) add_board_btn = QPushButton("+ Board") add_board_btn.clicked.connect(self._on_add_board) hw_btn_layout.addWidget(add_board_btn) @@ -99,10 +103,22 @@ def _setup_ui(self): add_device_btn.clicked.connect(self._on_add_device) hw_btn_layout.addWidget(add_device_btn) - layout.addLayout(hw_btn_layout) + layout.addWidget(self._add_row) # --- Public API --- + def set_editable(self, editable: bool) -> None: + """Allow or refuse changes to the hardware map. + + While not editable the ``+ Board``/``+ Device`` row is hidden and the + context menu drops its Edit/Remove items; Connect and Disconnect stay, + since they act on the link, not the map. The Pi ties this to the node + graph's lock. + """ + self._editable = editable + if self._add_row is not None: + self._add_row.setVisible(editable) + def refresh_tree(self): """Refresh the hardware tree widget.""" self._hardware_tree.clear() @@ -1274,21 +1290,24 @@ def _on_hardware_context_menu(self, position) -> None: disconnect_action = menu.addAction("Disconnect") disconnect_action.triggered.connect(lambda: self._disconnect_board(item_id)) - menu.addSeparator() + if self._editable: + menu.addSeparator() - edit_action = menu.addAction("Edit Board") - edit_action.triggered.connect(lambda: self._on_edit_board(item_id)) + edit_action = menu.addAction("Edit Board") + edit_action.triggered.connect(lambda: self._on_edit_board(item_id)) - remove_action = menu.addAction("Remove Board") - remove_action.triggered.connect(lambda: self._remove_board(item_id)) + remove_action = menu.addAction("Remove Board") + remove_action.triggered.connect(lambda: self._remove_board(item_id)) - elif item_type == "device": + elif item_type == "device" and self._editable: edit_action = menu.addAction("Edit Device") edit_action.triggered.connect(lambda: self._on_edit_device(item_id)) remove_action = menu.addAction("Remove Device") remove_action.triggered.connect(lambda: self._remove_device(item_id)) + if menu.isEmpty(): + return menu.exec(self._hardware_tree.viewport().mapToGlobal(position)) def _connect_board(self, board_id: str) -> None: diff --git a/src/glider/gui/runner/pi_dashboard.py b/src/glider/gui/runner/pi_dashboard.py new file mode 100644 index 00000000..87a41d2a --- /dev/null +++ b/src/glider/gui/runner/pi_dashboard.py @@ -0,0 +1,76 @@ +"""The Pi's Dashboard tab: the node graph over a tabbed panel. + +A vertical splitter, graph on top and a ``QTabWidget`` under it, the two +starting at half the height each and resizable by the grip between them. +It is layout only: the caller adds the real panels with ``add_tab`` and the +colours come from ``touch.qss`` through the object names. +""" + +from __future__ import annotations + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QSplitter, QTabWidget, QVBoxLayout, QWidget + +from glider.gui.node_graph.graph_view import NodeGraphView + + +class PiDashboard(QWidget): + """Graph on top, ``Nodes · Properties · Hardware · Camera · Control`` under it.""" + + def __init__(self, graph: NodeGraphView, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("piDashboard") + self._keys: list[str] = [] + self._sized = False + + self._splitter = QSplitter(Qt.Orientation.Vertical) + self._splitter.setObjectName("piDashboardSplitter") + self._splitter.setChildrenCollapsible(False) + + self._tabs = QTabWidget() + self._tabs.setObjectName("piDashboardTabs") + self._tabs.setDocumentMode(True) + self._tabs.tabBar().setExpanding(True) + + self._splitter.addWidget(graph) + self._splitter.addWidget(self._tabs) + self._splitter.setStretchFactor(0, 1) + self._splitter.setStretchFactor(1, 1) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._splitter) + + graph.node_selected.connect(self._on_node_selected) + + def showEvent(self, event) -> None: + super().showEvent(event) + # The graph's size hint would win the first layout, so split the height + # evenly once, on the first show; after that the grip owns the sizes. + if not self._sized: + self._sized = True + half = self._splitter.height() // 2 + self._splitter.setSizes([half, half]) + + def _on_node_selected(self, _node_id: str) -> None: + self.set_current("properties") + + def add_tab(self, key: str, label: str, widget: QWidget) -> None: + self._keys.append(key) + self._tabs.addTab(widget, label) + + def set_current(self, key: str) -> None: + if key in self._keys: + self._tabs.setCurrentIndex(self._keys.index(key)) + + def current(self) -> str: + return self._keys[self._tabs.currentIndex()] + + def keys(self) -> list[str]: + return list(self._keys) + + def splitter(self) -> QSplitter: + return self._splitter + + def tabs(self) -> QTabWidget: + return self._tabs diff --git a/tests/unit/gui/panels/test_hardware_panel.py b/tests/unit/gui/panels/test_hardware_panel.py index 13ca5cb6..d9c953ef 100644 --- a/tests/unit/gui/panels/test_hardware_panel.py +++ b/tests/unit/gui/panels/test_hardware_panel.py @@ -91,3 +91,69 @@ def test_refresh_link_states_does_not_announce_a_hardware_change(qtbot, mock_har assert emitted == [] assert _status_words(panel) == ["Reconnecting…"] + + +# --- the Pi's lock: the hardware map is read-only while the graph is locked --- + + +def _menu_texts(panel, monkeypatch, item): + """Open the tree's context menu on ``item`` and return the action texts.""" + from PyQt6.QtWidgets import QMenu + + captured: list[str] = [] + monkeypatch.setattr( + QMenu, "exec", lambda self, *a, **k: captured.extend(x.text() for x in self.actions()) + ) + tree = panel._hardware_tree + panel._on_hardware_context_menu(tree.visualItemRect(item).center()) + return captured + + +def test_set_editable_false_hides_the_add_row_and_reshows_it(qtbot, mock_hardware_manager): + panel = _panel(qtbot, mock_hardware_manager) + panel.show() + add_board = next(b for b in panel.findChildren(QPushButton) if b.text() == "+ Board") + assert add_board.isVisible() + + panel.set_editable(False) + assert not add_board.isVisible() + + panel.set_editable(True) + assert add_board.isVisible() + + +def test_set_editable_is_a_no_op_without_the_add_row(qtbot, mock_hardware_manager): + panel = _panel(qtbot, mock_hardware_manager, show_add_buttons=False) + panel.set_editable(False) + panel.set_editable(True) + + +def test_locked_board_menu_keeps_connect_and_drops_edit_remove( + qtbot, mock_hardware_manager, monkeypatch +): + panel = _panel(qtbot, mock_hardware_manager) + panel.show() + panel.refresh_tree() + board_item = panel._hardware_tree.topLevelItem(0) + + editable = _menu_texts(panel, monkeypatch, board_item) + assert {"Connect", "Disconnect", "Edit Board", "Remove Board"} <= set(editable) + + panel.set_editable(False) + locked = _menu_texts(panel, monkeypatch, board_item) + assert "Connect" in locked and "Disconnect" in locked + assert "Edit Board" not in locked and "Remove Board" not in locked + + panel.set_editable(True) + assert "Remove Board" in _menu_texts(panel, monkeypatch, board_item) + + +def test_locked_device_menu_has_no_edit_or_remove(qtbot, mock_hardware_manager, monkeypatch): + panel, _ = _tree_with_device(qtbot, mock_hardware_manager, ConnectionState.CONNECTED) + panel.show() + device_item = panel._hardware_tree.topLevelItem(0).child(0) + + assert _menu_texts(panel, monkeypatch, device_item) == ["Edit Device", "Remove Device"] + + panel.set_editable(False) + assert _menu_texts(panel, monkeypatch, device_item) == [] diff --git a/tests/unit/gui/runner/test_pi_dashboard.py b/tests/unit/gui/runner/test_pi_dashboard.py new file mode 100644 index 00000000..ba16b03c --- /dev/null +++ b/tests/unit/gui/runner/test_pi_dashboard.py @@ -0,0 +1,79 @@ +import pytest +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QLabel + +from glider.gui.node_graph.graph_view import NodeGraphView +from glider.gui.runner.pi_dashboard import PiDashboard + +_TABS = [ + ("nodes", "Nodes"), + ("properties", "Properties"), + ("hardware", "Hardware"), + ("camera", "Camera"), + ("control", "Control"), +] + + +# Fixtures, not a helper that returns the widgets: qtbot.addWidget keeps only a +# weak reference, so a helper's return value dropped at the end of the test body +# destroys a shown NodeGraphView before pytest-qt's final processEvents, which +# then paints the deleted scene and segfaults. A fixture holds them to teardown. +@pytest.fixture +def dash_env(qtbot): + graph = NodeGraphView() + dash = PiDashboard(graph) + qtbot.addWidget(dash) + for key, label in _TABS: + dash.add_tab(key, label, QLabel(label)) + dash.resize(480, 700) + dash.show() + return graph, dash + + +@pytest.fixture +def nodes_only_env(qtbot): + graph = NodeGraphView() + dash = PiDashboard(graph) + qtbot.addWidget(dash) + dash.add_tab("nodes", "Nodes", QLabel("Nodes")) + return graph, dash + + +def test_graph_on_top_tabs_under_it_split_evenly(dash_env): + graph, dash = dash_env + assert dash.splitter().orientation() == Qt.Orientation.Vertical + assert dash.splitter().widget(0) is graph + assert dash.keys() == [key for key, _ in _TABS] + top, bottom = dash.splitter().sizes() + assert abs(top - bottom) <= 0.15 * (top + bottom) + + +def test_selecting_a_node_shows_properties(dash_env): + graph, dash = dash_env + graph.node_selected.emit("n1") + assert dash.current() == "properties" + + +def test_set_current_switches_tab_and_ignores_unknown_keys(dash_env): + _, dash = dash_env + dash.set_current("camera") + assert dash.current() == "camera" + dash.set_current("nope") + assert dash.current() == "camera" + + +def test_node_selected_without_a_properties_tab_leaves_the_tab_alone(nodes_only_env): + graph, dash = nodes_only_env + graph.node_selected.emit("n1") + assert dash.current() == "nodes" + + +def test_splitter_children_cannot_collapse_and_tabs_fill_the_bar(dash_env): + _, dash = dash_env + splitter = dash.splitter() + assert not splitter.childrenCollapsible() + assert splitter.objectName() == "piDashboardSplitter" + assert dash.tabs().objectName() == "piDashboardTabs" + assert dash.tabs().documentMode() + assert dash.tabs().tabBar().expanding() + assert dash.objectName() == "piDashboard" From 948ba58b6d5caa43d077377bd80fca228152c528 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Tue, 20 Oct 2026 23:04:22 -0500 Subject: [PATCH 09/25] feat(pi): two-row header with a gear menu and a notice bar --- src/glider/gui/main_window.py | 99 +++++++++++++++++++---- src/glider/gui/shell/status_strip.py | 21 +++-- tests/unit/gui/runner/test_pi_header.py | 99 +++++++++++++++++++++++ tests/unit/gui/shell/test_touch_chrome.py | 12 ++- 4 files changed, 209 insertions(+), 22 deletions(-) create mode 100644 tests/unit/gui/runner/test_pi_header.py diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index a763dcb3..7d848304 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -31,6 +31,7 @@ QStackedWidget, QStatusBar, QToolBar, + QToolButton, QVBoxLayout, QWidget, ) @@ -53,6 +54,7 @@ from glider.gui.panels.node_editor_controller import NodeEditorController, node_category_for_type from glider.gui.panels.node_library_panel import NodeLibraryPanel from glider.gui.panels.tools_page import ToolsPage +from glider.gui.runner.notice_bar import NoticeBar from glider.gui.runner.readiness import compute_readiness from glider.gui.shell import ( AppShell, @@ -448,6 +450,12 @@ def __init__( # tab is showing -- and so they still report in runner mode, where # there is no Builder frame at all. self._status_strip: StatusStrip | None = None + # Runner mode only: the two-row header that holds the strip and the tabs + # in place of the desktop chrome row, its gear menu, and the bar that + # carries notifications now that there is no status bar. + self._pi_header: QWidget | None = None + self._gear_button: QToolButton | None = None + self._notice_bar: NoticeBar | None = None # The editor behind the Metadata and Mice sections, and the two pages # it was split into. Both are rebuilt per session; see # _experiment_detail_pages. @@ -638,7 +646,13 @@ def _setup_ui(self) -> None: column.setContentsMargins(0, 0, 0, 0) column.setSpacing(0) - column.addWidget(self._build_chrome_row(container)) + if self._view_manager.is_runner_mode: + self._pi_header = self._build_pi_header(container) + column.addWidget(self._pi_header) + self._notice_bar = NoticeBar(container) + column.addWidget(self._notice_bar) + else: + column.addWidget(self._build_chrome_row(container)) # Created before the pages: --file opens the experiment while the # window is still being built, and that open arms the watcher. @@ -728,6 +742,63 @@ def _build_chrome_row(self, parent: QWidget) -> QWidget: ) return row + def _build_pi_header(self, parent: QWidget) -> QWidget: + """The Pi's header: status strip and gear menu over the tabs. + + The desktop chrome row overlays the tabs on the strip, which at 480 px + covers the experiment name, so here the two are rows. Row one is the + strip (stretching, its name eliding) beside a gear button that carries + what the desktop's menus do; row two is the tabs, less Analyze. Both + widgets keep their attribute names, since a lot of code refreshes them. + + The gear's "Connect boards" resolves ``self._hardware_panel`` at click + time: the runner-mode panel is built after this header is. + """ + header = QWidget(parent) + header.setObjectName("piHeader") + rows = QVBoxLayout(header) + rows.setContentsMargins(0, 0, 0, 0) + rows.setSpacing(0) + + top = QHBoxLayout() + top.setContentsMargins(0, 0, 0, 0) + top.setSpacing(0) + self._status_strip = StatusStrip(header) + self._status_strip.set_touch_mode(True) + top.addWidget(self._status_strip, 1) + + self._gear_button = QToolButton(header) + self._gear_button.setObjectName("piGearButton") + self._gear_button.setText("⚙") + self._gear_button.setAccessibleName("Menu") + self._gear_button.setFixedSize(44, 44) + self._gear_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + menu = QMenu(self._gear_button) + for entry in ( + ("New", self._on_new), + ("Open…", self._on_open), + ("Save", self._on_save), + ("Save As…", self._on_save_as), + None, + ("Connect boards…", lambda: self._hardware_panel.show_board_settings_dialog()), + None, + ("Help", self._on_help), + ("Switch to Desktop", self._switch_to_desktop_mode), + ("Exit", self.close), + ): + if entry is None: + menu.addSeparator() + else: + menu.addAction(entry[0]).triggered.connect(entry[1]) + self._gear_button.setMenu(menu) + top.addWidget(self._gear_button) + rows.addLayout(top) + + self._tab_bar = ShellTabBar(header) + self._tab_bar.set_touch_mode(True) + rows.addWidget(self._tab_bar) + return header + def _has_open_experiment(self) -> bool: """Whether something was already opened before the window was built. @@ -1042,9 +1113,12 @@ def _on_page_changed(self, index: int) -> None: # Builder's side panels -- which only exist on the Dashboard. Left up # elsewhere they are buttons that collapse something you cannot see. if self._status_strip is not None: - on_dashboard = index == PAGE_BUILDER - self._status_strip.left_toggle().setVisible(on_dashboard) - self._status_strip.right_toggle().setVisible(on_dashboard) + # Runner mode has no Builder panels, and touch mode keeps the + # toggles hidden on every page. + if not self._view_manager.is_runner_mode: + on_dashboard = index == PAGE_BUILDER + self._status_strip.left_toggle().setVisible(on_dashboard) + self._status_strip.right_toggle().setVisible(on_dashboard) # Nothing is open on the landing page, so there is no name, no run # state and no rig to report on. self._status_strip.setVisible(index != PAGE_LANDING) @@ -2275,15 +2349,16 @@ def _notify_user( Qt event-loop tick (so it can't freeze an in-flight async coroutine). In runner mode (touch kiosk, usually on the Pi) we avoid modal popups entirely — runner mode is responsiveness-sensitive and dialogs don't - work well with touch input — and instead post to the status bar + - log at the appropriate level. + work well with touch input — and instead post to the notice bar under + the header + log at the appropriate level. Args: title: Short title for the notification. message: Body text. level: "info", "warning", or "critical" — affects log level and, in desktop mode, the QMessageBox icon. - timeout_ms: How long to show the status message in runner mode. + timeout_ms: Unused now. It timed the runner-mode status message; the + notice bar stays up until it is tapped (or a critical replaces it). """ log_message = f"{title}: {message}" if level == "critical": @@ -2294,13 +2369,9 @@ def _notify_user( logger.info(log_message) if self._view_manager.is_runner_mode: - # No modal dialogs on the touch dashboard — just the status bar. - try: - self.statusBar().showMessage(f"{title}: {message}", timeout_ms) - except Exception: - # Runner mode may not have a conventional status bar; the log - # above is still the source of truth. - pass + # No modal dialogs on the touch dashboard, and no status bar: the + # notice bar under the header carries it. + self._notice_bar.show_notice(title, message, level) return # Desktop mode: schedule the dialog on the next event-loop tick so the diff --git a/src/glider/gui/shell/status_strip.py b/src/glider/gui/shell/status_strip.py index e4306671..3ac2e65e 100644 --- a/src/glider/gui/shell/status_strip.py +++ b/src/glider/gui/shell/status_strip.py @@ -69,9 +69,11 @@ button. :meth:`StatusStrip.set_touch_mode` drops what a finger cannot use and a 480 px row cannot afford -- the panel toggles and the ``Ctrl K`` hint -- shows the dirty marker (a frameless window has no title bar to carry the modified -mark), and lets long names elide instead of widening the window. It only flips -visibility, a minimum width and the gaps between parts; ``set_touch_mode(False)`` -puts every one of them back, so the desktop strip is exactly what it was. +mark), and lets long names elide instead of widening the window. The strip also +grows from :data:`STRIP_HEIGHT` to 44 px, because the pill is a tap target and a +finger needs the room. It only flips visibility, a height, a minimum width and the +gaps between parts; ``set_touch_mode(False)`` puts every one of them back, so the +desktop strip is exactly what it was. """ from __future__ import annotations @@ -152,6 +154,10 @@ _TOUCH_NAME_MIN = 32 _TOUCH_DEVICE_MIN = 12 +#: The strip's fixed height in touch mode: a 44 px tap target, to match the gear +#: button beside it. +_TOUCH_HEIGHT = 44 + #: ``(side margin, gap between the strip's parts, gap between device chips)`` in #: pixels, on desktop and in touch mode. Touch packs tighter because the same #: worst case has to share 480 px with whatever styling the Pi's stylesheet adds. @@ -504,10 +510,10 @@ def set_touch_mode(self, enabled: bool) -> None: """Fit the strip to the Pi header, or back to the desktop. Touch mode hides the panel toggles and the ``Ctrl K`` hint, shows the - dirty marker, and lets the experiment name and the device names elide so - a long name and five boards still fit in 480 px. Reversible: ``False`` - restores every one of them, the hidden controls to whatever visibility - they had when touch mode was switched on. + dirty marker, makes the strip 44 px tall, and lets the experiment name and + the device names elide so a long name and five boards still fit in + 480 px. Reversible: ``False`` restores every one of them, the hidden + controls to whatever visibility they had when touch mode was switched on. """ enabled = bool(enabled) if enabled != self._touch: @@ -522,6 +528,7 @@ def set_touch_mode(self, enabled: bool) -> None: else: widget.setVisible(self._desktop_visible.pop(widget, True)) self._set_gaps(_TOUCH_GAPS if enabled else _GAPS) + self.setFixedHeight(_TOUCH_HEIGHT if enabled else STRIP_HEIGHT) self._dirty.setVisible(self._touch and self._is_dirty) self._name.set_elide_min(_TOUCH_NAME_MIN if self._touch else None) for label in self._names: diff --git a/tests/unit/gui/runner/test_pi_header.py b/tests/unit/gui/runner/test_pi_header.py new file mode 100644 index 00000000..7b901e88 --- /dev/null +++ b/tests/unit/gui/runner/test_pi_header.py @@ -0,0 +1,99 @@ +"""The Pi's two-row header and notice bar, as MainWindow builds them in runner mode. + +Row one is the status strip beside a gear menu, row two is the tabs, and the +notice bar sits under both. Until the operator view is rebuilt the window itself +is still as wide as the desktop shell's minimum, so these assert on the header's +own geometry rather than the window's width. +""" + +from __future__ import annotations + +from PyQt6.QtCore import QPoint +from PyQt6.QtWidgets import QStatusBar, QToolButton + + +def _pi(qtbot, main_window_factory): + w = main_window_factory(desktop_mode=False) + w.resize(480, 800) + w.show() + # A runner window shows itself before its central widget exists, so Qt only + # shows (and lays out) the header on the next pass of the event loop. + qtbot.waitUntil(w._pi_header.isVisible) + return w + + +def test_header_is_two_rows_with_no_overlap(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + strip, bar = w._status_strip, w._tab_bar + strip_bottom = strip.mapTo(w, QPoint(0, 0)).y() + strip.height() + assert strip_bottom <= bar.mapTo(w, QPoint(0, 0)).y() + assert not w._tab_bar.buttons()["analyze"].isVisibleTo(w) + + +def test_desktop_controls_stay_hidden_on_every_page(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + for go in (w.switch_to_builder, w._show_experiment_tab, w._enter_dashboard): + go() + assert not w._status_strip.left_toggle().isVisibleTo(w) + assert not w._status_strip.palette_hint().isVisibleTo(w) + + +def test_gear_menu_items(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + texts = [a.text() for a in w._gear_button.menu().actions() if not a.isSeparator()] + assert texts == [ + "New", + "Open…", + "Save", + "Save As…", + "Connect boards…", + "Help", + "Switch to Desktop", + "Exit", + ] + + +def test_gear_is_a_touch_sized_instant_popup(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + gear = w._gear_button + assert gear.objectName() == "piGearButton" + assert gear.width() >= 44 and gear.height() >= 44 + assert gear.popupMode() == QToolButton.ToolButtonPopupMode.InstantPopup + assert w._pi_header.objectName() == "piHeader" + + +def test_gear_menu_has_separators_before_connect_and_help(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + actions = w._gear_button.menu().actions() + for text in ("Connect boards…", "Help"): + before = actions[[a.text() for a in actions].index(text) - 1] + assert before.isSeparator() + + +def test_connect_boards_resolves_the_panel_when_clicked(qtbot, main_window_factory, monkeypatch): + w = _pi(qtbot, main_window_factory) + opened = [] + monkeypatch.setattr(w._hardware_panel, "show_board_settings_dialog", lambda: opened.append(1)) + connect = next(a for a in w._gear_button.menu().actions() if a.text() == "Connect boards…") + connect.trigger() + assert opened == [1] + + +def test_notice_bar_sits_directly_under_the_header(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + column = w.centralWidget().layout() + assert column.indexOf(w._notice_bar) == column.indexOf(w._pi_header) + 1 + + +def test_notices_use_the_bar_not_a_status_bar(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + w._notify_user("Start Error", "No board", level="warning") + assert w.findChild(QStatusBar) is None + assert w._notice_bar.text() == "Start Error: No board" + + +def test_long_name_does_not_widen_the_window(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + w._core.session.metadata.name = "x" * 60 + w._refresh_strip_experiment() + assert w._pi_header.minimumSizeHint().width() <= 480 diff --git a/tests/unit/gui/shell/test_touch_chrome.py b/tests/unit/gui/shell/test_touch_chrome.py index 3cd303f1..870a8830 100644 --- a/tests/unit/gui/shell/test_touch_chrome.py +++ b/tests/unit/gui/shell/test_touch_chrome.py @@ -10,7 +10,7 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QLabel -from glider.gui.shell.status_strip import StatusStrip +from glider.gui.shell.status_strip import STRIP_HEIGHT, StatusStrip from glider.gui.shell.tab_bar import ShellTabBar #: The header's first row, less the gear button beside the strip. @@ -178,3 +178,13 @@ def test_tab_bar_touch_mode_is_reversible(qtbot): bar.layout().activate() assert {k: (b.geometry(), b.sizePolicy()) for k, b in bar.buttons().items()} == before + + +def test_touch_strip_is_a_tap_target_tall_and_desktop_stays_40(qtbot): + strip = StatusStrip() + qtbot.addWidget(strip) + assert strip.height() == STRIP_HEIGHT == 40 + strip.set_touch_mode(True) + assert strip.minimumHeight() == strip.maximumHeight() == 44 + strip.set_touch_mode(False) + assert strip.minimumHeight() == strip.maximumHeight() == STRIP_HEIGHT From e53539f8803ac3b08508e8edf1427257db3a0b76 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Tue, 20 Oct 2026 23:28:48 -0500 Subject: [PATCH 10/25] feat(pi): dashboard tab is the graph over panel tabs; desktop switch re-hosts it --- src/glider/gui/main_window.py | 217 ++++++++++++++---- src/glider/gui/panels/hardware_panel.py | 10 +- .../gui/runner/test_pi_dashboard_window.py | 133 +++++++++++ .../runner/test_runner_shell_integration.py | 29 +-- tests/unit/gui/test_main_window_file_ops.py | 1 + 5 files changed, 313 insertions(+), 77 deletions(-) create mode 100644 tests/unit/gui/runner/test_pi_dashboard_window.py diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index 7d848304..13d404f2 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -55,6 +55,7 @@ from glider.gui.panels.node_library_panel import NodeLibraryPanel from glider.gui.panels.tools_page import ToolsPage from glider.gui.runner.notice_bar import NoticeBar +from glider.gui.runner.pi_dashboard import PiDashboard from glider.gui.runner.readiness import compute_readiness from glider.gui.shell import ( AppShell, @@ -456,6 +457,9 @@ def __init__( self._pi_header: QWidget | None = None self._gear_button: QToolButton | None = None self._notice_bar: NoticeBar | None = None + # Runner mode only: stack page 0, the graph over the Builder's panels as + # tabs. Switch to Desktop takes those widgets back into _builder_view. + self._pi_dashboard: PiDashboard | None = None # The editor behind the Metadata and Mice sections, and the two pages # it was split into. Both are rebuilt per session; see # _experiment_detail_pages. @@ -671,7 +675,12 @@ def _setup_ui(self) -> None: self._create_builder_view() self._create_runner_view() - self._stack.addWidget(self._builder_view) # Index 0 -- PAGE_BUILDER + if self._view_manager.is_runner_mode: + # Built after the runner view, whose hardware, camera and manual + # control widgets it hosts. The desktop frame stays built but off + # the stack, for Switch to Desktop. + self._pi_dashboard = self._build_pi_dashboard() + self._stack.addWidget(self._pi_dashboard or self._builder_view) # Index 0 -- PAGE_BUILDER # Index 1 is the operator view: RunnerShell (runner) or DashboardView # (desktop). _create_runner_view built exactly one of them. self._operator_view = self._runner_shell or self._dashboard_view @@ -723,6 +732,9 @@ def _build_chrome_row(self, parent: QWidget) -> QWidget: Overlaying costs nothing: the strip's middle is empty by construction, and the tab bar carries the same CHROME ground, so the seam is invisible. + + The strip and tabs are built here only if they do not exist yet: Switch + to Desktop calls this again to move the Pi header's pair into the row. """ row = QWidget(parent) row.setObjectName("shellChromeRow") @@ -730,10 +742,12 @@ def _build_chrome_row(self, parent: QWidget) -> QWidget: grid.setContentsMargins(0, 0, 0, 0) grid.setSpacing(0) - self._status_strip = StatusStrip(row) + if self._status_strip is None: + self._status_strip = StatusStrip(row) grid.addWidget(self._status_strip, 0, 0) - self._tab_bar = ShellTabBar(row) + if self._tab_bar is None: + self._tab_bar = ShellTabBar(row) grid.addWidget( self._tab_bar, 0, @@ -799,6 +813,25 @@ def _build_pi_header(self, parent: QWidget) -> QWidget: rows.addWidget(self._tab_bar) return header + def _install_desktop_chrome(self) -> None: + """Put the Pi header's strip and tabs back in the desktop chrome row. + + The same two widgets, out of touch mode, in the row ``_build_chrome_row`` + builds; the row takes the header's place in the column. The header, + holding only the gear now, and the notice bar are hidden, not deleted. + """ + self._status_strip.set_touch_mode(False) + self._tab_bar.set_touch_mode(False) + header = self._pi_header + container = header.parentWidget() + row = self._build_chrome_row(container) + container.layout().replaceWidget(header, row) + # A child added to a shown parent is otherwise shown a pass later. + row.show() + header.hide() + self._notice_bar.hide() + self._on_page_changed(self._stack.currentIndex()) + def _has_open_experiment(self) -> bool: """Whether something was already opened before the window was built. @@ -1232,7 +1265,10 @@ def _create_builder_view(self) -> None: self._node_editor.status_message.connect(self._show_status_message) self._node_editor.undo_redo_changed.connect(self._update_undo_redo_actions) - self._builder_view = AppShell(centre=self._graph_view, strip=self._status_strip) + # In runner mode the graph goes to the Pi dashboard, so the frame gets a + # placeholder centre; Switch to Desktop hands the graph back. + centre = None if self._view_manager.is_runner_mode else self._graph_view + self._builder_view = AppShell(centre=centre, strip=self._status_strip) self._builder_view.strip.palette_requested.connect(self._on_palette_requested) def _on_palette_requested(self) -> None: @@ -1400,8 +1436,9 @@ def _create_runner_shell_view(self) -> None: """Create the Pi 4-tab RunnerShell view (Setup / Run / Manual / Camera). One full-width page at a time behind a bottom tab bar, so it fits the - 480px touchscreen. Builds the shared HardwarePanel and CameraPanel that - the desktop docks re-host on a runner->desktop switch (no duplicates). + 480px touchscreen. Builds the shared HardwarePanel, CameraPanel and + RunnerDeviceControls; the Pi dashboard hosts them, and Switch to Desktop + re-hosts the first two in the desktop frame (no duplicates). """ from glider.gui.panels.runner_panel import RunnerPanel from glider.gui.runner.device_controls import RunnerDeviceControls @@ -1413,12 +1450,13 @@ def _create_runner_shell_view(self) -> None: self._runner_panel.start_requested.connect(self._on_start_clicked) self._runner_panel.stop_requested.connect(self._on_stop_clicked) - # --- Shared Hardware panel (desktop docks re-host this same instance) --- + # --- Shared Hardware panel (the Pi dashboard's Hardware tab). Its + # +Board/+Device row follows the graph lock; see _apply_pi_lock. self._hardware_panel = HardwarePanel( hardware_manager=self._core.hardware_manager, session_fn=lambda: self._core.session, run_async_fn=self._run_async, - show_add_buttons=False, + show_add_buttons=True, ) self._hardware_panel.status_message.connect(self._show_status_message) @@ -1452,12 +1490,14 @@ def _create_runner_shell_view(self) -> None: # --- Setup page --- self._runner_setup_page = RunnerSetupPage(self._core, hardware_widget=self._hardware_panel) - # --- Shell (owns the bottom tab bar + run banner) --- + # --- Shell (owns the bottom tab bar + run banner). The manual controls + # live on the Pi dashboard's Control tab, so Manual is a placeholder that + # keeps the shell's page indices (Camera is 3) where it expects them. self._runner_shell = RunnerShell( self._core, self._runner_setup_page, self._runner_panel, - self._runner_device_controls, + QWidget(), self._camera_panel, ) self._runner_panel.elapsed_updated.connect(self._runner_shell.set_banner_time) @@ -1537,8 +1577,8 @@ def _move_camera_to_operator_view(self) -> None: def _move_camera_to_builder(self) -> None: """Host the single CameraPanel in the Builder's Camera tab slot. - No-op in runner-only mode, which has not built the Builder's panels yet - — the camera then stays in the operator view, which is correct. + The slot is in the desktop frame's right panel, or in runner mode the Pi + dashboard's Camera tab. Nothing needs hiding at the far end any more. The empty slot the camera leaves behind sits inside the Builder frame, and the Builder frame is @@ -1556,6 +1596,18 @@ def _populate_builder_panels(self) -> None: wrap — this method re-hosts rather than rewrites. Left panel: Nodes, Hardware, Control, Files. Right panel: Properties, Camera. """ + self._build_builder_panels() + self._host_builder_panels_in_shell() + + def _build_builder_panels(self) -> None: + """Build the panels the desktop frame and the Pi dashboard share. + + The node library, the properties host, the hardware panel (unless the + runner view built it) and the camera tab slot. Idempotent: the Pi builds + these at startup, and Switch to Desktop only re-hosts them. + """ + if self._node_library_panel is not None: + return def session_fn(): return self._core.session @@ -1589,22 +1641,11 @@ def session_fn(): ) self._hardware_panel.status_message.connect(self._show_status_message) - # --- Control --- - self._device_control_panel = DeviceControlPanel( - hardware_manager=self._core.hardware_manager, - run_async_fn=self._run_async, - ) - self._device_control_panel.status_message.connect(self._show_status_message) - - # The Builder's HardwarePanel is a distinct instance from the - # dashboard's _dash_hardware_panel; its hardware_changed fans out only - # to the Builder's own _device_control_panel -- and to the strip, which - # is the roster half of what the two hardware readouts show. + # The strip is the roster half of what the two hardware readouts show. # ``hardware_connection_change`` reports a board *changing state*, which # is not a board being registered or dropped from the manager; without # this wire a board added or removed in this panel would not appear on # (or leave) the strip until something else happened to that board. - self._hardware_panel.hardware_changed.connect(self._device_control_panel.refresh_devices) self._hardware_panel.hardware_changed.connect(self._refresh_hardware_readouts) # Wire flow_functions_changed from node editor to node library @@ -1622,11 +1663,30 @@ def session_fn(): self._camera_panel = self._build_camera_panel() # Only take the single CameraPanel when Builder is the active view; # otherwise it stays in the operator view. This runs at desktop startup - # (and on runner->desktop switch) with Builder shown, so the camera - # correctly lands here then. + # with Builder shown, so the camera correctly lands here then. if self._stack is not None and self._stack.currentIndex() == PAGE_BUILDER: self._move_camera_to_builder() + def _host_builder_panels_in_shell(self) -> None: + """Put the shared panels in the desktop frame, with Control and Files. + + Control and Files are desktop-only and are built here, on the first + call; later calls do nothing. + """ + if self._device_control_panel is not None: + return + + # --- Control --- + self._device_control_panel = DeviceControlPanel( + hardware_manager=self._core.hardware_manager, + run_async_fn=self._run_async, + ) + self._device_control_panel.status_message.connect(self._show_status_message) + # The Builder's HardwarePanel is a distinct instance from the + # dashboard's _dash_hardware_panel; its hardware_changed fans out only + # to the Builder's own _device_control_panel (and the strip, above). + self._hardware_panel.hardware_changed.connect(self._device_control_panel.refresh_devices) + # --- Files --- from PyQt6.QtWidgets import QFrame, QPushButton, QScrollArea @@ -1689,6 +1749,52 @@ def session_fn(): self._builder_view.restore_layout(self._settings) self._sync_panel_actions() + def _build_pi_dashboard(self) -> PiDashboard: + """The Pi's Dashboard page: the graph over the shared panels as tabs. + + The graph starts in touch mode and locked; the lock also covers the + properties form and the hardware map (see ``_apply_pi_lock``). + """ + from PyQt6.QtWidgets import QPushButton + + self._build_builder_panels() + graph = self._graph_view + graph.set_touch_mode(True) + graph.set_locked(True) + graph.locked_changed.connect(self._apply_pi_lock) + self._apply_pi_lock(True) + + hardware = QWidget() + hardware_layout = QVBoxLayout(hardware) + hardware_layout.setContentsMargins(0, 0, 0, 0) + connect = QPushButton("Connect boards…") + connect.setObjectName("piConnectBoardsButton") + connect.setMinimumHeight(44) + connect.clicked.connect(lambda: self._hardware_panel.show_board_settings_dialog()) + hardware_layout.addWidget(connect) + hardware_layout.addWidget(self._hardware_panel, 1) + + dashboard = PiDashboard(graph) + dashboard.add_tab("nodes", "Nodes", self._node_library_panel) + dashboard.add_tab("properties", "Properties", self._properties_host) + dashboard.add_tab("hardware", "Hardware", hardware) + dashboard.add_tab("camera", "Camera", self._camera_tab_slot) + dashboard.add_tab("control", "Control", self._runner_device_controls) + + # Re-sync the manual controls each time their tab is shown, as the old + # Manual tab did: a board may have connected since they were last seen. + def _on_tab_changed(index: int) -> None: + if dashboard.tabs().widget(index) is self._runner_device_controls: + self._runner_device_controls.refresh() + + dashboard.tabs().currentChanged.connect(_on_tab_changed) + return dashboard + + def _apply_pi_lock(self, locked: bool) -> None: + """Extend the graph's lock to the properties form and the hardware map.""" + self._properties_host.setEnabled(not locked) + self._hardware_panel.set_editable(not locked) + def _heal_stale_paint(self, *_args) -> None: """Schedule a deferred full-window repaint after a central relayout. @@ -2500,6 +2606,9 @@ def _on_core_state_change(self, state) -> None: ) if self._runner_shell is not None: self._runner_shell.update_state(state_name) + if self._pi_dashboard is not None: + # The manual controls sit on the Pi dashboard, not in RunnerShell. + self._runner_device_controls.update_state(state_name) self._refresh_strip_run_state(state_name) @@ -2949,22 +3058,37 @@ def _set_default_layout(self) -> None: self._show_status_message("Default layout restored", 2000) def _switch_to_desktop_mode(self) -> None: - """Switch from runner to desktop mode.""" + """Switch from runner to desktop mode. + + The Pi dashboard's widgets move into the desktop frame, which takes + stack page 0 back, and the desktop chrome row replaces the Pi header. + Nothing is rebuilt. Refused while a run is live, before anything moves. + """ + if self._refuse_while_running("switch to the desktop layout"): + return self._view_manager.mode = ViewMode.DESKTOP + + dashboard = self._pi_dashboard + if dashboard is not None and self._stack.widget(PAGE_BUILDER) is dashboard: + graph = self._graph_view + graph.set_locked(False) + graph.set_touch_mode(False) + self._builder_view.set_centre(graph) + self._stack.removeWidget(dashboard) + self._stack.insertWidget(PAGE_BUILDER, self._builder_view) + self._host_builder_panels_in_shell() + self._install_desktop_chrome() + self.setWindowFlags(Qt.WindowType.Window) self.showNormal() self._stack.setCurrentIndex(PAGE_BUILDER) self._restore_analysis_dock() - if self._node_library_panel is None: - self._populate_builder_panels() - # Ensure the single reparented CameraPanel lands in the Builder's Camera - # tab. On first invocation _populate_builder_panels already did this - # (idempotent); on repeat invocations (the panels already exist) the - # camera is still in the operator view from switch_to_runner, so this - # explicit move prevents an empty Camera tab. Mirrors switch_to_builder(). + # tab: on a repeat invocation the camera is still in the operator view + # from switch_to_runner, so this explicit move prevents an empty Camera + # tab. Mirrors switch_to_builder(). self._move_camera_to_builder() screen_size = self._view_manager.screen_size @@ -2978,18 +3102,17 @@ def _switch_to_desktop_mode(self) -> None: def _refuse_while_running(self, action: str) -> bool: """True (after telling the user) if an experiment is live or mid-start. - New/Open clear the flow engine and hardware out from under a run. + New/Open clear the flow engine and hardware out from under a run. Runner + mode says so on the notice bar rather than in a dialog. """ - from glider.core.experiment_session import SessionState - - if self._core.state in (SessionState.RUNNING, SessionState.PAUSED) or ( - self._core.is_experiment_busy - ): - QMessageBox.information( - self, "Experiment running", f"Stop the experiment before you {action}." - ) - return True - return False + if not self._run_is_live(): + return False + message = f"Stop the experiment before you {action}." + if self._view_manager.is_runner_mode: + self._notify_user("Experiment running", message, level="info") + else: + QMessageBox.information(self, "Experiment running", message) + return True def _reset_undo_history(self) -> None: """Drop undo/redo commands that refer to the previous graph.""" @@ -4276,7 +4399,9 @@ def closeEvent(self, event) -> None: # The Builder frame is a page of the stack, not a window, so the window # is what remembers its layout. Never raises; see AppShell.save_layout. - if self._builder_view is not None: + # Not in runner mode, where the frame is off the stack and has no layout + # worth keeping. + if self._builder_view is not None and not self._view_manager.is_runner_mode: self._builder_view.save_layout(self._settings) # Deterministically stop the CameraPanel CV thread now that we are diff --git a/src/glider/gui/panels/hardware_panel.py b/src/glider/gui/panels/hardware_panel.py index 12242ccf..e3e2fd67 100644 --- a/src/glider/gui/panels/hardware_panel.py +++ b/src/glider/gui/panels/hardware_panel.py @@ -60,9 +60,10 @@ def __init__( hardware_manager: HardwareManager instance session_fn: Callable that returns the current ExperimentSession (or None) run_async_fn: Callable to schedule async coroutines - show_add_buttons: Whether to show the "+ Board"/"+ Device" buttons. - The Runner (Pi) UI hides these since the hardware map cannot be - edited on-device; the desktop dock keeps the default of True. + show_add_buttons: Whether to build the "+ Board"/"+ Device" buttons. + Only the desktop Run page's hidden panel passes False; the Pi + builds them and hides them while the graph is locked (see + :meth:`set_editable`). """ super().__init__(parent) self._hardware_manager = hardware_manager @@ -89,8 +90,7 @@ def _setup_ui(self): self._hardware_tree.customContextMenuRequested.connect(self._on_hardware_context_menu) layout.addWidget(self._hardware_tree) - # Hardware buttons (hidden on the Runner/Pi UI, which cannot edit the - # hardware map on-device). + # Hardware buttons (hidden while not editable; see set_editable). if self._show_add_buttons: self._add_row = QWidget() hw_btn_layout = QHBoxLayout(self._add_row) diff --git a/tests/unit/gui/runner/test_pi_dashboard_window.py b/tests/unit/gui/runner/test_pi_dashboard_window.py new file mode 100644 index 00000000..a3d853cc --- /dev/null +++ b/tests/unit/gui/runner/test_pi_dashboard_window.py @@ -0,0 +1,133 @@ +"""The Pi's Dashboard tab as MainWindow builds it, and Switch to Desktop. + +In runner mode stack page 0 is the graph over panel tabs, holding the real +Builder widgets. Switching to the desktop layout re-hosts those same objects in +the desktop frame; nothing is built twice. +""" + +from __future__ import annotations + +from PyQt6.QtWidgets import QPushButton + +from glider.gui.main_window import PAGE_BUILDER +from glider.gui.runner.pi_dashboard import PiDashboard + + +def _is_descendant(child, ancestor): + w = child + while w is not None: + if w is ancestor: + return True + w = w.parentWidget() + return False + + +def _pi(qtbot, main_window_factory): + w = main_window_factory(desktop_mode=False) + w.resize(480, 800) + w.show() + # A runner window shows itself before its central widget exists, so Qt only + # shows (and lays out) the header on the next pass of the event loop. + qtbot.waitUntil(w._pi_header.isVisible) + return w + + +def test_dashboard_tab_is_graph_over_panel_tabs(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + w.switch_to_builder() + page = w._stack.widget(PAGE_BUILDER) + assert isinstance(page, PiDashboard) + assert _is_descendant(w._graph_view, page) + assert page.keys() == ["nodes", "properties", "hardware", "camera", "control"] + assert _is_descendant(w._camera_panel, page) + assert w._graph_view.dragMode().name == "ScrollHandDrag" + # The desktop frame is off the stack, so it no longer sets the width floor. + assert w.minimumSizeHint().width() <= 480 + + +def test_panel_tabs_hold_the_real_widgets(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + tabs = w._pi_dashboard.tabs() + assert tabs.widget(0) is w._node_library_panel + assert tabs.widget(1) is w._properties_host + assert tabs.widget(3) is w._camera_tab_slot + assert tabs.widget(4) is w._runner_device_controls + hardware = tabs.widget(2) + assert _is_descendant(w._hardware_panel, hardware) + (connect,) = [b for b in hardware.findChildren(QPushButton) if b.text() == "Connect boards…"] + assert connect.minimumHeight() >= 44 + + +def test_connect_boards_opens_the_board_dialog(qtbot, main_window_factory, monkeypatch): + w = _pi(qtbot, main_window_factory) + opened = [] + monkeypatch.setattr(w._hardware_panel, "show_board_settings_dialog", lambda: opened.append(1)) + hardware = w._pi_dashboard.tabs().widget(2) + (connect,) = [b for b in hardware.findChildren(QPushButton) if b.text() == "Connect boards…"] + connect.click() + assert opened == [1] + + +def test_control_tab_refreshes_the_manual_controls_when_shown( + qtbot, main_window_factory, monkeypatch +): + w = _pi(qtbot, main_window_factory) + calls = [] + monkeypatch.setattr(w._runner_device_controls, "refresh", lambda: calls.append(1)) + w._pi_dashboard.set_current("hardware") + assert calls == [] + w._pi_dashboard.set_current("control") + assert calls == [1] + + +def test_graph_starts_locked_and_the_lock_covers_properties_and_hardware( + qtbot, main_window_factory +): + w = _pi(qtbot, main_window_factory) + assert w._graph_view.is_locked() + assert not w._properties_host.isEnabled() + assert w._hardware_panel._add_row.isHidden() + w._graph_view.set_locked(False) + assert w._properties_host.isEnabled() + assert not w._hardware_panel._add_row.isHidden() + w._graph_view.set_locked(True) + assert not w._properties_host.isEnabled() + assert w._hardware_panel._add_row.isHidden() + + +def test_switch_to_desktop_rehosts_without_duplicates(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + hw, cam, graph, lib = w._hardware_panel, w._camera_panel, w._graph_view, w._node_library_panel + w._switch_to_desktop_mode() + assert w._stack.widget(PAGE_BUILDER) is w._builder_view + assert w._builder_view.centre is graph + assert (w._hardware_panel, w._camera_panel, w._node_library_panel) == (hw, cam, lib) + assert w._builder_view.left.widget_for("hardware") is hw + assert cam.parent() is w._camera_tab_slot + assert _is_descendant(cam, w._builder_view.right) + assert w._tab_bar.buttons()["analyze"].isVisibleTo(w) + assert not w._gear_button.isVisibleTo(w) + assert graph.dragMode().name == "RubberBandDrag" and not graph.is_locked() + + +def test_switch_to_desktop_restores_the_desktop_chrome(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + w._switch_to_desktop_mode() + strip = w._status_strip + assert strip.maximumHeight() == 40 + assert w._tab_bar.parentWidget() is strip.parentWidget() + assert strip.parentWidget().objectName() == "shellChromeRow" + assert not w._pi_header.isVisibleTo(w) and not w._notice_bar.isVisibleTo(w) + # On the Dashboard the desktop shows its panel toggles again. + assert strip.left_toggle().isVisibleTo(w) + assert strip.palette_hint().isVisibleTo(w) + + +def test_switch_to_desktop_is_refused_mid_run(qtbot, main_window_factory, monkeypatch): + w = _pi(qtbot, main_window_factory) + monkeypatch.setattr(w, "_run_is_live", lambda: True) + w._switch_to_desktop_mode() + assert isinstance(w._stack.widget(PAGE_BUILDER), PiDashboard) + assert w.is_runner_mode + assert _is_descendant(w._graph_view, w._pi_dashboard) + assert w._notice_bar.text().startswith("Experiment running") diff --git a/tests/unit/gui/runner/test_runner_shell_integration.py b/tests/unit/gui/runner/test_runner_shell_integration.py index 71cf0b15..2398cb07 100644 --- a/tests/unit/gui/runner/test_runner_shell_integration.py +++ b/tests/unit/gui/runner/test_runner_shell_integration.py @@ -1,10 +1,9 @@ """Integration test: RunnerShell wired into MainWindow. Builds a real GliderCore + MainWindow in runner mode to verify the -single-tab Runner shell boots on Setup and that switching to desktop mode -reuses (rather than duplicates) the shared HardwarePanel/CameraPanel -instances built by ``_create_runner_view`` -- re-hosting them into the -Builder shell's side panels rather than building a second pair. +single-tab Runner shell boots on Setup. The desktop-switch reuse of the shared +HardwarePanel/CameraPanel, and the manual controls' home, are covered in +``test_pi_dashboard_window.py`` now that the Pi dashboard hosts them. CameraPanel starts a CV QThread in its constructor and only stops it from ``closeEvent``/``destroyed``. Since it is now nested inside RunnerShell's @@ -56,25 +55,3 @@ def test_boots_on_setup_tab(qtbot): assert w._runner_shell._stack.currentIndex() == 0 finally: _teardown(core, loop) - - -def test_desktop_switch_no_duplicate_hardware_panel(qtbot): - w, core, loop = _runner_window(qtbot) - try: - hp_id = id(w._hardware_panel) - cam_id = id(w._camera_panel) - w._switch_to_desktop_mode() - assert id(w._hardware_panel) == hp_id - assert w._builder_view.left.widget_for("hardware") is w._hardware_panel - assert id(w._camera_panel) == cam_id - assert w._camera_panel.parent() is w._camera_tab_slot - finally: - _teardown(core, loop) - - -def test_manual_tab_is_device_controls(qtbot): - w, core, loop = _runner_window(qtbot) - try: - assert w._runner_shell._stack.widget(2) is w._runner_device_controls - finally: - _teardown(core, loop) diff --git a/tests/unit/gui/test_main_window_file_ops.py b/tests/unit/gui/test_main_window_file_ops.py index 895a14a9..5bcf01e5 100644 --- a/tests/unit/gui/test_main_window_file_ops.py +++ b/tests/unit/gui/test_main_window_file_ops.py @@ -43,6 +43,7 @@ def _window(state=SessionState.IDLE, busy=False): data_recorder=_Recorder(), ) w._cleared = cleared + w._view_manager = types.SimpleNamespace(is_runner_mode=False) w._graph_view = types.SimpleNamespace(clear_graph=lambda: None) w._undo_stack = UndoStack() for attr in ( From c773adb53d5b393361d162fdf0dcfca16835e0cc Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 09:34:35 -0500 Subject: [PATCH 11/25] feat(pi): run page replaces the four-tab runner shell --- src/glider/gui/main_window.py | 179 ++++++++++-------- src/glider/gui/panels/camera_panel.py | 21 +- tests/unit/gui/runner/test_pi_run_window.py | 127 +++++++++++++ .../runner/test_runner_shell_integration.py | 57 ------ .../unit/gui/shell/test_main_window_shell.py | 2 +- tests/unit/gui/shell/test_shell_navigation.py | 3 + tests/unit/gui/test_dashboard_integration.py | 10 +- 7 files changed, 245 insertions(+), 154 deletions(-) create mode 100644 tests/unit/gui/runner/test_pi_run_window.py delete mode 100644 tests/unit/gui/runner/test_runner_shell_integration.py diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index 13d404f2..607863d8 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -78,12 +78,15 @@ if TYPE_CHECKING: from glider.core.glider_core import GliderCore + from glider.gui.runner.run_page import PiRunPage logger = logging.getLogger(__name__) _LIVE_STATES = ("RUNNING", "PAUSED") # States during which the current run's folder is still the one in use. _RUN_FOLDER_STATES = ("RUNNING", "PAUSED", "STOPPING") +# On the Pi these hold the graph locked, and make the header pill a way to Run. +_PI_LOCKED_STATES = ("RUNNING", "PAUSED", "INITIALIZING", "STOPPING") # QSettings flag, namespaced alongside the existing first_run/* keys and kept # separate from ``first_run/tour_complete``: one shared flag would mean sitting @@ -457,8 +460,10 @@ def __init__( self._pi_header: QWidget | None = None self._gear_button: QToolButton | None = None self._notice_bar: NoticeBar | None = None - # Runner mode only: stack page 0, the graph over the Builder's panels as - # tabs. Switch to Desktop takes those widgets back into _builder_view. + # Built in runner mode only: stack page 0, the graph over the Builder's + # panels as tabs. Switch to Desktop takes those widgets back into + # _builder_view and drops this off the stack, but the attribute stays + # set -- test is_runner_mode, not this, for "on the Pi". self._pi_dashboard: PiDashboard | None = None # The editor behind the Metadata and Mice sections, and the two pages # it was split into. Both are rebuilt per session; see @@ -535,12 +540,14 @@ def __init__( self._plugins_task = None # asyncio.Task for the open in progress # Operator (non-Builder) view. Exactly ONE of these is built, chosen by - # startup mode: the 4-tab RunnerShell in runner mode (Pi touchscreen), - # the camera-cockpit DashboardView in desktop mode. The other stays None, so every - # site that touches a mode-specific widget must guard on it. + # startup mode: the Pi Run page in runner mode (Pi touchscreen), the + # camera-cockpit DashboardView in desktop mode. The other stays None, so + # every site that touches a mode-specific widget must guard on it. The + # Pi page stays page 1 after Switch to Desktop. self._dashboard_view = None # DashboardView (desktop mode) - self._run_control_panel = None # RunControlPanel (dashboard) - self._run_header = None # RunHeader (dashboard) + self._pi_run_page: PiRunPage | None = None # runner mode + self._run_control_panel = None # RunControlPanel (either view) + self._run_header = None # RunHeader (either view) # The current run's numbered folder, shown while it records (preset runs). self._live_run_folder: Path | None = None # A run went live since the last reset; READY/IDLE then resets run values. @@ -548,9 +555,6 @@ def __init__( # Rehearse is starting the experiment / started the one now running. self._rehearsal_starting = False self._rehearsal_started_run = False - self._runner_shell = None # RunnerShell (runner mode) - self._runner_panel = None # RunnerPanel — Run tab (runner mode) - self._runner_setup_page = None # RunnerSetupPage — Setup tab (runner mode) # Experiment dialog self._experiment_dialog: ExperimentDialog | None = None @@ -681,9 +685,9 @@ def _setup_ui(self) -> None: # the stack, for Switch to Desktop. self._pi_dashboard = self._build_pi_dashboard() self._stack.addWidget(self._pi_dashboard or self._builder_view) # Index 0 -- PAGE_BUILDER - # Index 1 is the operator view: RunnerShell (runner) or DashboardView + # Index 1 is the operator view: the Pi Run page (runner) or DashboardView # (desktop). _create_runner_view built exactly one of them. - self._operator_view = self._runner_shell or self._dashboard_view + self._operator_view = self._pi_run_page or self._dashboard_view self._stack.addWidget(self._operator_view) # Index 1 -- PAGE_OPERATOR self._create_experiment_page() @@ -1315,13 +1319,13 @@ def commands(self) -> list[Command]: def _create_runner_view(self) -> None: """Build the operator (non-Builder) view for the detected mode. - Runner mode (Pi touchscreen) gets the 4-tab RunnerShell — one full-width - page at a time, which fits the 480px screen. Desktop mode gets the - camera-first DashboardView. Exactly one is built, so the panels they share - (CameraPanel, RunnerDeviceControls, HardwarePanel) are never double-owned. + Runner mode (Pi touchscreen) gets the Pi Run page, which fits the 480px + screen. Desktop mode gets the camera-first DashboardView. Exactly one is + built, so the panels they share (CameraPanel, RunnerDeviceControls, + HardwarePanel) are never double-owned. """ if self._view_manager.is_runner_mode: - self._create_runner_shell_view() + self._create_pi_run_view() else: self._create_dashboard_view() @@ -1432,23 +1436,32 @@ def _create_dashboard_view(self) -> None: self._health_timer.timeout.connect(self._refresh_run_health) self._health_timer.start() - def _create_runner_shell_view(self) -> None: - """Create the Pi 4-tab RunnerShell view (Setup / Run / Manual / Camera). + def _create_pi_run_view(self) -> None: + """Create the Pi Run page, and the panels the Pi dashboard hosts. - One full-width page at a time behind a bottom tab bar, so it fits the - 480px touchscreen. Builds the shared HardwarePanel, CameraPanel and - RunnerDeviceControls; the Pi dashboard hosts them, and Switch to Desktop - re-hosts the first two in the desktop frame (no duplicates). + The page is stack page 1. Its header and run box stand in for the + desktop ones (``_run_header``, ``_run_control_panel``) and its camera + slot for the desktop camera stage, so the session, run-info and camera + wiring the desktop uses applies unchanged. Also builds the shared + HardwarePanel, CameraPanel and RunnerDeviceControls; the Pi dashboard + hosts them, and Switch to Desktop re-hosts the first two in the desktop + frame (no duplicates). """ - from glider.gui.panels.runner_panel import RunnerPanel from glider.gui.runner.device_controls import RunnerDeviceControls - from glider.gui.runner.runner_setup_page import RunnerSetupPage - from glider.gui.runner.runner_shell import RunnerShell + from glider.gui.runner.run_page import PiRunPage # --- Run page --- - self._runner_panel = RunnerPanel(self._core, self._view_manager) - self._runner_panel.start_requested.connect(self._on_start_clicked) - self._runner_panel.stop_requested.connect(self._on_stop_clicked) + page = self._pi_run_page = PiRunPage(self._core) + page.start_requested.connect(self._on_start_clicked) + page.stop_requested.connect(self._on_stop_clicked) + page.pause_requested.connect(self._on_pause_clicked) + page.resume_requested.connect(self._on_resume_clicked) + page.header.run_values_changed.connect(self._refresh_run_info) + page.control.elapsed_updated.connect(self._on_pi_elapsed) + self._run_header = page.header + self._run_control_panel = page.control + self._camera_slot = page.camera_slot + self._status_strip.pill_clicked.connect(self._on_pi_pill_clicked) # --- Shared Hardware panel (the Pi dashboard's Hardware tab). Its # +Board/+Device row follows the graph lock; see _apply_pi_lock. @@ -1460,10 +1473,12 @@ def _create_runner_shell_view(self) -> None: ) self._hardware_panel.status_message.connect(self._show_status_message) - # --- Shared Camera panel (the single reparented CameraPanel) --- + # --- Shared Camera panel (the single reparented CameraPanel), parked + # video-only in the Run page's slot until the Dashboard tab takes it. self._camera_panel = self._build_camera_panel() + self._move_camera_to_operator_view() - # --- Manual page --- + # --- Manual controls (the Pi dashboard's Control tab) --- self._runner_device_controls = RunnerDeviceControls( self._core.hardware_manager, session_fn=lambda: self._core.session ) @@ -1487,42 +1502,33 @@ def _create_runner_shell_view(self) -> None: # two overlapping runs would corrupt each other's saved state. self._manual_run_busy = False - # --- Setup page --- - self._runner_setup_page = RunnerSetupPage(self._core, hardware_widget=self._hardware_panel) - - # --- Shell (owns the bottom tab bar + run banner). The manual controls - # live on the Pi dashboard's Control tab, so Manual is a placeholder that - # keeps the shell's page indices (Camera is 3) where it expects them. - self._runner_shell = RunnerShell( - self._core, - self._runner_setup_page, - self._runner_panel, - QWidget(), - self._camera_panel, - ) - self._runner_panel.elapsed_updated.connect(self._runner_shell.set_banner_time) - self._runner_shell.stop_requested.connect(self._on_stop_clicked) - - # Setup page file-action wiring (without these the buttons are dead). - self._runner_setup_page.new_requested.connect(self._on_new) - self._runner_setup_page.open_requested.connect(self._on_open) - self._runner_setup_page.save_requested.connect(self._on_save) - self._runner_setup_page.save_as_requested.connect(self._on_save_as) - self._runner_setup_page.help_requested.connect(self._on_help) - self._runner_setup_page.close_requested.connect(self.close) - self._runner_setup_page.switch_to_desktop_requested.connect(self._switch_to_desktop_mode) - self._runner_setup_page.board_settings_requested.connect( - self._hardware_panel.show_board_settings_dialog - ) - # Hardware-change fan-out (owned here since the runner-mode Hardware - # Panel is constructed here; _setup_dock_widgets reuses this instance - # and must NOT re-wire these, to avoid double-firing). - self._hardware_panel.hardware_changed.connect(self._runner_panel.refresh_devices) - self._hardware_panel.hardware_changed.connect(self._runner_setup_page.refresh) + # Panel is constructed here; _build_builder_panels reuses this instance + # and adds only the strip readouts, so nothing fires twice). + self._hardware_panel.hardware_changed.connect(page.devices.refresh_devices) self._hardware_panel.hardware_changed.connect(self._runner_device_controls.refresh) self._hardware_panel.refresh_tree() + def _on_pi_elapsed(self, text: str) -> None: + """Put the run clock's mm:ss on the header pill while a run is going. + + The pill keeps the state ``_refresh_strip_run_state`` gave it (REC when + recording); it is restyled only when the minutes or seconds change. + """ + state = getattr(self, "_last_session_state", None) + if not self._view_manager.is_runner_mode or state != "RUNNING": + return + strip = self._status_strip + detail = text.rsplit(".", 1)[0] + if detail != strip.run_detail(): + strip.set_run_state(strip.run_state(), detail) + + def _on_pi_pill_clicked(self) -> None: + """A tap on the header pill mid-run goes back to the Run page.""" + state = getattr(self, "_last_session_state", None) + if self._view_manager.is_runner_mode and state in _PI_LOCKED_STATES: + self._enter_dashboard() + def _build_camera_panel(self) -> CameraPanel: """Construct and fully configure a CameraPanel. @@ -1560,19 +1566,15 @@ def _build_camera_panel(self) -> CameraPanel: def _move_camera_to_operator_view(self) -> None: """Host the single CameraPanel in the active operator view. - Desktop mode has a dashboard camera slot; runner mode has the - RunnerShell's Camera tab. Only one exists, so this targets whichever is - built — used on entry to the operator view (including after a - desktop->operator switch that had moved the camera into the dock). + ``_camera_slot`` is the desktop dashboard's camera stage or the Pi Run + page's camera slot, whichever was built -- used on entry to the + operator view (including after a desktop->operator switch that had + moved the camera into the Builder). """ - if self._camera_panel is None: + if self._camera_panel is None or self._camera_slot is None: return - if self._camera_slot is not None: - self._camera_slot.layout().addWidget(self._camera_panel) # dashboard slot - self._camera_panel.set_controls_visible(False) # the cockpit shows video only - elif self._runner_shell is not None: - self._camera_panel.set_controls_visible(True) - self._runner_shell.rehost_camera() # RunnerShell Camera tab + self._camera_slot.layout().addWidget(self._camera_panel) + self._camera_panel.set_controls_visible(False) # the operator view shows video only def _move_camera_to_builder(self) -> None: """Host the single CameraPanel in the Builder's Camera tab slot. @@ -2551,14 +2553,12 @@ def _connect_signals(self) -> None: # so without this the readouts would keep describing the previous # experiment's boards -- green -- over an empty manager. self.session_changed.connect(self._refresh_hardware_readouts) - # Dashboard-only panels (desktop mode). + # The operator view's header and run box (the desktop dashboard's, or + # the Pi Run page's). if self._run_control_panel is not None: self.session_changed.connect(self._run_header.refresh) self.session_changed.connect(self._refresh_run_info) self.session_changed.connect(self._run_control_panel.refresh) - # Runner-only Setup page (runner mode). - if self._runner_setup_page is not None: - self.session_changed.connect(self._runner_setup_page.refresh) # Backstop for a bleak disconnect callback that never fires (spec §5). # __init__ runs before qasync starts the loop, so this no-ops here; @@ -2604,11 +2604,21 @@ def _on_core_state_change(self, state) -> None: self._dashboard_view.update_state( state_name, recording=bool(self._core.data_recorder.is_recording) ) - if self._runner_shell is not None: - self._runner_shell.update_state(state_name) - if self._pi_dashboard is not None: - # The manual controls sit on the Pi dashboard, not in RunnerShell. + if self._pi_run_page is not None: + # Still page 1 after Switch to Desktop, so not gated on the mode. + self._pi_run_page.update_state(state_name) + self._pi_run_page.set_camera_visible(self._core.camera_manager.is_streaming) + if self._view_manager.is_runner_mode: + # The manual controls sit on the Pi dashboard's Control tab. self._runner_device_controls.update_state(state_name) + # A run holds the graph locked; it stays locked after, but the + # toggle is free again. + graph = self._graph_view + if state_name in _PI_LOCKED_STATES: + graph.set_locked(True) + graph.set_lock_enabled(False) + else: + graph.set_lock_enabled(True) self._refresh_strip_run_state(state_name) @@ -2957,6 +2967,9 @@ def _enter_dashboard(self) -> None: toggle and programmatic switches.""" self._stack.setCurrentIndex(PAGE_OPERATOR) self._move_camera_to_operator_view() + if self._pi_run_page is not None: + # No empty camera box: the slot shows only while a stream runs. + self._pi_run_page.set_camera_visible(self._core.camera_manager.is_streaming) # Everything the Builder shows is inside the stack page that just went # away -- except the Analysis dock, which is a dock on the window and so # is painted over the operator view exactly as the Builder docks used to @@ -3071,6 +3084,8 @@ def _switch_to_desktop_mode(self) -> None: dashboard = self._pi_dashboard if dashboard is not None and self._stack.widget(PAGE_BUILDER) is dashboard: graph = self._graph_view + # Off the stack, the dashboard must not follow selections any more. + graph.node_selected.disconnect(dashboard._on_node_selected) graph.set_locked(False) graph.set_touch_mode(False) self._builder_view.set_centre(graph) diff --git a/src/glider/gui/panels/camera_panel.py b/src/glider/gui/panels/camera_panel.py index 9efbe6b1..a5921bbf 100644 --- a/src/glider/gui/panels/camera_panel.py +++ b/src/glider/gui/panels/camera_panel.py @@ -31,6 +31,7 @@ QSizePolicy, QSlider, QStackedWidget, + QStatusBar, QVBoxLayout, QWidget, ) @@ -1934,16 +1935,16 @@ def _set_drop_active(self, active: bool) -> None: style.polish(self) def _show_status(self, message: str) -> None: - """Surface a short message; falls back to the log when there's no bar.""" - window = self.window() - bar = getattr(window, "statusBar", None) - if callable(bar): - try: - bar().showMessage(message, 5000) - return - except Exception: - pass - logger.info("%s", message) + """Surface a short message; falls back to the log when there's no bar. + + Looks the bar up rather than calling ``QMainWindow.statusBar()``, which + would create one on a window built without it (the Pi's). + """ + bar = self.window().findChild(QStatusBar, options=Qt.FindChildOption.FindDirectChildrenOnly) + if bar is None: + logger.info("%s", message) + return + bar.showMessage(message, 5000) def _parse_keypoint_names(self) -> list[str]: """Split the keypoint-names field into a trimmed, non-empty name list.""" diff --git a/tests/unit/gui/runner/test_pi_run_window.py b/tests/unit/gui/runner/test_pi_run_window.py new file mode 100644 index 00000000..7fd10866 --- /dev/null +++ b/tests/unit/gui/runner/test_pi_run_window.py @@ -0,0 +1,127 @@ +"""The Pi's Run tab as MainWindow builds it. + +In runner mode stack page 1 is the Pi Run page, not the old four-tab shell. A +live run forces the graph lock, the header pill carries the run clock while you +are elsewhere, and the camera box only appears when a stream is running. +""" + +from __future__ import annotations + +from PyQt6.QtWidgets import QStatusBar + +from glider.gui.main_window import PAGE_OPERATOR +from glider.gui.runner.run_page import PiRunPage + + +def _pi(qtbot, main_window_factory): + w = main_window_factory(desktop_mode=False) + w.resize(480, 800) + w.show() + # The header lays out one event-loop pass after show. + qtbot.waitUntil(w._pi_header.isVisible) + return w + + +def _state(name): + return type("S", (), {"name": name})() + + +def test_run_tab_is_the_pi_run_page_with_one_nav_bar(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + assert isinstance(w._stack.widget(PAGE_OPERATOR), PiRunPage) + assert w._stack.currentIndex() == PAGE_OPERATOR # the kiosk opens on Run + assert not hasattr(w, "_runner_shell") or w._runner_shell is None + + +def test_run_page_is_the_runs_header_and_controls(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + page = w._pi_run_page + assert w._run_header is page.header + assert w._run_control_panel is page.control + assert w._camera_slot is page.camera_slot + + +def test_a_run_forces_the_lock_and_frees_the_toggle_after(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + g = w._graph_view + g.set_locked(False) + w._on_core_state_change(_state("RUNNING")) + assert g.is_locked() and not g._overlay.lock_btn.isEnabled() + w._on_core_state_change(_state("READY")) + assert g.is_locked() and g._overlay.lock_btn.isEnabled() + + +def test_run_state_reaches_the_page_and_the_manual_controls(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + seen = [] + w._runner_device_controls.update_state = seen.append + w._on_core_state_change(_state("RUNNING")) + assert seen == ["RUNNING"] + assert w._pi_run_page.clock.property("live") is True + + +def test_rec_pill_shows_minutes_and_goes_to_run(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + w._on_core_state_change(_state("RUNNING")) + w._pi_run_page.control.elapsed_updated.emit("12:41.36") + assert w._status_strip.run_detail() == "12:41" + w.switch_to_builder() + w._status_strip.pill_clicked.emit() + assert w._stack.currentIndex() == PAGE_OPERATOR + + +def test_pill_does_nothing_when_no_run_is_live(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + w._pi_run_page.control.elapsed_updated.emit("00:03.10") + assert w._status_strip.run_detail() != "00:03" + w.switch_to_builder() + w._status_strip.pill_clicked.emit() + assert w._stack.currentIndex() != PAGE_OPERATOR + + +def test_no_empty_camera_box_without_a_stream(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + w._pi_run_page.set_camera_visible(True) # left up by a stream that has since stopped + w._enter_dashboard() + assert not w._pi_run_page.camera_slot.isVisibleTo(w) + + +def test_camera_box_shows_while_a_stream_runs(qtbot, main_window_factory, monkeypatch): + w = _pi(qtbot, main_window_factory) + monkeypatch.setattr(type(w._core.camera_manager), "is_streaming", property(lambda _: True)) + w._enter_dashboard() + assert w._pi_run_page.camera_slot.isVisibleTo(w) + w._pi_run_page.set_camera_visible(False) + w._on_core_state_change(_state("RUNNING")) + assert w._pi_run_page.camera_slot.isVisibleTo(w) + + +def test_camera_on_the_run_page_is_video_only(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + cam = w._camera_panel + w.switch_to_builder() + assert cam._refresh_btn.isVisibleTo(cam) # Dashboard's Camera tab: full panel + w._enter_dashboard() + assert cam.parentWidget() is w._pi_run_page.camera_slot + assert not cam._refresh_btn.isVisibleTo(cam) + + +def test_desktop_switch_unhooks_the_removed_dashboard(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + w._pi_dashboard.set_current("nodes") + w._switch_to_desktop_mode() + w._graph_view.node_selected.emit("n1") + assert w._pi_dashboard.current() == "nodes" + + +def test_camera_status_does_not_grow_a_status_bar(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + w._camera_panel._show_status("Camera settings saved") + assert w.findChild(QStatusBar) is None + + +def test_camera_status_still_reaches_the_desktop_status_bar(qtbot, main_window_factory): + w = main_window_factory(desktop_mode=True) + w.switch_to_builder() + w._camera_panel._show_status("Camera settings saved") + assert w.statusBar().currentMessage() == "Camera settings saved" diff --git a/tests/unit/gui/runner/test_runner_shell_integration.py b/tests/unit/gui/runner/test_runner_shell_integration.py deleted file mode 100644 index 2398cb07..00000000 --- a/tests/unit/gui/runner/test_runner_shell_integration.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Integration test: RunnerShell wired into MainWindow. - -Builds a real GliderCore + MainWindow in runner mode to verify the -single-tab Runner shell boots on Setup. The desktop-switch reuse of the shared -HardwarePanel/CameraPanel, and the manual controls' home, are covered in -``test_pi_dashboard_window.py`` now that the Pi dashboard hosts them. - -CameraPanel starts a CV QThread in its constructor and only stops it from -``closeEvent``/``destroyed``. Since it is now nested inside RunnerShell's -QStackedWidget (not owned directly by the window), Qt does not fire those on a -MainWindow teardown, which used to leave the thread alive at interpreter -finalization and crash the process. ``MainWindow.closeEvent`` now stops the -CV thread deterministically, and pytest-qt's ``addWidget`` teardown fires -that ``closeEvent`` — so no manual camera-panel close is needed here. -""" - -import asyncio - -import pytest - -pytestmark = pytest.mark.usefixtures("qtbot") - - -def _runner_window(qtbot): - from PyQt6.QtWidgets import QApplication - - from glider.core.glider_core import GliderCore - from glider.gui.main_window import MainWindow - from glider.gui.view_manager import ViewManager, ViewMode - - app = QApplication.instance() - core = GliderCore() - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete(core.initialize()) - vm = ViewManager(app) - vm.mode = ViewMode.RUNNER - w = MainWindow(core, view_manager=vm) - w.switch_to_runner() - qtbot.addWidget(w) - return w, core, loop - - -def _teardown(core, loop): - try: - loop.run_until_complete(core.shutdown()) - finally: - loop.close() - asyncio.set_event_loop(None) - - -def test_boots_on_setup_tab(qtbot): - w, core, loop = _runner_window(qtbot) - try: - assert w._runner_shell._stack.currentIndex() == 0 - finally: - _teardown(core, loop) diff --git a/tests/unit/gui/shell/test_main_window_shell.py b/tests/unit/gui/shell/test_main_window_shell.py index d6b802d5..418a12c0 100644 --- a/tests/unit/gui/shell/test_main_window_shell.py +++ b/tests/unit/gui/shell/test_main_window_shell.py @@ -314,7 +314,7 @@ def test_camera_returns_to_the_builder_from_runner_mode(qtbot, main_window_facto assert _is_descendant(camera, window._builder_view.right) window.switch_to_runner() - assert _is_descendant(camera, window._runner_shell) + assert _is_descendant(camera, window._pi_run_page.camera_slot) window._switch_to_desktop_mode() # repeat: panels exist, camera must still move assert _is_descendant(camera, window._builder_view.right) diff --git a/tests/unit/gui/shell/test_shell_navigation.py b/tests/unit/gui/shell/test_shell_navigation.py index 9b387ec4..091376bf 100644 --- a/tests/unit/gui/shell/test_shell_navigation.py +++ b/tests/unit/gui/shell/test_shell_navigation.py @@ -181,8 +181,11 @@ def test_desktop_startup_lands_on_the_landing_page(qtbot, main_window_factory): def test_runner_startup_skips_the_landing_page(qtbot, main_window_factory): """The Pi is a kiosk beside a rig: it opens on the run, never on a chooser.""" + from glider.gui.runner.run_page import PiRunPage + window = main_window_factory(desktop_mode=False) assert window._stack.currentIndex() == PAGE_OPERATOR + assert isinstance(window._stack.currentWidget(), PiRunPage) def test_new_from_landing_enters_the_experiment_tab(qtbot, main_window_factory): diff --git a/tests/unit/gui/test_dashboard_integration.py b/tests/unit/gui/test_dashboard_integration.py index 5d6d16ad..49132e86 100644 --- a/tests/unit/gui/test_dashboard_integration.py +++ b/tests/unit/gui/test_dashboard_integration.py @@ -218,8 +218,8 @@ def test_switch_to_desktop_mode_keeps_camera_in_the_tab_on_repeat(qtbot, main_wi cam = window._camera_panel window._switch_to_desktop_mode() # first: builds the panels, camera -> tab assert _is_descendant(cam, window._builder_view.right) - window.switch_to_runner() # camera -> operator view (RunnerShell Camera tab) - assert _is_descendant(cam, window._runner_shell) + window.switch_to_runner() # camera -> operator view (the Pi Run page's slot) + assert _is_descendant(cam, window._pi_run_page.camera_slot) window._switch_to_desktop_mode() # repeat: panels exist; camera must STILL move back assert _is_descendant(cam, window._builder_view.right) assert window._camera_panel is cam # still the single instance @@ -235,12 +235,14 @@ def test_run_page_hides_the_camera_controls(qtbot, main_window_factory): assert cam._refresh_btn.isVisibleTo(cam) -def test_runner_shell_keeps_the_camera_controls(qtbot, main_window_factory): +def test_pi_run_page_shows_the_camera_video_only(qtbot, main_window_factory): window = main_window_factory(desktop_mode=False) cam = window._camera_panel window._switch_to_desktop_mode() - window.switch_to_runner() assert cam._refresh_btn.isVisibleTo(cam) + window.switch_to_runner() + assert not cam._refresh_btn.isVisibleTo(cam) + assert cam._preview_stack.isVisibleTo(cam) def test_run_page_hides_the_duplicate_fps_readout(qtbot, main_window_factory): From 166e12fb94be43803a6a5def8a7c4b1d79574b9a Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 09:34:35 -0500 Subject: [PATCH 12/25] refactor(pi): delete the old runner shell, setup page, banner and panel --- src/glider/gui/panels/runner_panel.py | 473 ------------------ src/glider/gui/runner/__init__.py | 9 +- src/glider/gui/runner/run_banner.py | 67 --- src/glider/gui/runner/runner_setup_page.py | 192 ------- src/glider/gui/runner/runner_shell.py | 132 ----- tests/unit/gui/runner/test_run_banner.py | 42 -- .../gui/runner/test_run_banner_integration.py | 46 -- .../gui/runner/test_runner_panel_readiness.py | 69 --- .../unit/gui/runner/test_runner_setup_page.py | 116 ----- tests/unit/gui/runner/test_runner_shell.py | 73 --- .../unit/gui/test_device_card_link_status.py | 2 +- 11 files changed, 6 insertions(+), 1215 deletions(-) delete mode 100644 src/glider/gui/panels/runner_panel.py delete mode 100644 src/glider/gui/runner/run_banner.py delete mode 100644 src/glider/gui/runner/runner_setup_page.py delete mode 100644 src/glider/gui/runner/runner_shell.py delete mode 100644 tests/unit/gui/runner/test_run_banner.py delete mode 100644 tests/unit/gui/runner/test_run_banner_integration.py delete mode 100644 tests/unit/gui/runner/test_runner_panel_readiness.py delete mode 100644 tests/unit/gui/runner/test_runner_setup_page.py delete mode 100644 tests/unit/gui/runner/test_runner_shell.py diff --git a/src/glider/gui/panels/runner_panel.py b/src/glider/gui/panels/runner_panel.py deleted file mode 100644 index 806bed35..00000000 --- a/src/glider/gui/panels/runner_panel.py +++ /dev/null @@ -1,473 +0,0 @@ -""" -Runner Panel - Touch-optimized "Run" page for experiment execution. - -Provides device status cards, experiment controls (start/stop), -and the elapsed timer. Readiness is computed here to gate START, but the -readiness strip (tap-to-fix rows) and gear/setup menu live on the Setup page. -Emergency stop is deliberately not offered here; it remains a desktop-only -menu action (see MainWindow._on_emergency_stop). -""" - -import logging -import time -from typing import TYPE_CHECKING - -from PyQt6.QtCore import Qt, QTimer, pyqtSignal -from PyQt6.QtWidgets import ( - QFrame, - QHBoxLayout, - QLabel, - QLineEdit, - QPushButton, - QScrollArea, - QVBoxLayout, - QWidget, -) - -from glider.core.config import get_config -from glider.gui.device_status import device_state_display, link_status_color, link_status_text -from glider.gui.runner.readiness import compute_readiness -from glider.gui.runner.run_timer import format_elapsed -from glider.gui.styles import colors, radius - -if TYPE_CHECKING: - from glider.core.glider_core import GliderCore - from glider.gui.view_manager import ViewManager - -logger = logging.getLogger(__name__) - - -class RunnerPanel(QWidget): - """Touch-optimized dashboard view for experiment execution.""" - - experiment_name_changed = pyqtSignal(str) - start_requested = pyqtSignal() - stop_requested = pyqtSignal() - elapsed_updated = pyqtSignal(str) - - def __init__(self, core: "GliderCore", view_manager: "ViewManager", parent=None): - super().__init__(parent) - self._core = core - self._view_manager = view_manager - - # Store device widgets for updates - self._runner_device_cards: dict[str, QWidget] = {} - self._experiment_start_time: float | None = None - self._state_name = "IDLE" - - self.setObjectName("runnerView") - self._setup_ui() - - def _setup_ui(self): - """Build the runner panel UI optimized for 480x800 portrait.""" - layout = QVBoxLayout(self) - layout.setContentsMargins(8, 8, 8, 8) - layout.setSpacing(8) - - # === Header Bar === - header = QWidget() - header.setFixedHeight(50) - header.setProperty("runnerHeader", True) - header_layout = QHBoxLayout(header) - header_layout.setContentsMargins(12, 4, 12, 4) - - self._runner_exp_name = QLineEdit("Untitled Experiment") - self._runner_exp_name.setProperty("title", True) - self._runner_exp_name.setPlaceholderText("Enter experiment name...") - self._runner_exp_name.setReadOnly(True) - self._runner_exp_name.textChanged.connect(self._on_experiment_name_changed) - header_layout.addWidget(self._runner_exp_name) - - header_layout.addStretch() - - self._runner_timer = QLabel("00:00.00") - self._runner_timer.setProperty("timer", True) - self._runner_timer.setStyleSheet( - f"color: {colors.SUCCESS}; font-size: 36px; font-weight: bold; font-family: monospace;" - ) - header_layout.addWidget(self._runner_timer) - - self._status_label = QLabel("IDLE") - self._status_label.setProperty("runnerStatus", True) - self._status_label.setProperty("statusState", "IDLE") - header_layout.addWidget(self._status_label) - - layout.addWidget(header) - - # === Recording Indicator === - self._runner_recording = QLabel("\u25cf REC") - self._runner_recording.setProperty("recording", True) - self._runner_recording.setFixedHeight(28) - self._runner_recording.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._runner_recording.hide() - layout.addWidget(self._runner_recording) - - # === Device Status Area (Scrollable) === - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.NoFrame) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - scroll.setStyleSheet("background-color: transparent;") - - from PyQt6.QtWidgets import QScroller - - QScroller.grabGesture( - scroll.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture - ) - - self._runner_devices_widget = QWidget() - self._runner_devices_layout = QVBoxLayout(self._runner_devices_widget) - self._runner_devices_layout.setContentsMargins(0, 0, 0, 0) - self._runner_devices_layout.setSpacing(8) - - self._runner_no_devices = QLabel("Connect hardware to see devices") - self._runner_no_devices.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._runner_no_devices.setProperty("textRole", "muted") - self._runner_devices_layout.addWidget(self._runner_no_devices) - self._runner_devices_layout.addStretch() - - scroll.setWidget(self._runner_devices_widget) - layout.addWidget(scroll, 1) - - # === Control Buttons === - controls = QWidget() - controls.setMinimumHeight(110) - controls.setProperty("runnerControls", True) - controls_layout = QVBoxLayout(controls) - controls_layout.setContentsMargins(12, 12, 12, 12) - controls_layout.setSpacing(8) - - self._not_ready_hint = QLabel("Not ready — check Setup") - self._not_ready_hint.setProperty("textRole", "muted") - self._not_ready_hint.hide() - controls_layout.addWidget(self._not_ready_hint) - - top_row = QHBoxLayout() - top_row.setSpacing(8) - - self._start_btn = QPushButton("\u25b6 START") - self._start_btn.setFixedHeight(60) - self._start_btn.setProperty("runnerAction", "start") - self._start_btn.setEnabled(False) - self._start_btn.clicked.connect(self.start_requested.emit) - top_row.addWidget(self._start_btn) - - self._stop_btn = QPushButton("\u25a0 STOP") - self._stop_btn.setFixedHeight(60) - self._stop_btn.setProperty("runnerAction", "stop") - self._stop_btn.clicked.connect(self.stop_requested.emit) - top_row.addWidget(self._stop_btn) - - controls_layout.addLayout(top_row) - - layout.addWidget(controls) - - # Timers - config = get_config() - self._device_refresh_timer = QTimer() - self._device_refresh_timer.setInterval(config.timing.device_refresh_interval_ms) - self._device_refresh_timer.timeout.connect(self._update_runner_device_states) - - self._elapsed_timer = QTimer() - self._elapsed_timer.setInterval(config.timing.elapsed_timer_interval_ms) - self._elapsed_timer.timeout.connect(self._update_elapsed_time) - - self._readiness_timer = QTimer(self) - self._readiness_timer.setInterval(500) - self._readiness_timer.timeout.connect(self._refresh_run_readiness) - self._readiness_timer.start() - - # --- TEMPORARY: main-thread stall instrument (remove in Task 9) --- - self._stall_last_tick: float | None = None - self._stall_timer = QTimer(self) - self._stall_timer.setInterval(50) - self._stall_timer.timeout.connect(self._check_main_thread_stall) - self._stall_timer.start() - - self._refresh_run_readiness() - - # --- Public API --- - - def refresh_devices(self) -> None: - """Refresh the device cards in runner view.""" - for card in self._runner_device_cards.values(): - card.setParent(None) - card.deleteLater() - self._runner_device_cards.clear() - - devices = self._core.hardware_manager.devices - - if not devices: - self._runner_no_devices.show() - return - - self._runner_no_devices.hide() - - for device_id, device in devices.items(): - card = self._create_device_card(device_id, device) - self._runner_devices_layout.insertWidget(self._runner_devices_layout.count() - 1, card) - self._runner_device_cards[device_id] = card - - self._refresh_run_readiness() - - def _refresh_run_readiness(self) -> None: - """Recompute board/experiment readiness and update the START button + hint.""" - r = compute_readiness(self._core) - live = getattr(self, "_state_name", "IDLE") in ("RUNNING", "PAUSED") - key = (r, live) - if key == getattr(self, "_last_readiness", None): - return - self._last_readiness = key - # START only when ready and not already running; STOP only mid-run. - self._start_btn.setEnabled(r.all_ready and not live) - self._stop_btn.setEnabled(live) - self._not_ready_hint.setVisible(not r.all_ready and not live) - - def update_state(self, state_name: str) -> None: - """Update UI based on core state changes.""" - self._state_name = state_name - - # Update status label - self._status_label.setText(state_name) - self._status_label.setProperty("statusState", state_name) - self._status_label.style().unpolish(self._status_label) - self._status_label.style().polish(self._status_label) - - self._refresh_run_readiness() - - # The header timer stays visible in every state. RunnerShell shows its - # run banner only when the operator is OFF the Run tab, so this header - # is the only timer on the Run tab during a live run. - - # Update recording indicator - if state_name == "RUNNING" and self._core.data_recorder.is_recording: - self._runner_recording.show() - else: - self._runner_recording.hide() - - # Start/stop device refresh timer - if state_name == "RUNNING": - self._device_refresh_timer.start() - else: - self._device_refresh_timer.stop() - self._update_runner_device_states() - - # Start/stop elapsed timer - if state_name == "RUNNING": - self._experiment_start_time = time.time() - self._elapsed_timer.start() - self._update_elapsed_time() - else: - self._elapsed_timer.stop() - # When the flow has completed, snap the displayed elapsed time - # to the flow's *logical* duration rather than leaving it on the - # last QTimer tick (which includes teardown latency — closing - # recorder files, atomic-renaming output, driving devices low, - # and so on, which adds a variable 100-400ms on a Pi). This is - # what keeps a ``Delay(10s)`` flow display 10.00s instead of - # 10.11s / 10.43s run-to-run. - self._snap_timer_to_flow_duration() - - def update_experiment_name(self, name: str | None = None) -> None: - """Update the experiment name from session.""" - self._runner_exp_name.blockSignals(True) - if name: - self._runner_exp_name.setText(name) - elif self._core.session and self._core.session.metadata.name: - self._runner_exp_name.setText(self._core.session.metadata.name) - else: - self._runner_exp_name.setText("Untitled Experiment") - self._runner_exp_name.blockSignals(False) - - # --- Internal methods --- - - def _on_experiment_name_changed(self, name: str) -> None: - """Handle experiment name change from user input.""" - if self._core.session: - self._core.session.metadata.name = name - self._core.session.mark_dirty() - self.experiment_name_changed.emit(name) - - def _check_main_thread_stall(self) -> None: - """TEMPORARY: log gaps in the Qt event loop > 200ms.""" - now = time.monotonic() - if self._stall_last_tick is not None: - gap = now - self._stall_last_tick - if gap > 0.200: - logger.warning( - "Main-thread stall: %.3fs (QTimer coalesced %d ticks)", - gap, - max(0, int(gap / 0.050) - 1), - ) - self._stall_last_tick = now - - def _update_elapsed_time(self) -> None: - """Update the elapsed time display. - - Format is ``MM:SS.cc`` (or ``HH:MM:SS.cc`` past one hour), where ``cc`` - is centiseconds — two decimal digits of seconds. We deliberately round - *toward zero* (truncate) rather than rounding nearest so the display - never jumps ahead of the wall clock and the centiseconds field never - reads "60" on a boundary. - """ - if self._experiment_start_time is None: - return - self._set_timer_display(time.time() - self._experiment_start_time) - - def _snap_timer_to_flow_duration(self) -> None: - """On flow end, freeze the timer on the flow's logical duration. - - This is the operator-visible piece of the timing fix. The QTimer's - last live-tick was a few hundred ms before the state change - actually fired (timer ticks at the configured interval), and the - state change itself fired *after* the entire teardown sequence. - Without this, the display ends on a stale wall-clock value that - includes I/O latency. ``core.last_flow_duration_s`` is anchored - to flow-engine start/end and is the truth-of-record. - """ - duration = self._core.last_flow_duration_s - if duration is None: - # No flow ran (or in progress) — leave the last live tick as - # the display. Happens on cleanup paths that don't correspond - # to a flow termination. - return - self._set_timer_display(duration) - - def _set_timer_display(self, elapsed: float) -> None: - """Format ``elapsed`` (seconds) and paint it into the timer label.""" - text = format_elapsed(elapsed) - self._runner_timer.setText(text) - self.elapsed_updated.emit(text) - - def _update_runner_device_states(self) -> None: - """Update the device state displays in runner view.""" - for device_id, card in self._runner_device_cards.items(): - device = self._core.hardware_manager.get_device(device_id) - if device is None: - continue - - # link_state, not _initialized -- the same swap the hardware tree - # made. "Has this been set up" is a question a peripheral that - # walked out of range never stops answering yes to, and this card - # repaints on a timer, so it asserted a green Ready continuously - # while the status strip showed a red dot for the same device. - link = getattr(device, "link_state", None) - - if hasattr(card, "_state_label"): - state_text, state_color, font_size = device_state_display(device) - - card._state_label.setText(state_text) - card._state_label.setStyleSheet(f""" - QLabel {{ - background-color: {state_color}; - color: {colors.TEXT_PRIMARY}; - font-size: {font_size}; - font-weight: bold; - border-radius: {radius.MEDIUM}px; - padding: 4px 8px; - border: none; - line-height: 1.2; - }} - """) - - if hasattr(card, "_ready_label"): - card._ready_label.setText(link_status_text(link)) - card._ready_label.setStyleSheet( - f"font-size: 10px; color: {link_status_color(link)}; background: transparent; border: none;" - ) - - def _create_device_card(self, device_id: str, device) -> QWidget: - """Create a device status card for the runner view.""" - card = QWidget() - card.setProperty("deviceCard", True) - card.setFixedHeight(80) - - layout = QHBoxLayout(card) - layout.setContentsMargins(16, 12, 16, 12) - layout.setSpacing(12) - - info_layout = QVBoxLayout() - info_layout.setSpacing(2) - - name_label = QLabel(device_id) - name_label.setStyleSheet( - f"font-size: 16px; font-weight: bold; color: {colors.TEXT_PRIMARY}; background: transparent; border: none;" - ) - info_layout.addWidget(name_label) - - device_type = getattr(device, "device_type", "Unknown") - type_label = QLabel(device_type) - type_label.setStyleSheet( - f"font-size: 12px; color: {colors.TEXT_MUTED}; background: transparent; border: none;" - ) - info_layout.addWidget(type_label) - - layout.addLayout(info_layout) - layout.addStretch() - - link = getattr(device, "link_state", None) - device_type = getattr(device, "device_type", "Unknown") - - status_widget = QWidget() - # 80 for both: the link words ("Disconnected", "Reconnecting…") are - # longer than the "Ready"/"---" the 60 was sized for, and a status that - # paints as "Disconnec" is not one anybody can act on. - status_widget.setFixedSize(80, 50) - status_layout = QVBoxLayout(status_widget) - status_layout.setContentsMargins(0, 0, 0, 0) - status_layout.setSpacing(2) - - state_text, state_color, font_size = device_state_display(device) - - state_label = QLabel(state_text) - state_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - state_label.setStyleSheet(f""" - QLabel {{ - background-color: {state_color}; - color: {colors.TEXT_PRIMARY}; - font-size: {font_size}; - font-weight: bold; - border-radius: {radius.MEDIUM}px; - padding: 4px 8px; - border: none; - line-height: 1.2; - }} - """) - status_layout.addWidget(state_label) - - ready_label = QLabel(link_status_text(link)) - ready_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - ready_label.setStyleSheet( - f"font-size: 10px; color: {link_status_color(link)}; background: transparent; border: none;" - ) - status_layout.addWidget(ready_label) - - layout.addWidget(status_widget) - - card._state_label = state_label - card._ready_label = ready_label - - return card - - # --- Cleanup --- - - def closeEvent(self, event) -> None: # noqa: N802 (Qt override) - """ - Stop all QTimers before the widget is destroyed. - - Timers started in __init__ (particularly the 50ms stall-instrument - timer that runs unconditionally) keep firing against a dead widget - if we don't explicitly stop them — polluting logs and blocking - garbage collection of this panel. - """ - for attr in ("_stall_timer", "_elapsed_timer", "_device_refresh_timer", "_readiness_timer"): - timer = getattr(self, attr, None) - if timer is not None: - try: - timer.stop() - except Exception: - # Qt objects may already be partially torn down by the time - # closeEvent fires; swallow to guarantee the other timers - # still get stopped. - pass - super().closeEvent(event) diff --git a/src/glider/gui/runner/__init__.py b/src/glider/gui/runner/__init__.py index 92443c98..728269c5 100644 --- a/src/glider/gui/runner/__init__.py +++ b/src/glider/gui/runner/__init__.py @@ -1,15 +1,16 @@ """ Runner subpackage. -Building blocks for the operator views: the runner-mode RunnerShell (Pi +Building blocks for the operator views: the runner-mode Pi pages (Pi touchscreen) and the desktop dashboard reuse these: - ``readiness.py`` — readiness computation for starting an experiment - ``run_timer.py`` — elapsed-time formatting -- ``run_banner.py`` — the run status banner - ``device_controls.py`` — manual device controls -- ``runner_setup_page.py`` — the RunnerShell's Setup page -- ``runner_shell.py`` — the runner-mode tabbed shell +- ``device_grid.py`` — the Pi Run page's device cards +- ``notice_bar.py`` — the Pi's notification bar +- ``pi_dashboard.py`` — the Pi's Dashboard tab (graph over panel tabs) +- ``run_page.py`` — the Pi's Run tab See ``glider.gui.dashboard`` for the desktop camera-cockpit dashboard. """ diff --git a/src/glider/gui/runner/run_banner.py b/src/glider/gui/runner/run_banner.py deleted file mode 100644 index 5d5bf659..00000000 --- a/src/glider/gui/runner/run_banner.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Run banner - persistent slim strip shown during a live recorded run. - -Displayed across both Runner pages (Dashboard + Manual) so the operator never -loses the timer/state/STOP when switching pages. This is a pure-UI widget: it -receives pre-formatted strings via setters and exposes a ``stop_requested`` -signal. It owns no timer and drives nothing - callers push time/state updates -in. There is deliberately no emergency-stop button here; emergency stop stays -on the Dashboard. -""" - -from __future__ import annotations - -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget - - -class RunBanner(QWidget): - """Slim persistent banner showing timer/state/REC and a STOP button.""" - - stop_requested = pyqtSignal() - - def __init__(self, parent=None): - super().__init__(parent) - self.setObjectName("runnerRunBanner") - self.setProperty("runnerRunBanner", True) - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - self.setFixedHeight(48) - self._setup_ui() - - def _setup_ui(self) -> None: - layout = QHBoxLayout(self) - layout.setContentsMargins(12, 4, 12, 4) - layout.setSpacing(10) - - self._time = QLabel("00:00.00") - self._time.setProperty("timer", True) - self._time.setStyleSheet("font-family: monospace; font-weight: bold; font-size: 18px;") - layout.addWidget(self._time) - - self._state = QLabel("IDLE") - self._state.setProperty("runnerStatus", True) - self._state.setProperty("statusState", "IDLE") - layout.addWidget(self._state) - - self._rec = QLabel("● REC") - self._rec.setProperty("recording", True) - self._rec.hide() - layout.addWidget(self._rec) - - layout.addStretch() - - self._stop = QPushButton("■ STOP") - self._stop.setProperty("runnerAction", "stop") - self._stop.clicked.connect(self.stop_requested.emit) - layout.addWidget(self._stop) - - def set_time(self, text: str) -> None: - """Set the pre-formatted elapsed-time text (e.g. "01:23.45").""" - self._time.setText(text) - - def set_state(self, name: str, recording: bool) -> None: - """Update the state pill text and REC indicator visibility.""" - self._state.setText(name) - self._state.setProperty("statusState", name) - self._state.style().unpolish(self._state) - self._state.style().polish(self._state) - self._rec.setVisible(recording) diff --git a/src/glider/gui/runner/runner_setup_page.py b/src/glider/gui/runner/runner_setup_page.py deleted file mode 100644 index 25bc9611..00000000 --- a/src/glider/gui/runner/runner_setup_page.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -Runner Setup Page - the Setup tab of the runner-mode RunnerShell. - -Shows a readiness status line, experiment file actions, an embedded hardware -panel, and a housekeeping menu. This widget is PURE UI: it emits signals and -hosts a hardware widget passed in by the caller; it does not touch file -dialogs or hardware itself. main_window wires the signals up separately. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from PyQt6.QtCore import Qt, QTimer, pyqtSignal -from PyQt6.QtWidgets import ( - QFrame, - QGridLayout, - QHBoxLayout, - QLabel, - QMenu, - QPushButton, - QScrollArea, - QScroller, - QSizePolicy, - QVBoxLayout, - QWidget, -) - -from glider.gui.runner.readiness import compute_readiness - -if TYPE_CHECKING: - from glider.core.glider_core import GliderCore - -# How often (ms) to poll readiness so the status line tracks board connect/disconnect. -_POLL_MS = 500 - - -class RunnerSetupPage(QWidget): - """Scrollable Setup landing page: status, file actions, hardware, housekeeping.""" - - new_requested = pyqtSignal() - open_requested = pyqtSignal() - save_requested = pyqtSignal() - save_as_requested = pyqtSignal() - help_requested = pyqtSignal() - close_requested = pyqtSignal() - switch_to_desktop_requested = pyqtSignal() - board_settings_requested = pyqtSignal() - - def __init__(self, core: GliderCore, hardware_widget: QWidget, parent: QWidget | None = None): - super().__init__(parent) - self._core = core - self.setObjectName("runnerSetupPage") - - self._setup_ui(hardware_widget) - - self._poll_timer = QTimer(self) - self._poll_timer.setInterval(_POLL_MS) - self._poll_timer.timeout.connect(self.refresh) - self._poll_timer.start() - - self.refresh() - - # --- UI scaffolding --- - - def _setup_ui(self, hardware_widget: QWidget) -> None: - outer = QVBoxLayout(self) - outer.setContentsMargins(0, 0, 0, 0) - outer.setSpacing(0) - - self._scroll = QScrollArea() - self._scroll.setWidgetResizable(True) - self._scroll.setFrameShape(QFrame.Shape.NoFrame) - self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - - QScroller.grabGesture( - self._scroll.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture - ) - - self._content = QWidget() - content = self._content - content_layout = QVBoxLayout(content) - content_layout.setContentsMargins(8, 8, 8, 8) - content_layout.setSpacing(12) - - # 1. Status line, with the housekeeping ⚙ tucked into the top-right - # corner instead of a giant full-width button below the content. - status_row = QHBoxLayout() - self._board_status = QLabel() - self._exp_status = QLabel() - status_row.addWidget(self._board_status) - status_row.addWidget(self._exp_status) - status_row.addStretch(1) - self._menu_btn = QPushButton("⚙") - self._menu_btn.setFixedSize(40, 40) - self._menu_btn.setStyleSheet( - "min-width:40px; max-width:40px; min-height:40px; max-height:40px; " - "padding:0px; border:none;" - ) - self._menu_btn.clicked.connect(self._open_housekeeping_menu) - status_row.addWidget(self._menu_btn) - content_layout.addLayout(status_row) - - # 2. Experiment section. - self._exp_name = QLabel() - content_layout.addWidget(self._exp_name) - - # File-action buttons in a 2x2 grid (plus a full-width Connect row) - # so the row fits Pi-width screens without clipping. Housed in its - # own container widget so the grid layout is directly introspectable - # via the buttons' parentWidget(). - file_grid_widget = QWidget() - file_grid = QGridLayout(file_grid_widget) - file_grid.setContentsMargins(0, 0, 0, 0) - self._new_btn = QPushButton("New") - self._open_btn = QPushButton("Open") - self._save_btn = QPushButton("Save") - self._save_as_btn = QPushButton("Save As") - self._connect_btn = QPushButton("Connect / Ports") - for btn in ( - self._new_btn, - self._open_btn, - self._save_btn, - self._save_as_btn, - self._connect_btn, - ): - btn.setMinimumHeight(48) - btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - file_grid.addWidget(self._new_btn, 0, 0) - file_grid.addWidget(self._open_btn, 0, 1) - file_grid.addWidget(self._save_btn, 1, 0) - file_grid.addWidget(self._save_as_btn, 1, 1) - file_grid.addWidget(self._connect_btn, 2, 0, 1, 2) - content_layout.addWidget(file_grid_widget) - - self._new_btn.clicked.connect(self.new_requested) - self._open_btn.clicked.connect(self.open_requested) - self._save_btn.clicked.connect(self.save_requested) - self._save_as_btn.clicked.connect(self.save_as_requested) - self._connect_btn.clicked.connect(self.board_settings_requested) - - # 3. Hardware section. - hardware_widget.setMinimumWidth(0) - content_layout.addWidget(hardware_widget) - content_layout.addStretch(1) - - self._scroll.setWidget(content) - outer.addWidget(self._scroll, 1) - - def resizeEvent(self, event) -> None: - super().resizeEvent(event) - if hasattr(self, "_content") and hasattr(self, "_scroll"): - # Use the scroll area's own width rather than viewport().width(): - # the viewport hasn't been re-laid-out yet at this point in the - # resize event, so it still reports a stale (pre-resize) value. - # The scroll area has NoFrame + zero margins, so its width is - # the true available width (viewport width, minus a vertical - # scrollbar if one becomes visible). - self._content.setMaximumWidth(self._scroll.width()) - - # --- Public API --- - - def refresh(self) -> None: - r = compute_readiness(self._core) - if r.board_ready: - self._board_status.setText(f"Board: ✓ {r.board_label}") - else: - self._board_status.setText("Board: ✗ not connected") - if r.experiment_ready: - self._exp_status.setText(f"Experiment: ✓ {r.experiment_label}") - else: - self._exp_status.setText("Experiment: ✗ none loaded") - self._exp_name.setText(r.experiment_label or "Untitled") - - # --- Housekeeping menu --- - - def _open_housekeeping_menu(self) -> None: - menu = QMenu(self) - help_action = menu.addAction("Help") - help_action.triggered.connect(self.help_requested) - desktop_action = menu.addAction("Switch to Desktop") - desktop_action.triggered.connect(self.switch_to_desktop_requested) - exit_action = menu.addAction("Exit") - exit_action.triggered.connect(self.close_requested) - menu.exec(self._menu_btn.mapToGlobal(self._menu_btn.rect().center())) - - # --- Lifecycle --- - - def closeEvent(self, event) -> None: - self._poll_timer.stop() - super().closeEvent(event) diff --git a/src/glider/gui/runner/runner_shell.py b/src/glider/gui/runner/runner_shell.py deleted file mode 100644 index a756c7f2..00000000 --- a/src/glider/gui/runner/runner_shell.py +++ /dev/null @@ -1,132 +0,0 @@ -# src/glider/gui/runner/runner_shell.py -"""Four-tab Runner-mode shell: Setup / Run / Manual / Camera behind a bottom tab bar.""" - -from __future__ import annotations - -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import ( - QHBoxLayout, - QPushButton, - QStackedWidget, - QVBoxLayout, - QWidget, -) - -from glider.gui.runner.run_banner import RunBanner - -_TAB_LABELS = ("Setup", "Run", "Manual", "Camera") - - -class RunnerShell(QWidget): - """Holds the four Runner-mode pages, switched by a persistent bottom tab bar. - - Also owns the persistent run banner (timer/state/REC/STOP). The banner is - shown whenever the experiment is live (RUNNING/PAUSED) AND the operator is - not on the Run tab, so the timer/STOP follow them onto Setup/Manual/Camera - mid-run without duplicating the Run tab's own controls. - """ - - stop_requested = pyqtSignal() - - def __init__( - self, - core, - setup_page: QWidget, - run_page: QWidget, - manual_page: QWidget, - camera_page: QWidget, - parent=None, - ): - super().__init__(parent) - self._core = core - self._state_name = "IDLE" - self._setup = setup_page - self._run = run_page - self._manual = manual_page - self._camera = camera_page - self._setup_ui() - self.select_tab(0) - - def _setup_ui(self) -> None: - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - self._banner = RunBanner() - self._banner.hide() - self._banner.stop_requested.connect(self.stop_requested) - layout.addWidget(self._banner) - - self._stack = QStackedWidget() - self._stack.addWidget(self._setup) # index 0 - self._stack.addWidget(self._run) # index 1 - self._stack.addWidget(self._manual) # index 2 - self._stack.addWidget(self._camera) # index 3 - layout.addWidget(self._stack, 1) - - self._tab_bar = QWidget() - self._tab_bar.setProperty("runnerTabBar", True) - # Plain QWidget only paints a QSS background/border when styled-background - # is enabled; without this the bar's top divider would not render. - self._tab_bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - self._tab_bar.setFixedHeight(76) - tabs_layout = QHBoxLayout(self._tab_bar) - tabs_layout.setContentsMargins(0, 0, 0, 0) - tabs_layout.setSpacing(0) - - self._tab_btns: list[QPushButton] = [] - for i, label in enumerate(_TAB_LABELS): - btn = QPushButton(label) - btn.setCheckable(True) - btn.setMinimumHeight(72) - btn.clicked.connect(lambda _checked=False, idx=i: self.select_tab(idx)) - tabs_layout.addWidget(btn, 1) # equal-width tabs - self._tab_btns.append(btn) - - layout.addWidget(self._tab_bar) - - def select_tab(self, index: int) -> None: - self._stack.setCurrentIndex(index) - for j, btn in enumerate(self._tab_btns): - btn.setChecked(j == index) - # Re-sync the Manual page's enable state each time it is shown, so buttons - # reflect the current hardware-connected status (the user may have - # connected a board on the Setup/Ports flow since last viewing it). - if index == 2 and hasattr(self._manual, "refresh"): - self._manual.refresh() - self._update_banner() - - def update_state(self, state_name: str) -> None: - """Forward state changes to any page that has an update_state method.""" - self._state_name = state_name - for page in (self._setup, self._run, self._manual, self._camera): - if hasattr(page, "update_state"): - page.update_state(state_name) - self._update_banner() - - def _update_banner(self) -> None: - # PAUSED is a live state (main_window auto-pauses on mid-run board - # disconnect); the banner carries the timer/STOP off the Run tab, so it - # must stay up through a pause too. - live = self._state_name in ("RUNNING", "PAUSED") - on_run = self._stack.currentIndex() == 1 - visible = live and not on_run - self._banner.setVisible(visible) - if visible: - self._banner.set_state( - self._state_name, recording=bool(self._core.data_recorder.is_recording) - ) - - def set_banner_time(self, text: str) -> None: - """Forward a pre-formatted elapsed-time string to the run banner.""" - self._banner.set_time(text) - - def rehost_camera(self) -> None: - """Re-insert the camera page into the Camera tab. - - The single CameraPanel is reparented out to the desktop Camera dock on a - runner->desktop switch; switching back must return it to index 3 so the - Camera tab is not left empty. - """ - if self._stack.indexOf(self._camera) == -1: - self._stack.insertWidget(3, self._camera) diff --git a/tests/unit/gui/runner/test_run_banner.py b/tests/unit/gui/runner/test_run_banner.py deleted file mode 100644 index 40561140..00000000 --- a/tests/unit/gui/runner/test_run_banner.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest - -pytestmark = pytest.mark.usefixtures("qtbot") - - -def test_set_time_updates_label(qtbot): - from glider.gui.runner.run_banner import RunBanner - - b = RunBanner() - qtbot.addWidget(b) - b.set_time("01:23.45") - assert b._time.text() == "01:23.45" - - -def test_stop_button_emits(qtbot): - from glider.gui.runner.run_banner import RunBanner - - b = RunBanner() - qtbot.addWidget(b) - with qtbot.waitSignal(b.stop_requested): - b._stop.click() - - -def test_set_state_updates_pill(qtbot): - from glider.gui.runner.run_banner import RunBanner - - b = RunBanner() - qtbot.addWidget(b) - b.set_state("RUNNING", recording=False) - assert b._state.text() == "RUNNING" - - -def test_recording_toggles_rec_visibility(qtbot): - from glider.gui.runner.run_banner import RunBanner - - b = RunBanner() - qtbot.addWidget(b) - b.show() - b.set_state("RUNNING", recording=True) - assert b._rec.isVisibleTo(b) - b.set_state("RUNNING", recording=False) - assert not b._rec.isVisibleTo(b) diff --git a/tests/unit/gui/runner/test_run_banner_integration.py b/tests/unit/gui/runner/test_run_banner_integration.py deleted file mode 100644 index ffaa3af5..00000000 --- a/tests/unit/gui/runner/test_run_banner_integration.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Integration: the real RunnerPanel -> RunnerShell banner chain. - -Wires the same connections main_window makes (panel.elapsed_updated -> -shell.set_banner_time) and exercises the core behavior of Task 9: the -header timer stays visible on the Run tab during a live run (the banner is -hidden there), and elapsed updates still reach the banner for the other tabs. -""" - -from unittest.mock import MagicMock - -import pytest - -pytestmark = pytest.mark.usefixtures("qtbot") - - -def _wire(qtbot, mock_core): - from PyQt6.QtWidgets import QWidget - - from glider.gui.panels.runner_panel import RunnerPanel - from glider.gui.runner.runner_shell import RunnerShell - - # Leaving this a MagicMock would make _snap_timer_to_flow_duration paint a - # mock into the timer on IDLE; None takes its documented early-return path. - mock_core.last_flow_duration_s = None - panel = RunnerPanel(mock_core, MagicMock()) - shell = RunnerShell(mock_core, QWidget(), panel, QWidget(), QWidget()) - panel.elapsed_updated.connect(shell.set_banner_time) - qtbot.addWidget(shell) - shell.show() - return panel, shell - - -def test_run_tab_shows_a_timer_during_run(qtbot, mock_core): - panel, shell = _wire(qtbot, mock_core) - - shell.select_tab(1) # the Run tab, where the banner is hidden - shell.update_state("RUNNING") - assert shell._banner.isVisibleTo(shell) is False - assert panel._runner_timer.isVisibleTo(panel) is True - - # A live tick flows panel -> banner via elapsed_updated. - panel._set_timer_display(5.0) - assert shell._banner._time.text() == "00:05.00" - - shell.update_state("IDLE") - assert panel._runner_timer.isVisibleTo(panel) is True diff --git a/tests/unit/gui/runner/test_runner_panel_readiness.py b/tests/unit/gui/runner/test_runner_panel_readiness.py deleted file mode 100644 index d31347e6..00000000 --- a/tests/unit/gui/runner/test_runner_panel_readiness.py +++ /dev/null @@ -1,69 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from glider.core.experiment_session import FlowConfig, NodeConfig - -pytestmark = pytest.mark.usefixtures("qtbot") - - -def _panel(qtbot, mock_core, *, board, has_start, name="Exp"): - from glider.gui.panels.runner_panel import RunnerPanel - - mock_core.hardware_manager.is_any_board_connected = lambda: board - nodes = [NodeConfig(id="s", node_type="StartExperiment")] if has_start else [] - mock_core.session.flow = FlowConfig(nodes=nodes) - mock_core.session.metadata.name = name - p = RunnerPanel(mock_core, MagicMock()) - qtbot.addWidget(p) - p._refresh_run_readiness() - return p - - -def test_start_disabled_until_all_ready(qtbot, mock_core): - p = _panel(qtbot, mock_core, board=False, has_start=False) - assert p._start_btn.isEnabled() is False - - -def test_start_enabled_when_all_ready(qtbot, mock_core): - p = _panel(qtbot, mock_core, board=True, has_start=True) - assert p._start_btn.isEnabled() is True - - -def test_not_ready_hint_shown_when_blocked(qtbot, mock_core): - p = _panel(qtbot, mock_core, board=False, has_start=False) - assert p._not_ready_hint.isVisibleTo(p) is True - - p2 = _panel(qtbot, mock_core, board=True, has_start=True) - assert p2._not_ready_hint.isVisibleTo(p2) is False - - -def test_header_timer_visible_while_running(qtbot, mock_core): - # IDLE (after RUNNING) runs the timer-snap path (else branch); None takes - # its early return instead of painting a MagicMock into the timer label. - mock_core.last_flow_duration_s = None - p = _panel(qtbot, mock_core, board=True, has_start=True) - p.update_state("RUNNING") - assert p._runner_timer.isVisibleTo(p) is True - p.update_state("IDLE") - assert p._runner_timer.isVisibleTo(p) is True - - -def test_header_timer_visible_while_paused(qtbot, mock_core): - # PAUSED runs the timer-snap path (else branch); None takes its early return - # instead of painting a MagicMock into the timer label. - mock_core.last_flow_duration_s = None - p = _panel(qtbot, mock_core, board=True, has_start=True) - p.update_state("PAUSED") - assert p._runner_timer.isVisibleTo(p) is True - p.update_state("IDLE") - assert p._runner_timer.isVisibleTo(p) is True - - -def test_no_emergency_button(qtbot, mock_core): - # EMERGENCY STOP was removed from the Run tab: it overlapped START/STOP - # when the controls block was too short with hardware disconnected. - # Emergency stop remains available from the desktop menu only. - p = _panel(qtbot, mock_core, board=True, has_start=True) - assert not hasattr(p, "_emergency_btn") - assert not hasattr(type(p), "emergency_stop_requested") diff --git a/tests/unit/gui/runner/test_runner_setup_page.py b/tests/unit/gui/runner/test_runner_setup_page.py deleted file mode 100644 index f0f63709..00000000 --- a/tests/unit/gui/runner/test_runner_setup_page.py +++ /dev/null @@ -1,116 +0,0 @@ -from types import SimpleNamespace - -import pytest -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QApplication, QGridLayout, QWidget - -from glider.core.experiment_session import FlowConfig, NodeConfig - -pytestmark = pytest.mark.usefixtures("qtbot") - - -def _core(board, has_start, name="Exp"): - flow = FlowConfig(nodes=[NodeConfig(id="s", node_type="StartExperiment")] if has_start else []) - session = SimpleNamespace(flow=flow, metadata=SimpleNamespace(name=name)) - hw = SimpleNamespace( - is_any_board_connected=lambda: board, connected_board_description=lambda: "Uno" - ) - return SimpleNamespace(session=session, hardware_manager=hw) - - -def _page(qtbot, core, hw=None): - from glider.gui.runner.runner_setup_page import RunnerSetupPage - - p = RunnerSetupPage(core, hardware_widget=hw if hw is not None else QWidget()) - qtbot.addWidget(p) - p.refresh() - return p - - -def test_open_button_emits(qtbot): - p = _page(qtbot, _core(False, False)) - with qtbot.waitSignal(p.open_requested): - p._open_btn.click() - - -def test_new_and_save_buttons_emit(qtbot): - p = _page(qtbot, _core(False, False)) - with qtbot.waitSignal(p.new_requested): - p._new_btn.click() - with qtbot.waitSignal(p.save_requested): - p._save_btn.click() - - -def test_save_as_and_board_settings_emit(qtbot): - p = _page(qtbot, _core(True, False)) - with qtbot.waitSignal(p.save_as_requested): - p._save_as_btn.click() - with qtbot.waitSignal(p.board_settings_requested): - p._connect_btn.click() - - -def test_status_line_reflects_readiness(qtbot): - p = _page(qtbot, _core(board=True, has_start=True, name="My Exp")) - assert "✓" in p._board_status.text() - assert "✓" in p._exp_status.text() - p2 = _page(qtbot, _core(board=False, has_start=False)) - assert "✗" in p2._board_status.text() - assert "✗" in p2._exp_status.text() - - -def test_hardware_widget_embedded(qtbot): - hw = QWidget() - p = _page(qtbot, _core(True, True), hw=hw) - assert p.isAncestorOf(hw) - - -def test_scroll_stored_and_no_hscroll(qtbot): - p = _page(qtbot, _core(False, False)) - assert p._scroll.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff - - -def test_file_buttons_in_grid(qtbot): - p = _page(qtbot, _core(False, False)) - grid = p._new_btn.parentWidget().layout() - assert isinstance(grid, QGridLayout) - new_pos = grid.getItemPosition(grid.indexOf(p._new_btn)) - open_pos = grid.getItemPosition(grid.indexOf(p._open_btn)) - save_pos = grid.getItemPosition(grid.indexOf(p._save_btn)) - save_as_pos = grid.getItemPosition(grid.indexOf(p._save_as_btn)) - assert new_pos[0] == 0 - assert open_pos[0] == 0 - assert save_pos[0] == 1 - assert save_as_pos[0] == 1 - - -def test_menu_button_small_and_in_status_row(qtbot): - p = _page(qtbot, _core(False, False)) - assert p._menu_btn.size().width() <= 48 - assert p._menu_btn.size().height() <= 48 - # Lives alongside the status labels rather than as its own full-width row. - assert p._menu_btn.parentWidget() is p._board_status.parentWidget() - - -def test_content_capped_to_viewport(qtbot): - p = _page(qtbot, _core(False, False)) - p.resize(480, 800) - p.show() - QApplication.processEvents() - assert p._content.maximumWidth() <= p._scroll.viewport().width() + 1 - - -def test_menu_button_is_square(qtbot): - p = _page(qtbot, _core(False, False)) - assert p._menu_btn.maximumHeight() == 40 - assert p._menu_btn.maximumWidth() == 40 - - -def test_housekeeping_signals(qtbot): - # Drive the menu-backed signals directly (menus are hard to click in tests): - p = _page(qtbot, _core(True, True)) - with qtbot.waitSignal(p.help_requested): - p.help_requested.emit() - with qtbot.waitSignal(p.switch_to_desktop_requested): - p.switch_to_desktop_requested.emit() - with qtbot.waitSignal(p.close_requested): - p.close_requested.emit() diff --git a/tests/unit/gui/runner/test_runner_shell.py b/tests/unit/gui/runner/test_runner_shell.py deleted file mode 100644 index ca9bff5c..00000000 --- a/tests/unit/gui/runner/test_runner_shell.py +++ /dev/null @@ -1,73 +0,0 @@ -import pytest -from PyQt6.QtWidgets import QWidget - -pytestmark = pytest.mark.usefixtures("qtbot") - - -def _shell(qtbot, mock_core): - from glider.gui.runner.runner_shell import RunnerShell - - s = RunnerShell( - mock_core, QWidget(), QWidget(), QWidget(), QWidget() - ) # setup, run, manual, camera - qtbot.addWidget(s) - s.show() - return s - - -def test_boots_on_setup(qtbot, mock_core): - s = _shell(qtbot, mock_core) - assert s._stack.currentIndex() == 0 - - -def test_banner_hidden_when_idle(qtbot, mock_core): - s = _shell(qtbot, mock_core) - s.update_state("IDLE") - assert not s._banner.isVisibleTo(s) - - -def test_banner_shown_off_run_tab_while_running(qtbot, mock_core): - s = _shell(qtbot, mock_core) - s.update_state("RUNNING") - s.select_tab(0) - assert s._banner.isVisibleTo(s) - - -def test_banner_hidden_on_run_tab_while_running(qtbot, mock_core): - s = _shell(qtbot, mock_core) - s.update_state("RUNNING") - s.select_tab(1) - assert not s._banner.isVisibleTo(s) - - -def test_banner_reappears_when_leaving_run_tab(qtbot, mock_core): - s = _shell(qtbot, mock_core) - s.update_state("RUNNING") - s.select_tab(1) - assert not s._banner.isVisibleTo(s) - s.select_tab(2) - assert s._banner.isVisibleTo(s) - - -def test_paused_treated_as_live(qtbot, mock_core): - s = _shell(qtbot, mock_core) - s.update_state("PAUSED") - s.select_tab(3) - assert s._banner.isVisibleTo(s) - - -def test_stop_reemits(qtbot, mock_core): - s = _shell(qtbot, mock_core) - with qtbot.waitSignal(s.stop_requested): - s._banner.stop_requested.emit() - - -def test_tab_bar_larger(qtbot, mock_core): - s = _shell(qtbot, mock_core) - assert s._tab_bar.minimumHeight() >= 72 or s._tab_bar.height() >= 72 - - -def test_set_banner_time_forwards(qtbot, mock_core): - s = _shell(qtbot, mock_core) - s.set_banner_time("00:07.00") - assert s._banner._time.text() == "00:07.00" diff --git a/tests/unit/gui/test_device_card_link_status.py b/tests/unit/gui/test_device_card_link_status.py index 0604302a..6b4e9a84 100644 --- a/tests/unit/gui/test_device_card_link_status.py +++ b/tests/unit/gui/test_device_card_link_status.py @@ -1,7 +1,7 @@ """The two device-card panels read the link, not "has this been set up". Task 6 converted the hardware tree and the Device Control panel; these two -were missed. RunnerPanel's cards repaint on a timer, so they went on asserting +were missed. The Run page's cards repaint on a timer, so they went on asserting a green "Ready" for a peripheral whose link was down -- while the status strip showed a red dot for the same device two inches away. That is the exact disagreement glider.gui.device_status exists to prevent, and the Runner card From 9886cb9ba130953d7b95913227c6a60d36d1c6db Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 10:13:39 -0500 Subject: [PATCH 13/25] style(pi): professional touch stylesheet for the new layout touch.qss is rewritten for the Pi's new layout. Buttons are neutral by default; cyan marks only the selected thing (tabs, chips, the editing lock), green is START and red is STOP / REC. Every tap target is 44 px. - Header: CHROME rows with a 1 px divider under each, underlined shell tabs, the status strip rules ported from desktop.qss (pill states with a red REC tint, device dots and tinted chips), the gear without its menu arrow, 44 px menu items, and the notice bar's three levels. - Dashboard: 48 px panel tabs with an accent underline, a borderless pane, a 14 px splitter grip, and the graph lock's two looks. - Run: a 64 px monospace clock (green while live), 44 px run-field chips, READY / NOT READY / NEEDS DETAILS pills, SURFACE_1 device cards and a run-bar card. - Experiment: 44 px section chips with an accent :checked tint, form labels centred on their fields, inputs on SURFACE_1, side-by-side - / + spin buttons, radio and check indicators. - Dead rules are deleted: runnerRunBanner, readinessStrip/readinessRow, runnerHeader, runnerControls, and the .HeaderBar / .ControlPanel / .StatusIndicator / .VirtualKeyboard family that nothing uses. Every colour is a Deep Navy token or a tint of one. Three small touch-only code changes: - graph_view re-fits the lock button on reposition, because the window's sheet lands after the button was first sized and its text clipped; - the notice bar repolishes its label, so the text takes a new level's colour; - the touch RunControlPanel paints its stylesheet background and wraps its board line. Renders (480x800, examples/box_pi_side.glider), docs/superpowers/renders/: experiment.png, experiment_mice.png, experiment_zones.png, experiment_vocabulary.png, experiment_plugins.png, dashboard_nodes.png, dashboard_nodes_mid.png, dashboard_nodes_end.png, dashboard_properties.png, dashboard_properties_node.png, dashboard_hardware.png, dashboard_camera.png, dashboard_camera_mid.png, dashboard_camera_end.png, dashboard_control.png, dashboard_control_mid.png, dashboard_control_end.png, dashboard_unlocked.png, dashboard_recording.png, run_idle.png, run_recording.png, notice_critical.png, gear_menu.png --- .../gui/dashboard/panels/run_control_panel.py | 9 +- src/glider/gui/node_graph/graph_view.py | 4 + src/glider/gui/runner/notice_bar.py | 8 +- src/glider/gui/styles/icons/grip.svg | 1 + src/glider/gui/styles/icons/minus.svg | 1 + src/glider/gui/styles/icons/plus.svg | 1 + src/glider/gui/styles/touch.qss | 1182 ++++++++++------- tests/unit/gui/test_touch_stylesheet.py | 59 + 8 files changed, 771 insertions(+), 494 deletions(-) create mode 100644 src/glider/gui/styles/icons/grip.svg create mode 100644 src/glider/gui/styles/icons/minus.svg create mode 100644 src/glider/gui/styles/icons/plus.svg create mode 100644 tests/unit/gui/test_touch_stylesheet.py diff --git a/src/glider/gui/dashboard/panels/run_control_panel.py b/src/glider/gui/dashboard/panels/run_control_panel.py index 3e9f6683..6c775f81 100644 --- a/src/glider/gui/dashboard/panels/run_control_panel.py +++ b/src/glider/gui/dashboard/panels/run_control_panel.py @@ -17,7 +17,7 @@ import time from typing import TYPE_CHECKING -from PyQt6.QtCore import QTimer, pyqtSignal +from PyQt6.QtCore import Qt, QTimer, pyqtSignal from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from glider.core.config import get_config @@ -61,6 +61,10 @@ def __init__(self, core: "GliderCore", parent=None, *, touch: bool = False): self._paused_total = 0.0 # seconds paused so far in this run self.setObjectName("runControlPanel") + if touch: + # touch.qss gives the Pi's run bar a card; a QWidget subclass paints + # a stylesheet background only with this set. + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) self._setup_ui() self.refresh() @@ -79,6 +83,9 @@ def _setup_ui(self): self._sub = QLabel() self._sub.setProperty("textRole", "muted") + if self._touch: + # A long board description wraps rather than widening the 480 px page. + self._sub.setWordWrap(True) layout.addWidget(self._sub) self._start_btn = QPushButton("▶ Start run") diff --git a/src/glider/gui/node_graph/graph_view.py b/src/glider/gui/node_graph/graph_view.py index a24e044b..0e17f0e0 100644 --- a/src/glider/gui/node_graph/graph_view.py +++ b/src/glider/gui/node_graph/graph_view.py @@ -595,6 +595,10 @@ def _reposition_overlay(self) -> None: x = viewport.width() - overlay_size.width() - margin y = viewport.height() - overlay_size.height() - margin self._overlay.move(max(margin, x), max(margin, y)) + # Re-fit here too: the window's stylesheet (touch.qss: 44 px tall, its + # own font and padding) lands after the button was first sized, and a + # resize is the first event that comes after it. + self._overlay.lock_btn.adjustSize() self._overlay.lock_btn.move(margin, margin) def mousePressEvent(self, event: QMouseEvent) -> None: diff --git a/src/glider/gui/runner/notice_bar.py b/src/glider/gui/runner/notice_bar.py index 8c855872..0b96f369 100644 --- a/src/glider/gui/runner/notice_bar.py +++ b/src/glider/gui/runner/notice_bar.py @@ -12,6 +12,8 @@ from PyQt6.QtGui import QMouseEvent from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel +from glider.gui.styles import restyle + _RANK = {"info": 0, "warning": 1, "critical": 2} @@ -32,8 +34,10 @@ def show_notice(self, title: str, message: str, level: str) -> None: return self._label.setText(f"{title}: {message}") self.setProperty("level", level) - self.style().unpolish(self) - self.style().polish(self) + # The label too: touch.qss colours it by the bar's level, and Qt + # re-resolves a descendant selector only when the descendant is polished. + restyle(self) + restyle(self._label) self.show() def text(self) -> str: diff --git a/src/glider/gui/styles/icons/grip.svg b/src/glider/gui/styles/icons/grip.svg new file mode 100644 index 00000000..c63e61d1 --- /dev/null +++ b/src/glider/gui/styles/icons/grip.svg @@ -0,0 +1 @@ + diff --git a/src/glider/gui/styles/icons/minus.svg b/src/glider/gui/styles/icons/minus.svg new file mode 100644 index 00000000..de384476 --- /dev/null +++ b/src/glider/gui/styles/icons/minus.svg @@ -0,0 +1 @@ + diff --git a/src/glider/gui/styles/icons/plus.svg b/src/glider/gui/styles/icons/plus.svg new file mode 100644 index 00000000..0bffb553 --- /dev/null +++ b/src/glider/gui/styles/icons/plus.svg @@ -0,0 +1 @@ + diff --git a/src/glider/gui/styles/touch.qss b/src/glider/gui/styles/touch.qss index 5b2adfdc..0a33f291 100644 --- a/src/glider/gui/styles/touch.qss +++ b/src/glider/gui/styles/touch.qss @@ -1,807 +1,1007 @@ /* - * GLIDER Touch Stylesheet — Deep Navy palette - * High-contrast theme for Raspberry Pi 480x800 touchscreen - * Optimized for touch with large targets (min 80px) + * GLIDER Touch Stylesheet -- Deep Navy palette, Raspberry Pi 480x800 portrait. * - * Color tokens: - * BASE #0f1419 - * CHROME #0b0f14 - * CANVAS #0a0e13 - * BORDER #1e2530 - * ACCENT #38bdf8 - * ACCENT_HOVER #0ea5e9 - * TEXT_PRIMARY #e2e8f0 - * TEXT_MUTED #718096 - * TEXT_DISABLED #475569 + * One quiet visual language: neutral surfaces, cyan only for the selected or + * primary thing, green for START and red for STOP / REC. Every tap target is + * at least 44 px. In this sheet a widget's min-height is its *content* height, + * so a 1 px-bordered control asks for 42 px to land on 44. + * + * Colour tokens (glider/gui/styles/colors.py; QSS cannot import them): + * CANVAS #0a0e13 BORDER #1e2530 + * CHROME #0b0f14 TEXT_PRIMARY #e2e8f0 + * BASE #0f1419 TEXT_TERTIARY #94a3b8 + * SURFACE_1 #111820 TEXT_MUTED #718096 + * SURFACE_2 #151c25 ACCENT #38bdf8 + * SUCCESS #34d399 WARNING #fbbf24 + * ERROR #f87171 */ /* === Global === */ QWidget { - background-color: #0a0e13; - color: #e2e8f0; + background-color: #0f1419; /* BASE */ + color: #e2e8f0; /* TEXT_PRIMARY */ font-family: -apple-system, BlinkMacSystemFont, "Roboto", "Segoe UI", "Helvetica Neue", sans-serif; font-size: 14px; } -/* === Main Window === */ -QMainWindow { - background-color: #0a0e13; +QMainWindow, +QDialog, +QMessageBox { + background-color: #0f1419; /* BASE */ } -/* === Status Bar === */ -QStatusBar { - background-color: #0b0f14; - border-top: 2px solid #1e2530; - min-height: 40px; - font-size: 14px; +QLabel { + background-color: transparent; + color: #e2e8f0; /* TEXT_PRIMARY */ } -/* === Scroll Area === */ +/* === Scrolling === */ QScrollArea { border: none; background-color: transparent; } QScrollBar:vertical { - background-color: #0b0f14; + background-color: transparent; width: 20px; - border-radius: 10px; /* geometric */ margin: 4px; } QScrollBar::handle:vertical { - background-color: #1e2530; - border-radius: 8px; /* geometric */ + background-color: #1e2530; /* BORDER */ + border-radius: 6px; /* geometric: half of the 12 px handle */ min-height: 60px; } -QScrollBar::handle:vertical:pressed { - background-color: #0ea5e9; -} - -QScrollBar::add-line:vertical, -QScrollBar::sub-line:vertical { - height: 0; -} - QScrollBar:horizontal { - background-color: #0b0f14; + background-color: transparent; height: 20px; - border-radius: 10px; /* geometric */ margin: 4px; } QScrollBar::handle:horizontal { - background-color: #1e2530; - border-radius: 8px; /* geometric */ + background-color: #1e2530; /* BORDER */ + border-radius: 6px; /* geometric: half of the 12 px handle */ min-width: 60px; } -QScrollBar::handle:horizontal:pressed { - background-color: #0ea5e9; +QScrollBar::handle:pressed { + background-color: #718096; /* TEXT_MUTED */ } -QScrollBar::add-line:horizontal, -QScrollBar::sub-line:horizontal { +QScrollBar::add-line, +QScrollBar::sub-line, +QScrollBar::add-page, +QScrollBar::sub-page { width: 0; + height: 0; + background: none; } -/* === Push Button === */ +/* === Push buttons === + * Neutral by default. A solid fill means something: cyan for the one primary + * action on a screen, green START, red STOP. + */ QPushButton { - background-color: #38bdf8; - color: #e2e8f0; - border: none; + background-color: #151c25; /* SURFACE_2 */ + border: 1px solid #1e2530; /* BORDER */ border-radius: @RADIUS_MEDIUM@; - padding: 12px 20px; + color: #e2e8f0; /* TEXT_PRIMARY */ font-size: 14px; - font-weight: bold; - min-height: 44px; + font-weight: 600; + padding: 0 16px; + min-height: 42px; } QPushButton:pressed { - background-color: #0ea5e9; + background-color: #111820; /* SURFACE_1 */ + border-color: rgba(56, 189, 248, 0.50); } -QPushButton:disabled { - background-color: #1e2530; - /* Muted but still legible (was #475569, ~1.9:1) so a disconnected control's - label can be read by a gloved operator. */ - color: #718096; +QPushButton:checked { + background-color: rgba(56, 189, 248, 0.13); /* ACCENT @ 13% */ + border-color: rgba(56, 189, 248, 0.50); + color: #38bdf8; /* ACCENT */ } -/* Runner Manual status strip: persistent icon+text notice. Level is set as a - dynamic property; each variant pairs a distinct tint with a text glyph so it - never relies on color alone. */ -QLabel#runnerStatusStrip { - padding: 10px 14px; - border-radius: @RADIUS_MEDIUM@; - font-size: 15px; - font-weight: 600; - color: #e2e8f0; - background-color: #1e2530; -} -QLabel#runnerStatusStrip[level="error"] { - background-color: rgba(248, 113, 113, 0.20); - color: #f87171; -} -QLabel#runnerStatusStrip[level="warn"] { - background-color: rgba(251, 191, 36, 0.20); - color: #fbbf24; -} -QLabel#runnerStatusStrip[level="info"] { - background-color: rgba(96, 165, 250, 0.18); - color: #93c5fd; +QPushButton:disabled { + background-color: #111820; /* SURFACE_1 */ + border-color: #1e2530; + color: #718096; /* TEXT_MUTED: still legible */ } -/* Primary action button */ QPushButton[primary="true"] { - background-color: #34d399; + background-color: #38bdf8; /* ACCENT */ + border-color: #38bdf8; + color: #0a0e13; /* CANVAS: dark ink on cyan */ } QPushButton[primary="true"]:pressed { - background-color: #22a57a; + background-color: rgba(56, 189, 248, 0.80); } -/* Danger button */ -QPushButton[danger="true"] { - background-color: rgba(248, 113, 113, 0.2); - color: #f87171; - border: 2px solid #f87171; +QPushButton[secondary="true"], +QPushButton[buttonRole="secondary"] { + background-color: transparent; + color: #94a3b8; /* TEXT_TERTIARY */ } -QPushButton[danger="true"]:pressed { - background-color: rgba(248, 113, 113, 0.35); +QPushButton[secondary="true"]:pressed, +QPushButton[buttonRole="secondary"]:pressed { + background-color: #111820; /* SURFACE_1 */ } -/* Secondary button */ -QPushButton[secondary="true"] { - background-color: #1e2530; - color: #94a3b8; +/* === Run controls (RunControlPanel) === */ +QPushButton[runnerAction="start"], +QPushButton[runnerAction="stop"], +QPushButton[runnerAction="pause"] { + border-radius: @RADIUS_LARGE@; + font-size: 20px; + font-weight: bold; } -QPushButton[secondary="true"]:pressed { - background-color: #2a3440; +QPushButton[runnerAction="start"] { + background-color: #34d399; /* SUCCESS */ + border-color: #34d399; + color: #0a0e13; /* CANVAS */ } -/* === Label === */ -QLabel { - background-color: transparent; - color: #e2e8f0; +QPushButton[runnerAction="start"]:pressed { + background-color: rgba(52, 211, 153, 0.80); } -QLabel[title="true"] { - font-size: 18px; - font-weight: bold; +QPushButton[runnerAction="stop"] { + background-color: #f87171; /* ERROR */ + border-color: #f87171; + color: #0a0e13; /* CANVAS */ } -QLabel[subtitle="true"] { - font-size: 14px; - color: #94a3b8; +QPushButton[runnerAction="stop"]:pressed { + background-color: rgba(248, 113, 113, 0.80); } -QLabel[value="true"] { - font-size: 22px; - font-weight: bold; - color: #38bdf8; +QPushButton[runnerAction="start"]:disabled, +QPushButton[runnerAction="stop"]:disabled { + background-color: #151c25; /* SURFACE_2 */ + border-color: #1e2530; + color: #718096; /* TEXT_MUTED */ } -QLabel[muted="true"] { +/* The Pi's run bar: a card at the foot of the Run page. */ +QWidget#runControlPanel { + background-color: #0b0f14; /* CHROME */ + border: 1px solid #1e2530; /* BORDER */ + border-radius: @RADIUS_LARGE@; +} + +/* === Text inputs === */ +QLineEdit, +QComboBox, +QSpinBox, +QDoubleSpinBox { + background-color: #111820; /* SURFACE_1 */ + border: 1px solid #1e2530; /* BORDER */ + border-radius: @RADIUS_MEDIUM@; + color: #e2e8f0; font-size: 14px; - color: #718096; + padding: 0 12px; + min-height: 42px; + selection-background-color: rgba(56, 189, 248, 0.35); } -/* === Slider === */ -QSlider { - min-height: 60px; +QTextEdit, +QPlainTextEdit { + background-color: #111820; /* SURFACE_1 */ + border: 1px solid #1e2530; /* BORDER */ + border-radius: @RADIUS_MEDIUM@; + color: #e2e8f0; + font-size: 14px; + padding: 6px 8px; + selection-background-color: rgba(56, 189, 248, 0.35); } -QSlider::groove:horizontal { - height: 24px; - background-color: #1e2530; - border-radius: 12px; /* geometric */ +QLineEdit:focus, +QComboBox:focus, +QSpinBox:focus, +QDoubleSpinBox:focus, +QTextEdit:focus, +QPlainTextEdit:focus { + border-color: #38bdf8; /* ACCENT */ } -QSlider::handle:horizontal { - width: 48px; - height: 48px; - margin: -12px 0; - background-color: #38bdf8; - border-radius: 24px; /* geometric */ - border: 4px solid #0ea5e9; +QLineEdit:disabled, +QComboBox:disabled, +QSpinBox:disabled, +QDoubleSpinBox:disabled, +QTextEdit:disabled, +QPlainTextEdit:disabled { + color: #718096; /* TEXT_MUTED */ } -QSlider::handle:horizontal:pressed { - background-color: #0ea5e9; - border-color: #0284c7; +QComboBox::drop-down { + subcontrol-origin: border; + subcontrol-position: center right; + border: none; + width: 40px; } -QSlider::sub-page:horizontal { - background-color: #38bdf8; - border-radius: 12px; /* geometric */ +QComboBox::down-arrow { + image: url(@ICONS@/chevron-down.svg); + width: 12px; + height: 12px; } -QSlider::groove:vertical { - width: 24px; - background-color: #1e2530; - border-radius: 12px; /* geometric */ +QComboBox::down-arrow:disabled { + image: url(@ICONS@/chevron-down-disabled.svg); } -QSlider::handle:vertical { - width: 48px; - height: 48px; - margin: 0 -12px; - background-color: #38bdf8; - border-radius: 24px; /* geometric */ - border: 4px solid #0ea5e9; +QComboBox QAbstractItemView { + background-color: #111820; /* SURFACE_1 */ + border: 1px solid #1e2530; /* BORDER */ + border-radius: @RADIUS_MEDIUM@; + outline: none; + selection-background-color: rgba(56, 189, 248, 0.13); + selection-color: #38bdf8; +} + +QComboBox QAbstractItemView::item { + min-height: 44px; + padding: 0 12px; } -/* === Spin Box === */ +/* Spin boxes put a finger-sized − and + either side of the value, rather than + * two half-height arrows stacked on the right. */ QSpinBox, QDoubleSpinBox { - background-color: #0f1419; - border: 2px solid #1e2530; - border-radius: @RADIUS_MEDIUM@; - padding: 8px; - font-size: 16px; - min-height: 40px; + padding: 0 4px; + min-width: 150px; + max-width: 150px; + qproperty-alignment: AlignCenter; } -QSpinBox:focus, -QDoubleSpinBox:focus { - border-color: #38bdf8; +QSpinBox::up-button, +QDoubleSpinBox::up-button, +QSpinBox::down-button, +QDoubleSpinBox::down-button { + subcontrol-origin: border; + background-color: #151c25; /* SURFACE_2 */ + border: none; + width: 44px; } QSpinBox::up-button, QDoubleSpinBox::up-button { - background-color: #34d399; - width: 50px; - border-top-right-radius: 6px; -} - -QSpinBox::up-button:pressed, -QDoubleSpinBox::up-button:pressed { - background-color: #22a57a; + subcontrol-position: center right; + border-left: 1px solid #1e2530; + border-top-right-radius: @RADIUS_MEDIUM@; + border-bottom-right-radius: @RADIUS_MEDIUM@; } QSpinBox::down-button, QDoubleSpinBox::down-button { - background-color: rgba(248, 113, 113, 0.4); - width: 50px; - border-bottom-right-radius: 6px; + subcontrol-position: center left; + border-right: 1px solid #1e2530; + border-top-left-radius: @RADIUS_MEDIUM@; + border-bottom-left-radius: @RADIUS_MEDIUM@; } +QSpinBox::up-button:pressed, +QDoubleSpinBox::up-button:pressed, QSpinBox::down-button:pressed, QDoubleSpinBox::down-button:pressed { - background-color: rgba(248, 113, 113, 0.6); + background-color: #1e2530; /* BORDER */ } -/* === Line Edit === */ -QLineEdit { - background-color: #0f1419; - border: 2px solid #1e2530; - border-radius: @RADIUS_MEDIUM@; - padding: 8px; - font-size: 14px; - min-height: 36px; - selection-background-color: #38bdf8; +QSpinBox::up-arrow, +QDoubleSpinBox::up-arrow { + image: url(@ICONS@/plus.svg); + width: 14px; + height: 14px; } -QLineEdit:focus { - border-color: #38bdf8; +QSpinBox::down-arrow, +QDoubleSpinBox::down-arrow { + image: url(@ICONS@/minus.svg); + width: 14px; + height: 14px; } -/* === Combo Box === */ -QComboBox { - background-color: #0f1419; - border: 2px solid #1e2530; - border-radius: @RADIUS_MEDIUM@; - padding: 8px; - font-size: 14px; - min-height: 40px; +/* === Check box === */ +QCheckBox, +QRadioButton { + background-color: transparent; + spacing: 12px; + min-height: 44px; +} + +QCheckBox::indicator, +QRadioButton::indicator { + width: 22px; + height: 22px; + border: 1px solid #718096; /* TEXT_MUTED */ + background-color: #111820; /* SURFACE_1 */ +} + +QCheckBox::indicator { + border-radius: @RADIUS_SMALL@; +} + +QRadioButton::indicator { + border-radius: 12px; /* geometric: a circle, 22 px + 1 px border */ } -QComboBox:focus { +QCheckBox::indicator:checked { + background-color: #38bdf8; /* ACCENT */ border-color: #38bdf8; + image: url(@ICONS@/check.svg); } -QComboBox::drop-down { - border: none; - width: 48px; +QRadioButton::indicator:checked { + border-color: #38bdf8; /* ACCENT: a dot in a ring */ + background-color: qradialgradient(cx: 0.5, cy: 0.5, radius: 0.5, fx: 0.5, fy: 0.5, + stop: 0 #38bdf8, stop: 0.45 #38bdf8, stop: 0.5 #111820, stop: 1 #111820); } -QComboBox QAbstractItemView { - background-color: #0f1419; - border: 2px solid #38bdf8; - border-radius: @RADIUS_LARGE@; - selection-background-color: #38bdf8; +/* === Slider: a 44 px knob on a slim track === */ +QSlider { + background-color: transparent; + min-height: 44px; } -QComboBox QAbstractItemView::item { - min-height: 50px; - padding: 12px; +QSlider::groove:horizontal { + height: 8px; + background-color: #1e2530; /* BORDER */ + border-radius: 4px; /* geometric */ } -/* === Check Box === */ -QCheckBox { - spacing: 16px; - min-height: 60px; +QSlider::sub-page:horizontal { + background-color: #38bdf8; /* ACCENT */ + border-radius: 4px; /* geometric */ } -QCheckBox::indicator { - width: 36px; - height: 36px; - border: 3px solid #1e2530; - border-radius: @RADIUS_MEDIUM@; - background-color: #0f1419; +QSlider::handle:horizontal { + width: 40px; + height: 40px; + margin: -18px 0; + background-color: #e2e8f0; /* TEXT_PRIMARY */ + border: 2px solid #38bdf8; /* ACCENT */ + border-radius: 22px; /* geometric */ } -QCheckBox::indicator:checked { - background-color: #38bdf8; - border-color: #38bdf8; +QSlider::groove:vertical { + width: 8px; + background-color: #1e2530; + border-radius: 4px; /* geometric */ +} + +QSlider::handle:vertical { + width: 40px; + height: 40px; + margin: 0 -18px; + background-color: #e2e8f0; + border: 2px solid #38bdf8; + border-radius: 22px; /* geometric */ } -/* === Progress Bar === */ +QSlider::handle:pressed { + background-color: #38bdf8; /* ACCENT */ +} + +/* === Progress bar === */ QProgressBar { - background-color: #1e2530; + background-color: #1e2530; /* BORDER */ border: none; border-radius: 12px; /* geometric */ height: 24px; text-align: center; - font-size: 14px; + font-size: 13px; } QProgressBar::chunk { - background-color: #38bdf8; + background-color: #38bdf8; /* ACCENT */ border-radius: 12px; /* geometric */ } -/* === Frame === */ -QFrame[class="card"] { - background-color: #0f1419; - border: 2px solid #1e2530; - border-radius: @RADIUS_LARGE@; - padding: 16px; +/* === Separator lines === */ +QFrame[frameShape="4"] { /* HLine */ + background-color: #1e2530; /* BORDER */ + border: none; + max-height: 1px; } -QFrame[class="separator"] { - background-color: #1e2530; - max-height: 2px; +QFrame[frameShape="5"] { /* VLine */ + background-color: #1e2530; /* BORDER */ + border: none; + max-width: 1px; } -/* === Group Box === */ +/* === Group box === */ QGroupBox { - border: 2px solid #1e2530; + border: 1px solid #1e2530; /* BORDER */ border-radius: @RADIUS_LARGE@; - margin-top: 20px; - padding: 16px; - padding-top: 24px; + margin-top: 22px; + padding: 12px; + padding-top: 18px; } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; - padding: 4px 16px; - font-size: 16px; - font-weight: bold; - color: #94a3b8; + left: 12px; + padding: 0 6px; + font-size: 13px; + font-weight: 600; + color: #94a3b8; /* TEXT_TERTIARY */ } -/* === List Widget === */ +/* === Lists and trees === */ QListWidget { - background-color: #0f1419; - border: 2px solid #1e2530; + background-color: #111820; /* SURFACE_1 */ + border: 1px solid #1e2530; /* BORDER */ border-radius: @RADIUS_LARGE@; + outline: none; } +/* No padding: rows that carry their own widget (a label and a remove button) + * fill the item rect, and padding would crush them. */ QListWidget::item { - padding: 16px; + min-height: 44px; border-bottom: 1px solid #1e2530; - min-height: 50px; } -QListWidget::item:selected { - background-color: #38bdf8; +QTreeView { + background-color: #0f1419; /* BASE */ + border: none; + outline: none; } -QListWidget::item:hover { - background-color: #1e2530; +QTreeView::item { + min-height: 44px; +} + +QListWidget::item:selected, +QTreeView::item:selected { + background-color: rgba(56, 189, 248, 0.13); /* ACCENT @ 13% */ + color: #38bdf8; /* ACCENT */ +} + +QHeaderView::section { + background-color: #0b0f14; /* CHROME */ + color: #718096; /* TEXT_MUTED */ + border: none; + border-bottom: 1px solid #1e2530; /* BORDER */ + padding: 8px; + font-size: 12px; + font-weight: 600; } -/* === Tab Widget === */ +/* === Tabs (anything but the Pi dashboard's own, below) === */ QTabWidget::pane { - border: 2px solid #1e2530; + border: 1px solid #1e2530; /* BORDER */ border-radius: @RADIUS_LARGE@; - background-color: #0f1419; + background-color: #0f1419; /* BASE */ } QTabBar::tab { - background-color: #0b0f14; - color: #718096; - padding: 8px 12px; - font-size: 12px; - min-width: 60px; - border-top-left-radius: 6px; - border-top-right-radius: 6px; + background-color: #0b0f14; /* CHROME */ + color: #94a3b8; /* TEXT_TERTIARY */ + border: none; + border-bottom: 2px solid transparent; + padding: 0 14px; + min-height: 42px; + font-size: 13px; + font-weight: 500; } QTabBar::tab:selected { - background-color: #0f1419; - color: #e2e8f0; - border-bottom: 3px solid #38bdf8; + color: #e2e8f0; /* TEXT_PRIMARY */ + border-bottom-color: #38bdf8; /* ACCENT */ } -/* === Dock Widget Tab Bar (compact for Pi screen) === */ QMainWindow::separator { - background-color: #1e2530; + background-color: #1e2530; /* BORDER */ width: 4px; height: 4px; } -QTabBar[tabPosition="South"]::tab, -QTabBar[tabPosition="North"]::tab { - padding: 6px 10px; - font-size: 11px; - min-width: 50px; - max-width: 80px; +/* === Menus: the ⚙ menu and every popup === */ +QMenu { + background-color: #111820; /* SURFACE_1 */ + border: 1px solid #1e2530; /* BORDER */ + border-radius: @RADIUS_MEDIUM@; + padding: 6px; } -/* === Tool Tip === */ -QToolTip { - background-color: #1e2530; - color: #e2e8f0; - border: 2px solid #38bdf8; - border-radius: @RADIUS_LARGE@; - padding: 12px; - font-size: 14px; +QMenu::item { + background-color: transparent; + color: #e2e8f0; /* TEXT_PRIMARY */ + font-size: 15px; + min-height: 44px; + padding: 0 32px 0 16px; + border-radius: @RADIUS_SMALL@; } -/* === Message Box === */ -QMessageBox { - background-color: #0f1419; +QMenu::item:selected { + background-color: rgba(56, 189, 248, 0.13); /* ACCENT @ 13% */ + color: #38bdf8; /* ACCENT */ +} + +QMenu::item:disabled { + color: #718096; /* TEXT_MUTED */ +} + +QMenu::separator { + height: 1px; + background-color: #1e2530; /* BORDER */ + margin: 6px 8px; +} + +/* === Tool tip === */ +QToolTip { + background-color: #151c25; /* SURFACE_2 */ + color: #e2e8f0; + border: 1px solid #1e2530; + border-radius: @RADIUS_MEDIUM@; + padding: 8px; + font-size: 13px; } QMessageBox QLabel { - font-size: 18px; - min-height: 60px; + font-size: 16px; } QMessageBox QPushButton { min-width: 120px; } -/* === Dialog === */ -QDialog { - background-color: #0f1419; -} +/* ============================================================================ + * The Pi header: row one is the status strip beside the ⚙ button, row two the + * tabs. CHROME ground, a 1 px BORDER under each row. + * ========================================================================= */ -/* === Header Bar (Runner) === */ -.HeaderBar { - background-color: #0b0f14; - border-bottom: 2px solid #1e2530; - min-height: 60px; +QWidget#piHeader { + background-color: #0b0f14; /* CHROME */ } -/* === Dashboard Widget Container === */ -.DashboardWidget { - background-color: #0f1419; - border: 2px solid #1e2530; - border-radius: @RADIUS_LARGE@; - margin: 8px; - padding: 16px; +QFrame#statusStrip, +QFrame#statusStripDevices { + background-color: #0b0f14; /* CHROME */ + border: none; } -.DashboardWidget:hover { - border-color: #2a3440; +QLabel#statusStripExperiment { + color: #e2e8f0; /* TEXT_PRIMARY */ + font-size: 14px; + font-weight: 600; } -/* === Control Panel === */ -.ControlPanel { - background-color: #0b0f14; - border-top: 2px solid #1e2530; - min-height: 80px; +QLabel#statusStripDirty { + color: #fbbf24; /* WARNING */ + font-size: 12px; } -.ControlPanel QPushButton { - min-width: 100px; - min-height: 70px; +/* The run pill. It is a tap target (back to Run mid-run), so the label keeps + * the strip's full 44 px and the margin insets only the painted pill. */ +QLabel#statusStripPill { + background-color: #151c25; /* SURFACE_2 */ + border: 1px solid #1e2530; /* BORDER */ + border-radius: 11px; /* geometric: half of the 22 px pill */ + margin: 11px 0; + padding: 0 8px; + color: #94a3b8; /* TEXT_TERTIARY */ + font-size: 11px; + font-weight: bold; } -/* Start button */ -.ControlPanel QPushButton[action="start"] { - background-color: #34d399; +QLabel#statusStripPill[state="running"] { + background-color: rgba(52, 211, 153, 0.13); /* SUCCESS @ 13% */ + border-color: rgba(52, 211, 153, 0.40); + color: #34d399; /* SUCCESS */ } -.ControlPanel QPushButton[action="start"]:pressed { - background-color: #22a57a; +QLabel#statusStripPill[state="recording"] { + background-color: rgba(248, 113, 113, 0.15); /* ERROR @ 15% */ + border-color: rgba(248, 113, 113, 0.40); + color: #f87171; /* ERROR */ } -/* Stop button */ -.ControlPanel QPushButton[action="stop"] { - background-color: #718096; +/* Error is the only filled thing in the header: nothing can out-shout it. */ +QLabel#statusStripPill[state="error"] { + background-color: #f87171; /* ERROR */ + border-color: #f87171; + color: #0a0e13; /* CANVAS */ } -.ControlPanel QPushButton[action="stop"]:pressed { - background-color: #5a6878; +/* A healthy board is a quiet dot and a name; a down one becomes a tinted + * chip, so it differs from the boards beside it. Padding stays small: five + * boards, the name and the pill share 436 px. */ +QFrame#statusStripDevice { + background-color: transparent; + border: 1px solid transparent; + border-radius: 11px; /* geometric: half of the 22 px chip */ + margin: 11px 0; + padding: 0 2px; } -/* Pause button */ -.ControlPanel QPushButton[action="pause"] { - background-color: #fbbf24; +QFrame#statusStripDevice[state="warn"] { + background-color: rgba(251, 191, 36, 0.13); /* WARNING @ 13% */ + border-color: rgba(251, 191, 36, 0.30); + padding: 0 4px; } -.ControlPanel QPushButton[action="pause"]:pressed { - background-color: #d69e10; +QFrame#statusStripDevice[state="error"] { + background-color: rgba(248, 113, 113, 0.13); /* ERROR @ 13% */ + border-color: rgba(248, 113, 113, 0.30); + padding: 0 4px; } -/* === Status Indicators === */ -.StatusIndicator { - min-height: 40px; - padding: 8px 16px; - border-radius: 20px; /* geometric */ - font-size: 14px; - font-weight: bold; +/* The name stays neutral: a chip's state can change after it was polished, + * and only the chip and its dot are re-polished, so a state colour here would + * go stale. The dot and the chip's tint carry the state. */ +QLabel#statusStripDeviceName { + background-color: transparent; + color: #94a3b8; /* TEXT_TERTIARY */ + font-size: 12px; } -.StatusIndicator[status="running"] { - background-color: rgba(52, 211, 153, 0.15); - color: #34d399; +QLabel#statusStripDot { + background-color: #718096; /* TEXT_MUTED */ + color: #718096; + border-radius: 4px; /* geometric: half of the 8 px side */ } -.StatusIndicator[status="stopped"] { - background-color: #1e2530; - color: #718096; +QLabel#statusStripDot[state="ok"] { + background-color: #34d399; /* SUCCESS */ + color: #34d399; } -.StatusIndicator[status="paused"] { - background-color: rgba(251, 191, 36, 0.15); +QLabel#statusStripDot[state="warn"] { + background-color: #fbbf24; /* WARNING */ color: #fbbf24; } -.StatusIndicator[status="error"] { - background-color: rgba(248, 113, 113, 0.15); +QLabel#statusStripDot[state="error"] { + background-color: #f87171; /* ERROR */ color: #f87171; } -/* === Keyboard (Virtual) === */ -.VirtualKeyboard { - background-color: #0b0f14; - border-top: 2px solid #1e2530; -} - -.VirtualKeyboard QPushButton { - min-height: 60px; - min-width: 50px; - font-size: 20px; - margin: 2px; +QToolButton#piGearButton { + background-color: #0b0f14; /* CHROME */ + border: none; + color: #94a3b8; /* TEXT_TERTIARY */ + font-size: 22px; + padding: 0; } -/* === Runner Status Colors === */ -QLabel[runnerStatus="true"] { - padding: 6px 14px; - border-radius: @RADIUS_LARGE@; - font-size: 14px; - font-weight: bold; +QToolButton#piGearButton:pressed, +QToolButton#piGearButton:open { + background-color: #151c25; /* SURFACE_2 */ color: #e2e8f0; } -QLabel[statusState="IDLE"] { - background-color: #1e2530; +QToolButton#piGearButton::menu-indicator { + image: none; + width: 0; } -QLabel[statusState="READY"] { - background-color: rgba(52, 211, 153, 0.15); - color: #34d399; +/* Row two: Experiment · Dashboard · Run, equal width, underlined when current. */ +QFrame#shellTabBar { + background-color: #0b0f14; /* CHROME */ } -QLabel[statusState="RUNNING"] { - background-color: rgba(56, 189, 248, 0.15); - color: #38bdf8; +QWidget#piHeader QFrame#shellTabBar { + border-top: 1px solid #1e2530; /* BORDER */ + border-bottom: 1px solid #1e2530; } -QLabel[statusState="PAUSED"], -QLabel[statusState="STOPPING"] { - background-color: rgba(251, 191, 36, 0.15); - color: #fbbf24; +QToolButton#shellTab { + background-color: transparent; + border: none; + border-bottom: 3px solid transparent; + border-radius: 0; + color: #94a3b8; /* TEXT_TERTIARY */ + font-size: 15px; + font-weight: 500; + padding: 0 16px; + min-height: 42px; } -QLabel[statusState="ERROR"] { - background-color: rgba(248, 113, 113, 0.15); - color: #f87171; +QToolButton#shellTab:checked { + color: #e2e8f0; /* TEXT_PRIMARY */ + border-bottom-color: #38bdf8; /* ACCENT */ } -QLabel[statusState="INITIALIZING"] { - background-color: rgba(167, 139, 250, 0.15); - color: #a78bfa; +/* The notice bar under the header: tinted by level, tap to dismiss. */ +QFrame#piNoticeBar { + border: none; + border-bottom: 1px solid #1e2530; + min-height: 44px; + padding: 0 6px; } -/* === Recording Indicator === */ -QLabel[recording="true"] { - background-color: rgba(248, 113, 113, 0.2); - color: #f87171; - padding: 4px 12px; - border-radius: @RADIUS_MEDIUM@; +QFrame#piNoticeBar QLabel { font-size: 13px; - font-weight: bold; } -/* === Runner Control Buttons === */ -QPushButton[runnerAction="start"] { - background-color: #34d399; - color: #e2e8f0; - font-size: 20px; - font-weight: bold; - border-radius: @RADIUS_LARGE@; +QFrame#piNoticeBar[level="info"] { + background-color: rgba(56, 189, 248, 0.10); /* ACCENT @ 10% */ + border-bottom-color: rgba(56, 189, 248, 0.30); +} + +QFrame#piNoticeBar[level="info"] QLabel { + color: #38bdf8; /* ACCENT */ +} + +QFrame#piNoticeBar[level="warning"] { + background-color: rgba(251, 191, 36, 0.12); /* WARNING @ 12% */ + border-bottom-color: rgba(251, 191, 36, 0.35); +} + +QFrame#piNoticeBar[level="warning"] QLabel { + color: #fbbf24; /* WARNING */ +} + +QFrame#piNoticeBar[level="critical"] { + background-color: rgba(248, 113, 113, 0.18); /* ERROR @ 18% */ + border-bottom-color: rgba(248, 113, 113, 0.45); +} + +QFrame#piNoticeBar[level="critical"] QLabel { + color: #f87171; /* ERROR */ + font-weight: 600; +} + +/* ============================================================================ + * Dashboard: the graph over the panel tabs. + * ========================================================================= */ + +QSplitter#piDashboardSplitter::handle:vertical { + background-color: #0b0f14; /* CHROME */ + border-top: 1px solid #1e2530; /* BORDER */ + height: 14px; + image: url(@ICONS@/grip.svg); +} + +QTabWidget#piDashboardTabs::pane { border: none; + background-color: #0f1419; /* BASE */ } -QPushButton[runnerAction="start"]:pressed { - background-color: #22a57a; +QTabWidget#piDashboardTabs QTabBar { + background-color: #0b0f14; /* CHROME */ } -QPushButton[runnerAction="stop"] { - background-color: #718096; - color: #e2e8f0; - font-size: 20px; - font-weight: bold; - border-radius: @RADIUS_LARGE@; +QTabWidget#piDashboardTabs QTabBar::tab { + background-color: #0b0f14; /* CHROME */ + color: #94a3b8; /* TEXT_TERTIARY */ border: none; + border-bottom: 1px solid #1e2530; /* BORDER */ + padding: 0 4px; + height: 47px; + min-width: 0; + font-size: 14px; + font-weight: 500; } -QPushButton[runnerAction="stop"]:pressed { - background-color: #5a6878; +QTabWidget#piDashboardTabs QTabBar::tab:selected { + color: #38bdf8; /* ACCENT */ + border-bottom: 2px solid #38bdf8; + height: 46px; } -QPushButton[runnerAction="start"]:disabled, -QPushButton[runnerAction="stop"]:disabled { - background-color: #2d3748; - color: #718096; +/* The edit lock on the graph: a quiet pill while locked, cyan while editing. */ +QPushButton#graphLockButton { + background-color: #151c25; /* SURFACE_2 */ + border: 1px solid #1e2530; /* BORDER */ + border-radius: 22px; /* geometric: half of the 44 px height */ + color: #94a3b8; /* TEXT_TERTIARY */ + font-size: 13px; + font-weight: 600; + padding: 0 16px; + min-height: 42px; } -QPushButton[runnerAction="emergency"] { - background-color: rgba(248, 113, 113, 0.2); - color: #f87171; - font-size: 18px; - font-weight: bold; - border-radius: @RADIUS_LARGE@; - border: 2px solid #f87171; +/* Opaque, like the locked look: a tint would let the graph show through. */ +QPushButton#graphLockButton[locked="false"] { + border-color: #38bdf8; /* ACCENT */ + color: #38bdf8; /* ACCENT */ } -QPushButton[runnerAction="emergency"]:pressed { - background-color: rgba(248, 113, 113, 0.35); +QPushButton#graphLockButton:disabled { + color: #718096; /* TEXT_MUTED */ } -/* === Runner View === */ -QWidget[runnerHeader="true"] { - background-color: #0b0f14; - border-radius: @RADIUS_LARGE@; +/* ============================================================================ + * Run page + * ========================================================================= */ + +QLabel#piRunClock { + color: #e2e8f0; /* TEXT_PRIMARY */ + font-family: "DejaVu Sans Mono", "Liberation Mono", Menlo, "SF Mono", monospace; + font-size: 64px; + font-weight: 600; + padding: 8px 0; } -/* === Persistent Run Banner (shown across Runner pages during a live run) === */ -QWidget[runnerRunBanner="true"] { - background-color: #0b0f14; - border-top: 1px solid #1e2530; - border-bottom: 1px solid #1e2530; +QLabel#piRunClock[live="true"] { + color: #34d399; /* SUCCESS */ } -QWidget[runnerRunBanner="true"] QLabel[timer="true"] { - color: #34d399; +/* Run fields: tap to edit in place. */ +QPushButton[fieldTag="true"] { + background-color: #111820; /* SURFACE_1 */ + border: 1px solid #1e2530; /* BORDER */ + border-radius: @RADIUS_MEDIUM@; + color: #e2e8f0; /* TEXT_PRIMARY */ + font-size: 13px; + font-weight: 500; + padding: 0 12px; + min-height: 42px; +} + +QPushButton[fieldTag="true"][need="true"] { + background-color: rgba(251, 191, 36, 0.10); /* WARNING @ 10% */ + border-color: rgba(251, 191, 36, 0.55); + color: #fbbf24; /* WARNING */ } -/* === Readiness Strip (Dashboard) === */ -QWidget[readinessStrip="true"] { +QPushButton[fieldTag="true"][locked="true"] { background-color: transparent; + color: #94a3b8; /* TEXT_TERTIARY */ } -/* Blocked gate: tall, amber, tappable — the row demands attention. */ -QPushButton[readinessRow="blocked"] { - background-color: #fbbf24; - color: #1a1400; - font-size: 15px; - font-weight: bold; - min-height: 60px; - border-radius: @RADIUS_MEDIUM@; - border: none; - text-align: left; - padding-left: 16px; +QLineEdit[fieldTagEditor="true"] { + border-color: #38bdf8; /* ACCENT */ } -QPushButton[readinessRow="blocked"]:pressed { - background-color: #e0a91a; +/* READY / NOT READY / NEEDS DETAILS, and the live states. */ +QLabel[runnerStatus="true"], +QLabel[statusIndicator="true"] { + background-color: #151c25; /* SURFACE_2 */ + border-radius: 11px; /* geometric: half of the 23 px pill, rounded down */ + padding: 4px 12px; + color: #94a3b8; /* TEXT_TERTIARY */ + font-size: 12px; + font-weight: bold; } -/* Satisfied gate: slim green confirmation — no longer needs a tap. */ -QPushButton[readinessRow="ok"] { - background-color: rgba(52, 211, 153, 0.12); +QLabel[statusState="READY"] { + background-color: rgba(52, 211, 153, 0.13); /* SUCCESS @ 13% */ color: #34d399; - font-size: 13px; - font-weight: bold; - min-height: 32px; - max-height: 32px; - border-radius: @RADIUS_MEDIUM@; - border: 1px solid rgba(52, 211, 153, 0.3); - text-align: left; - padding-left: 16px; } -QPushButton[readinessRow="ok"]:pressed { - background-color: rgba(52, 211, 153, 0.2); +QLabel[statusState="RUNNING"], +QLabel[statusState="INITIALIZING"] { + background-color: rgba(56, 189, 248, 0.13); /* ACCENT @ 13% */ + color: #38bdf8; +} + +QLabel[statusState="PAUSED"], +QLabel[statusState="STOPPING"], +QLabel[statusState="NEEDS_DETAILS"] { + background-color: rgba(251, 191, 36, 0.13); /* WARNING @ 13% */ + color: #fbbf24; +} + +QLabel[statusState="ERROR"] { + background-color: rgba(248, 113, 113, 0.15); /* ERROR @ 15% */ + color: #f87171; } -QWidget[runnerControls="true"] { - background-color: #0b0f14; +/* Device cards: name and type; the state badge keeps its per-state colour. */ +QWidget[deviceCard="true"] { + background-color: #111820; /* SURFACE_1 */ + border: 1px solid #1e2530; /* BORDER */ border-radius: @RADIUS_LARGE@; } -/* === Dynamic Property Selectors (touch sizes: +2px from desktop) === */ +QLabel#deviceCardName { + font-size: 14px; + font-weight: 600; +} + +QLabel#deviceCardType { + color: #718096; /* TEXT_MUTED */ + font-size: 12px; +} + +/* ============================================================================ + * Experiment page: section chips across the top. + * ========================================================================= */ + +QToolButton#experimentChip { + background-color: #111820; /* SURFACE_1 */ + border: 1px solid #1e2530; /* BORDER */ + border-radius: 22px; /* geometric: half of the 44 px chip */ + color: #94a3b8; /* TEXT_TERTIARY */ + font-size: 13px; + padding: 0 8px; + min-height: 42px; +} + +QToolButton#experimentChip:checked { + background-color: rgba(56, 189, 248, 0.12); /* ACCENT @ 12% */ + border-color: rgba(56, 189, 248, 0.50); + color: #38bdf8; /* ACCENT */ +} + +/* A form label is as tall as the 44 px field beside it, so its text sits on + * the field's centre line rather than at its top edge. */ +QWidget#experimentSection QGroupBox > QLabel { + min-height: 44px; +} + +/* ============================================================================ + * Shared roles + * ========================================================================= */ -/* Text roles */ QLabel[textRole="muted"] { color: #718096; font-size: 13px; } QLabel[textRole="section"] { color: #718096; font-size: 12px; font-weight: bold; } QLabel[textRole="secondary"] { color: #94a3b8; } -QLabel[textRole="disabled"] { color: #475569; } QLabel[textRole="primary"] { color: #e2e8f0; } +QLabel[textRole="disabled"] { color: #718096; } +QLabel[textRole="warning"] { color: #fbbf24; font-size: 13px; } -/* Button roles */ -QPushButton[buttonRole="secondary"] { background-color: #1e2530; color: #94a3b8; } -QPushButton[buttonRole="secondary"]:hover { background-color: #2a3440; } -QPushButton[buttonRole="danger"] { background-color: rgba(248, 113, 113, 0.15); color: #f87171; border: 1px solid rgba(248, 113, 113, 0.3); } +QLabel[inputValue="true"] { + background-color: #151c25; /* SURFACE_2 */ + border-radius: @RADIUS_MEDIUM@; + color: #34d399; /* SUCCESS */ + padding: 4px 8px; +} -/* Input value display */ -QLabel[inputValue="true"] { background-color: #151c25; border-radius: @RADIUS_MEDIUM@; color: #34d399; padding: 4px 8px; } +QLabel[recording="true"] { + background-color: rgba(248, 113, 113, 0.15); /* ERROR @ 15% */ + color: #f87171; + padding: 4px 12px; + border-radius: @RADIUS_MEDIUM@; + font-size: 13px; + font-weight: bold; +} -/* Properties panel section headers */ +/* Properties panel section headers and dividers */ QLabel[class="props-section-header"] { - color: #718096; + color: #718096; /* TEXT_MUTED */ font-size: 12px; font-weight: bold; padding-top: 4px; padding-bottom: 2px; } -/* Properties panel dividers */ QFrame[class="props-divider"] { - color: #1e2530; + color: #1e2530; /* BORDER */ margin-top: 4px; margin-bottom: 4px; } -/* Device card */ -QWidget[deviceCard="true"] { background-color: #0f1419; border: 2px solid #1e2530; border-radius: @RADIUS_LARGE@; } - -/* Status indicator (dynamic property version) */ -QLabel[statusIndicator="true"] { color: #e2e8f0; padding: 4px 12px; border-radius: @RADIUS_MEDIUM@; font-weight: bold; margin: 2px; } - -/* Drag-and-drop target: the camera panel accepts pose models, behavior - models and videos, routed by type. The accent border is the only - feedback that a drop will land somewhere. */ -CameraPanel[dropActive="true"] { - border: 2px dashed #38bdf8; +/* The Control tab's status strip: a persistent notice, a glyph and a tint per + * level so it never relies on colour alone. */ +QLabel#runnerStatusStrip { + padding: 10px 14px; + border-radius: @RADIUS_MEDIUM@; + font-size: 14px; + font-weight: 600; + color: #e2e8f0; + background-color: #151c25; /* SURFACE_2 */ } -/* ============================================================================ - * Top-level navigation on the Pi. - * - * The tab bar exists in runner mode too -- the operator does occasionally need - * the Experiment tab to pick a subject -- but 480px is not a desktop, so the - * tabs get touch-sized targets and nothing else from the desktop sheet. - * ========================================================================= */ - -QFrame#shellTabBar { - background-color: #0b0f14; /* CHROME */ +QLabel#runnerStatusStrip[level="error"] { + background-color: rgba(248, 113, 113, 0.15); + color: #f87171; /* ERROR */ } -QToolButton#shellTab { - background-color: transparent; - border: 1px solid transparent; - border-radius: @RADIUS_SMALL@; - color: #94a3b8; /* TEXT_TERTIARY */ - font-size: 15px; - font-weight: 500; - padding: 9px 16px; +QLabel#runnerStatusStrip[level="warn"] { + background-color: rgba(251, 191, 36, 0.15); + color: #fbbf24; /* WARNING */ } -QToolButton#shellTab:checked { - background-color: rgba(56, 189, 248, 0.13); /* ACCENT @ 13% */ - border-color: rgba(56, 189, 248, 0.40); +QLabel#runnerStatusStrip[level="info"] { + background-color: rgba(56, 189, 248, 0.12); color: #38bdf8; /* ACCENT */ } + +/* Drag-and-drop target: the camera panel accepts pose models, behavior + * models and videos. The accent border is the only feedback that a drop will + * land somewhere. */ +CameraPanel[dropActive="true"] { + border: 2px dashed #38bdf8; +} diff --git a/tests/unit/gui/test_touch_stylesheet.py b/tests/unit/gui/test_touch_stylesheet.py new file mode 100644 index 00000000..33d8c7e4 --- /dev/null +++ b/tests/unit/gui/test_touch_stylesheet.py @@ -0,0 +1,59 @@ +"""What touch.qss has to get right on the Pi, measured with the sheet applied.""" + +from __future__ import annotations + +from PyQt6.QtGui import QPalette + +from glider.gui.styles import colors, load_stylesheet + + +def test_the_graph_lock_is_a_44px_target_and_shows_all_its_text(qtbot, main_window_factory): + w = main_window_factory(desktop_mode=False) + w.resize(480, 800) + w.show() + qtbot.waitUntil(w._pi_header.isVisible) + w.switch_to_builder() + lock = w._graph_view._overlay.lock_btn + qtbot.waitUntil(lock.isVisible) + # The sheet lands after the button was first sized; it must re-fit. + assert lock.height() >= 44 + assert lock.width() >= lock.sizeHint().width() + + +def test_the_notice_text_takes_the_colour_of_a_new_level(qtbot): + from glider.gui.runner.notice_bar import NoticeBar + + bar = NoticeBar() + qtbot.addWidget(bar) + bar.setStyleSheet(load_stylesheet("touch")) + bar.show_notice("Saved", "box_pi_side.glider", "info") + bar.show_notice("UNSAFE STATE", "Hardware may still be active", "critical") + text = bar._label.palette().color(QPalette.ColorRole.WindowText) + assert text.name() == colors.ERROR + + +def test_the_styled_strip_still_fits_five_boards_beside_the_gear(qtbot): + from glider.gui.shell.status_strip import StatusStrip + + strip = StatusStrip() + qtbot.addWidget(strip) + strip.setStyleSheet(load_stylesheet("touch")) + strip.set_touch_mode(True) + strip.set_experiment("x" * 60, dirty=True) + strip.set_run_state("recording", "12:41") + strip.set_devices([(f"board_{i}", "error", "") for i in range(5)]) + strip.ensurePolished() + for child in strip.findChildren(type(strip.pill())): + child.ensurePolished() + assert strip.minimumSizeHint().width() <= 480 - 44 + + +def test_the_pi_run_box_wraps_its_board_line(qtbot, mock_core): + from glider.gui.dashboard.panels.run_control_panel import RunControlPanel + + touch = RunControlPanel(mock_core, touch=True) + desktop = RunControlPanel(mock_core) + qtbot.addWidget(touch) + qtbot.addWidget(desktop) + assert touch._sub.wordWrap() + assert not desktop._sub.wordWrap() From 8416b2a6ea4e3e585fdd3c30d86531d3c548e3a6 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 10:41:11 -0500 Subject: [PATCH 14/25] fix(pi): 44 px vocabulary rows, finger scrolling and a fitted graph on the Pi The previous commit said every tap target was 44 px. That was not true: the Lab Vocabulary rows were 28 px with 24x22 remove buttons, and the 12 px scroll-bar handles were the only way to scroll most panels by finger. All of these changes are runner-only; desktop is unchanged. - Vocabulary (touch mode): the remove button is a fixed 44x44, which makes each row 48 px. The row's size hint is taken before any stylesheet reaches it, so the size is set in code. The list is tall enough to show three rows. touch.qss gains a neutral base QToolButton rule, so the button no longer falls back to the native bevel. - Finger scrolling: QScroller drag-scrolling on the scroll areas of the Nodes, Hardware (tree) and Camera tabs, and on the compact Experiment sections and the scroll areas inside them. The bars stay thin indicators. Zones is left out on purpose: its preview is drawn on by dragging, and a scroller would take every quick drag. Properties has no scroll area to give it. - Graph fit: the Pi dashboard frames the flow on its first show and after New, Open or a load (pending if hidden). In touch mode the fit uses the band below the lock pill, so the pill never covers a node. Still under 44 px: the 14 px splitter grip (by design), the Mice and Zones table rows (the header's default section size), and the thin scroll bars, now indicators only. Renders checked (docs/superpowers/renders/): experiment_vocabulary.png, dashboard_nodes.png, dashboard_unlocked.png. --- src/glider/gui/dialogs/lab_setup_dialog.py | 14 +- src/glider/gui/main_window.py | 15 ++ src/glider/gui/node_graph/graph_view.py | 24 +++- src/glider/gui/panels/experiment_page.py | 13 ++ src/glider/gui/runner/pi_dashboard.py | 19 ++- src/glider/gui/styles/touch.qss | 31 ++++- tests/unit/gui/test_touch_stylesheet.py | 151 ++++++++++++++++++++- 7 files changed, 258 insertions(+), 9 deletions(-) diff --git a/src/glider/gui/dialogs/lab_setup_dialog.py b/src/glider/gui/dialogs/lab_setup_dialog.py index 26d29350..2995115f 100644 --- a/src/glider/gui/dialogs/lab_setup_dialog.py +++ b/src/glider/gui/dialogs/lab_setup_dialog.py @@ -97,10 +97,13 @@ def __init__( title: str, placeholder: str, parent: QWidget | None = None, + *, + touch: bool = False, ): super().__init__(title, parent) self._vocabulary = vocabulary self._name = name + self._touch = touch self._remove_buttons: dict[str, QToolButton] = {} layout = QVBoxLayout(self) @@ -112,7 +115,8 @@ def __init__( layout.addWidget(self.entry) self.list_widget = QListWidget() - self.list_widget.setMinimumHeight(90) + # Three rows before the list scrolls: 28 px rows here, 48 px on the Pi. + self.list_widget.setMinimumHeight(150 if touch else 90) layout.addWidget(self.list_widget) self._rebuild() @@ -170,6 +174,10 @@ def _append_row(self, value: str) -> None: button = QToolButton() button.setText("✕") button.setToolTip(f"Remove {value}") + if self._touch: + # A fingertip target. Sized here, not by the stylesheet: the item's + # height is taken from the row below, before any sheet reaches it. + button.setFixedSize(44, 44) button.clicked.connect(lambda _checked=False, v=value: self._on_remove(v)) row_layout.addWidget(button) @@ -234,7 +242,9 @@ def _setup_ui(self) -> None: for name in LISTS: title, placeholder = LIST_PRESENTATION.get(name, (name.replace("_", " ").title(), "")) - editor = VocabularyListEditor(self.vocabulary, name, title, placeholder) + editor = VocabularyListEditor( + self.vocabulary, name, title, placeholder, touch=self._is_touch_mode + ) self.editors[name] = editor contents_layout.addWidget(editor) diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index 607863d8..8cb43944 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -1790,8 +1790,21 @@ def _on_tab_changed(index: int) -> None: self._runner_device_controls.refresh() dashboard.tabs().currentChanged.connect(_on_tab_changed) + + # Finger scrolling: a drag on the content scrolls it, and the thin bars + # are only indicators. (Control scrolls this way already.) + from PyQt6.QtWidgets import QScrollArea, QTreeView + + for panel in (self._node_library_panel, self._hardware_panel, self._camera_panel): + for area in panel.findChildren((QScrollArea, QTreeView)): + self._view_manager.configure_widget_for_touch(area.viewport()) return dashboard + def _fit_pi_graph(self) -> None: + """On the Pi, put a freshly opened or new flow in view.""" + if self._view_manager.is_runner_mode and self._pi_dashboard is not None: + self._pi_dashboard.fit_graph() + def _apply_pi_lock(self, locked: bool) -> None: """Extend the graph's lock to the properties form and the hardware map.""" self._properties_host.setEnabled(not locked) @@ -3207,6 +3220,7 @@ def _on_new(self) -> bool: # sections hold the session and ZoneConfiguration that were just # thrown away. self._reset_experiment_page() + self._fit_pi_graph() return True def _on_open(self) -> None: @@ -3268,6 +3282,7 @@ def _load_experiment_path(self, file_path: str) -> bool: # tab editing objects nothing reads any more. self._reset_experiment_page() self._watch_open_experiment() + self._fit_pi_graph() self._show_status_message(f"Opened: {file_path}") return True except Exception as e: diff --git a/src/glider/gui/node_graph/graph_view.py b/src/glider/gui/node_graph/graph_view.py index 0e17f0e0..f5098099 100644 --- a/src/glider/gui/node_graph/graph_view.py +++ b/src/glider/gui/node_graph/graph_view.py @@ -18,6 +18,7 @@ QMouseEvent, QPainter, QPen, + QTransform, QWheelEvent, ) from PyQt6.QtWidgets import ( @@ -241,6 +242,7 @@ def __init__(self, parent: QWidget | None = None): self._selected_nodes: list[str] = [] # Edit lock: when set the graph is look-and-pan only (see set_locked). self._locked = False + self._touch = False # Zoom settings self._zoom = 1.0 @@ -284,6 +286,7 @@ def set_touch_mode(self, enabled: bool) -> None: rubber band), makes the zoom buttons finger-sized and shows the edit-lock toggle. Desktop is the default and is left exactly as it was. """ + self._touch = enabled self.setDragMode( QGraphicsView.DragMode.ScrollHandDrag if enabled @@ -520,7 +523,10 @@ def fit_to_screen(self) -> None: # we hand to fitInView is interpreted in scene coordinates, so # 40 px of padding survives any zoom level. padded = items_rect.adjusted(-40, -40, 40, 40) - self.fitInView(padded, Qt.AspectRatioMode.KeepAspectRatio) + if self._touch: + self._fit_below_lock(padded) + else: + self.fitInView(padded, Qt.AspectRatioMode.KeepAspectRatio) # fitInView clamps the transform regardless of our zoom limits, so # update the cached value from the actual transform matrix. self._zoom = self.transform().m11() @@ -529,6 +535,22 @@ def fit_to_screen(self) -> None: self._zoom = 1.0 self.centerOn(0, 0) + def _fit_below_lock(self, rect: QRectF) -> None: + """Fit ``rect`` into the part of the viewport under the lock pill. + + The pill floats over the top-left corner, so a plain fit can leave a + node under it. This fits into the band below the pill instead. + """ + view = QRectF(self.viewport().rect()) + free = view.adjusted(0, self._overlay.lock_btn.geometry().bottom() + 1, 0, 0) + if free.height() < 1 or rect.isEmpty(): + self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio) + return + scale = min(free.width() / rect.width(), free.height() / rect.height()) + self.setTransform(QTransform.fromScale(scale, scale)) + # centerOn aims at the viewport's centre; shift by the band's offset. + self.centerOn(rect.center() - (free.center() - view.center()) / scale) + def _zoom_by(self, factor: float, *, anchor_under_mouse: bool) -> None: """Apply a zoom factor, respecting min/max bounds. diff --git a/src/glider/gui/panels/experiment_page.py b/src/glider/gui/panels/experiment_page.py index a49e8d06..0f6787ab 100644 --- a/src/glider/gui/panels/experiment_page.py +++ b/src/glider/gui/panels/experiment_page.py @@ -58,6 +58,7 @@ QHBoxLayout, QLabel, QScrollArea, + QScroller, QSizePolicy, QStackedWidget, QToolButton, @@ -90,6 +91,10 @@ "plugins": "Plugins", } +#: Compact sections with a surface that is drawn on by dragging; they keep +#: their scroll bar and get no finger scrolling. +_DRAWN_ON = frozenset({"zones"}) + #: Rail icon edge length in pixels. 20 rather than a more conventional 16 #: because these are illustrations, not symbols: the mouse has an ear, an eye #: and whiskers, and below about 18px those stop resolving and it degrades into @@ -276,6 +281,14 @@ def _build_section(self, key: str) -> None: holder.setFrameShape(QFrame.Shape.NoFrame) holder.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) holder.setWidget(widget) + # Finger scrolling: a drag on the content scrolls it. Not on Zones, + # whose preview is drawn on by dragging, and a scroller would take + # every quick drag for itself. + if key not in _DRAWN_ON: + for area in (holder, *widget.findChildren(QScrollArea)): + QScroller.grabGesture( + area.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture + ) self._stack.addWidget(holder) self._built[key] = widget self._holders[key] = holder diff --git a/src/glider/gui/runner/pi_dashboard.py b/src/glider/gui/runner/pi_dashboard.py index 87a41d2a..8e008adc 100644 --- a/src/glider/gui/runner/pi_dashboard.py +++ b/src/glider/gui/runner/pi_dashboard.py @@ -8,7 +8,7 @@ from __future__ import annotations -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QTimer from PyQt6.QtWidgets import QSplitter, QTabWidget, QVBoxLayout, QWidget from glider.gui.node_graph.graph_view import NodeGraphView @@ -22,6 +22,8 @@ def __init__(self, graph: NodeGraphView, parent: QWidget | None = None) -> None: self.setObjectName("piDashboard") self._keys: list[str] = [] self._sized = False + self._graph = graph + self._fit_pending = True # frame the flow on the first show self._splitter = QSplitter(Qt.Orientation.Vertical) self._splitter.setObjectName("piDashboardSplitter") @@ -51,6 +53,21 @@ def showEvent(self, event) -> None: self._sized = True half = self._splitter.height() // 2 self._splitter.setSizes([half, half]) + if self._fit_pending: + self._fit_pending = False + # After this layout pass, so the fit sees the graph's real size. + QTimer.singleShot(0, self._graph.fit_to_screen) + + def fit_graph(self) -> None: + """Frame the whole flow now, or on the next show if this is hidden. + + A kiosk has no wheel to go looking for a flow with, so a freshly opened + one is put in view rather than wherever the last one left the camera. + """ + if self.isVisible(): + self._graph.fit_to_screen() + else: + self._fit_pending = True def _on_node_selected(self, _node_id: str) -> None: self.set_current("properties") diff --git a/src/glider/gui/styles/touch.qss b/src/glider/gui/styles/touch.qss index 0a33f291..631936c5 100644 --- a/src/glider/gui/styles/touch.qss +++ b/src/glider/gui/styles/touch.qss @@ -2,9 +2,10 @@ * GLIDER Touch Stylesheet -- Deep Navy palette, Raspberry Pi 480x800 portrait. * * One quiet visual language: neutral surfaces, cyan only for the selected or - * primary thing, green for START and red for STOP / REC. Every tap target is - * at least 44 px. In this sheet a widget's min-height is its *content* height, - * so a 1 px-bordered control asks for 42 px to land on 44. + * primary thing, green for START and red for STOP / REC. The controls this + * sheet sizes are at least 44 px tall; a few shared widgets size their own. + * In this sheet a widget's min-height is its *content* height, so a 1 px + * bordered control asks for 42 px to land on 44. * * Colour tokens (glider/gui/styles/colors.py; QSS cannot import them): * CANVAS #0a0e13 BORDER #1e2530 @@ -131,6 +132,30 @@ QPushButton[buttonRole="secondary"]:pressed { background-color: #111820; /* SURFACE_1 */ } +/* Tool buttons get the same neutral look, so none falls back to the native + * bevel. Named ones (tabs, chips, the gear) override it below. Their size is + * the caller's: some sit in rows sized before this sheet reaches them. */ +QToolButton { + background-color: #151c25; /* SURFACE_2 */ + border: 1px solid #1e2530; /* BORDER */ + border-radius: @RADIUS_MEDIUM@; + color: #e2e8f0; /* TEXT_PRIMARY */ +} + +QToolButton:pressed { + background-color: #111820; /* SURFACE_1 */ +} + +QToolButton:checked { + background-color: rgba(56, 189, 248, 0.13); /* ACCENT @ 13% */ + border-color: rgba(56, 189, 248, 0.50); + color: #38bdf8; /* ACCENT */ +} + +QToolButton:disabled { + color: #718096; /* TEXT_MUTED */ +} + /* === Run controls (RunControlPanel) === */ QPushButton[runnerAction="start"], QPushButton[runnerAction="stop"], diff --git a/tests/unit/gui/test_touch_stylesheet.py b/tests/unit/gui/test_touch_stylesheet.py index 33d8c7e4..24473fe5 100644 --- a/tests/unit/gui/test_touch_stylesheet.py +++ b/tests/unit/gui/test_touch_stylesheet.py @@ -1,17 +1,28 @@ -"""What touch.qss has to get right on the Pi, measured with the sheet applied.""" +"""Fit and finish on the Pi: what touch.qss and the runner-only touch fixes +have to get right, measured on real widgets.""" from __future__ import annotations +from pathlib import Path + from PyQt6.QtGui import QPalette +from PyQt6.QtWidgets import QScrollArea, QScroller, QTreeView from glider.gui.styles import colors, load_stylesheet +_EXAMPLE = Path(__file__).resolve().parents[3] / "examples" / "box_pi_side.glider" -def test_the_graph_lock_is_a_44px_target_and_shows_all_its_text(qtbot, main_window_factory): + +def _pi(qtbot, main_window_factory): w = main_window_factory(desktop_mode=False) w.resize(480, 800) w.show() qtbot.waitUntil(w._pi_header.isVisible) + return w + + +def test_the_graph_lock_is_a_44px_target_and_shows_all_its_text(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) w.switch_to_builder() lock = w._graph_view._overlay.lock_btn qtbot.waitUntil(lock.isVisible) @@ -57,3 +68,139 @@ def test_the_pi_run_box_wraps_its_board_line(qtbot, mock_core): qtbot.addWidget(desktop) assert touch._sub.wordWrap() assert not desktop._sub.wordWrap() + + +def test_vocabulary_rows_and_remove_buttons_are_44px_on_the_pi(qtbot): + from glider.core.vocabulary import Vocabulary + from glider.gui.dialogs.lab_setup_dialog import LabSetupDialog + + vocabulary = Vocabulary(groups=["control", "CHR2"]) + pi = LabSetupDialog(vocabulary=vocabulary, is_touch_mode=True).embed(compact=True) + desk = LabSetupDialog(vocabulary=vocabulary) + qtbot.addWidget(pi) + qtbot.addWidget(desk) + editor = pi.editors["groups"] + for row in range(editor.row_count()): + assert editor.list_widget.item(row).sizeHint().height() >= 44 + button = editor.remove_button("control") + assert button.minimumSize().width() >= 44 and button.minimumSize().height() >= 44 + assert desk.editors["groups"].remove_button("control").minimumSize().height() < 44 + + +def _finger_scrolled(w) -> dict[str, list[bool]]: + """Whether each scrollable viewport of the Pi's panels has a scroller.""" + page = w._experiment_page + page.show_section("metadata") + page.show_section("vocabulary") + page.show_section("zones") + panels = { + "nodes": w._node_library_panel, + "hardware": w._hardware_panel, + "camera": w._camera_panel, + "metadata": page.built_sections()["metadata"], + "vocabulary": page.built_sections()["vocabulary"], + } + found = { + key: [ + QScroller.hasScroller(area.viewport()) + for area in panel.findChildren((QScrollArea, QTreeView)) + ] + for key, panel in panels.items() + } + holders = page._holders + for key in ("metadata", "zones"): + if holders[key] is not page.built_sections()[key]: # compact wrapper + found[f"{key}_wrapper"] = [QScroller.hasScroller(holders[key].viewport())] + return found + + +def test_the_pi_scrolls_its_panels_by_finger_and_desktop_does_not(qtbot, main_window_factory): + pi = _finger_scrolled(_pi(qtbot, main_window_factory)) + for key in ("nodes", "hardware", "camera", "vocabulary"): + assert pi[key] and all(pi[key]), key + assert pi["metadata_wrapper"] == [True] + # Zones is drawn on by dragging: a scroller would take the drags. + assert pi["zones_wrapper"] == [False] + + desk = _finger_scrolled(main_window_factory(desktop_mode=True)) + assert not any(any(flags) for flags in desk.values()) + + +def test_an_opened_flow_is_fitted_clear_of_the_lock(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + w.switch_to_builder() + graph = w._graph_view + qtbot.waitUntil(graph.isVisible) + graph.scale(0.2, 0.2) # somewhere the flow is not framed + assert w._load_experiment_path(str(_EXAMPLE)) + view = graph.viewport().rect() + lock = graph._overlay.lock_btn.geometry() + assert graph.nodes + for node in graph.nodes.values(): + box = graph.mapFromScene(node.sceneBoundingRect()).boundingRect() + assert view.contains(box), node + assert not box.intersects(lock), node + + +def test_the_first_dashboard_visit_fits_the_flow(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + assert w._load_experiment_path(str(_EXAMPLE)) # loaded while on Run + calls = [] + graph = w._graph_view + original = graph.fit_to_screen + graph.fit_to_screen = lambda: (calls.append(1), original()) + w.switch_to_builder() + qtbot.waitUntil(lambda: bool(calls)) + + +def test_taps_still_reach_controls_inside_the_finger_scrolled_areas( + qtbot, main_window_factory, monkeypatch +): + from PyQt6.QtCore import Qt + from PyQt6.QtWidgets import QPushButton + + w = _pi(qtbot, main_window_factory) + assert w._load_experiment_path(str(_EXAMPLE)) + w.switch_to_builder() + dash = w._pi_dashboard + graph = w._graph_view + graph.set_locked(False) + + # Nodes: a tap on a library button adds the node. + before = len(graph.nodes) + button = next( + b for b in w._node_library_panel.findChildren(QPushButton) if b.property("nodeButton") + ) + qtbot.waitUntil(button.isVisible) + qtbot.mouseClick(button, Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: len(graph.nodes) == before + 1) + + # Hardware: a tap on a row selects it. + dash.set_current("hardware") + tree = w._hardware_panel.findChild(QTreeView) + qtbot.waitUntil(tree.isVisible) + item = tree.model().index(0, 0) + qtbot.mouseClick(tree.viewport(), Qt.MouseButton.LeftButton, pos=tree.visualRect(item).center()) + qtbot.waitUntil(lambda: tree.currentIndex() == item) + + # Camera: a tap on one of the panel's buttons lands. + dash.set_current("camera") + camera_button = next( + b for b in w._camera_panel.findChildren(QPushButton) if b.isVisibleTo(w._camera_panel) + ) + tapped = [] + camera_button.clicked.connect(lambda: tapped.append(1)) + qtbot.waitUntil(camera_button.isVisible) + camera_button.setEnabled(True) # some need a camera; the tap is the point + # On screen, as a finger would find it: the scroller replays the press + # at that spot. + w._camera_panel.findChild(QScrollArea).ensureWidgetVisible(camera_button) + qtbot.mouseClick(camera_button, Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: bool(tapped)) + + # Experiment: a chip tap switches the section. + w._show_experiment_tab() + chip = w._experiment_page._buttons["mice"] + qtbot.waitUntil(chip.isVisible) + qtbot.mouseClick(chip, Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: w._experiment_page.current_section() == "mice") From f0f46bb926e5d5643128dd3f6c1fc69df232fd98 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 11:14:49 -0500 Subject: [PATCH 15/25] fix(pi): finger-scroll item views and properties; release scrollers on the desktop switch - Item views scroll by finger. A new FingerScroll grabs a QScroller on every QAbstractScrollArea under the panels it is given (combo-box popup lists excepted), not only QScrollArea/QTreeView. That brings in the Mice and Zones tables and the Lab Vocabulary lists. Item views scroll per pixel while grabbed, so a flick glides. The Zones section's own scroll area still gets no scroller (ruling R11): its preview is drawn on by dragging. - Properties scrolls. In runner mode PropertiesHost holds the form in a vertical QScrollArea, which gets a scroller. A tall form (the example's WaitForInput, about 950 px) is now laid out whole instead of crushed into the tab. host.widget() is still the form, and the tab is still the host. - Switch to Desktop releases every scroller the Pi grabbed, from the list kept when grabbing, and puts the item views' scroll modes back. A scroller left on a re-hosted panel swallowed the mouse moves that drag-to-add needs. The Experiment page stops grabbing for sections built later. - Spin boxes take a 110 px floor. Qt's size hint counts only one of the two side-by-side buttons, and special text such as "No timeout" was clipped. Still under 44 px: the 14 px splitter grip, and the Mice and Zones table row heights (from the header's default section size). Renders checked (docs/superpowers/renders/): dashboard_properties_wait.png, dashboard_properties_wait_end.png, dashboard_control.png, experiment_mice.png. --- src/glider/gui/main_window.py | 44 +++++++--- src/glider/gui/panels/experiment_page.py | 31 ++++--- src/glider/gui/runner/finger_scroll.py | 71 ++++++++++++++++ src/glider/gui/styles/touch.qss | 7 +- tests/unit/gui/test_touch_stylesheet.py | 102 ++++++++++++++++++++--- 5 files changed, 218 insertions(+), 37 deletions(-) create mode 100644 src/glider/gui/runner/finger_scroll.py diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index 8cb43944..0ab76685 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -27,6 +27,7 @@ QMainWindow, QMenu, QMessageBox, + QScrollArea, QSizePolicy, QStackedWidget, QStatusBar, @@ -54,6 +55,7 @@ from glider.gui.panels.node_editor_controller import NodeEditorController, node_category_for_type from glider.gui.panels.node_library_panel import NodeLibraryPanel from glider.gui.panels.tools_page import ToolsPage +from glider.gui.runner.finger_scroll import FingerScroll from glider.gui.runner.notice_bar import NoticeBar from glider.gui.runner.pi_dashboard import PiDashboard from glider.gui.runner.readiness import compute_readiness @@ -332,13 +334,22 @@ class PropertiesHost(QWidget): the user ever clicked; nothing referenced them again. """ - def __init__(self, parent: QWidget | None = None) -> None: + def __init__(self, parent: QWidget | None = None, *, scroll: bool = False) -> None: + """``scroll``: hold the form in a vertical scroll area (the Pi's half- + height tab cannot fit a tall form, which would otherwise be crushed).""" super().__init__(parent) self.setObjectName("propertiesHost") self._layout = QVBoxLayout(self) self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) self._widget: QWidget | None = None + self._scroll: QScrollArea | None = None + if scroll: + self._scroll = QScrollArea(self) + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.Shape.NoFrame) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._layout.addWidget(self._scroll) def widget(self) -> QWidget | None: """The form currently on show, or ``None`` before the first one.""" @@ -350,11 +361,17 @@ def setWidget(self, widget: QWidget) -> None: # noqa: N802 - Qt-shaped API if previous is widget: return if previous is not None: - self._layout.removeWidget(previous) + if self._scroll is not None: + self._scroll.takeWidget() + else: + self._layout.removeWidget(previous) previous.setParent(None) previous.deleteLater() self._widget = widget - self._layout.addWidget(widget) + if self._scroll is not None: + self._scroll.setWidget(widget) + else: + self._layout.addWidget(widget) def lab_setup_complete( @@ -465,6 +482,7 @@ def __init__( # _builder_view and drops this off the stack, but the attribute stays # set -- test is_runner_mode, not this, for "on the Pi". self._pi_dashboard: PiDashboard | None = None + self._finger_scroll: FingerScroll | None = None # runner mode # The editor behind the Metadata and Mice sections, and the two pages # it was split into. Both are rebuilt per session; see # _experiment_detail_pages. @@ -1625,7 +1643,7 @@ def session_fn(): # --- Properties --- # The node editor replaces this form on every selection, so the tab # hosts a container and the form lives inside it. See PropertiesHost. - self._properties_host = PropertiesHost() + self._properties_host = PropertiesHost(scroll=self._view_manager.is_runner_mode) self._properties_widget = QWidget() properties_layout = QVBoxLayout(self._properties_widget) properties_layout.addWidget(QLabel("Select a node to view properties")) @@ -1792,12 +1810,15 @@ def _on_tab_changed(index: int) -> None: dashboard.tabs().currentChanged.connect(_on_tab_changed) # Finger scrolling: a drag on the content scrolls it, and the thin bars - # are only indicators. (Control scrolls this way already.) - from PyQt6.QtWidgets import QScrollArea, QTreeView - - for panel in (self._node_library_panel, self._hardware_panel, self._camera_panel): - for area in panel.findChildren((QScrollArea, QTreeView)): - self._view_manager.configure_widget_for_touch(area.viewport()) + # are only indicators. (Control scrolls this way already.) Kept, so the + # desktop switch can release it from the panels it re-hosts. + self._finger_scroll = FingerScroll() + self._finger_scroll.add( + self._node_library_panel, + self._properties_host, + self._hardware_panel, + self._camera_panel, + ) return dashboard def _fit_pi_graph(self) -> None: @@ -3101,6 +3122,9 @@ def _switch_to_desktop_mode(self) -> None: graph.node_selected.disconnect(dashboard._on_node_selected) graph.set_locked(False) graph.set_touch_mode(False) + # A scroller swallows the mouse moves that desktop drags need. + self._finger_scroll.release() + self._experiment_page.release_finger_scrolling() self._builder_view.set_centre(graph) self._stack.removeWidget(dashboard) self._stack.insertWidget(PAGE_BUILDER, self._builder_view) diff --git a/src/glider/gui/panels/experiment_page.py b/src/glider/gui/panels/experiment_page.py index 0f6787ab..fd9ddd5f 100644 --- a/src/glider/gui/panels/experiment_page.py +++ b/src/glider/gui/panels/experiment_page.py @@ -58,7 +58,6 @@ QHBoxLayout, QLabel, QScrollArea, - QScroller, QSizePolicy, QStackedWidget, QToolButton, @@ -66,6 +65,7 @@ QWidget, ) +from glider.gui.runner.finger_scroll import FingerScroll from glider.gui.widgets.pastel_glyphs import glyph_icon logger = logging.getLogger(__name__) @@ -91,8 +91,8 @@ "plugins": "Plugins", } -#: Compact sections with a surface that is drawn on by dragging; they keep -#: their scroll bar and get no finger scrolling. +#: Compact sections with a surface that is drawn on by dragging: the section's +#: own scroll area keeps its bar and gets no finger scrolling. _DRAWN_ON = frozenset({"zones"}) #: Rail icon edge length in pixels. 20 rather than a more conventional 16 @@ -133,6 +133,7 @@ def __init__( super().__init__(parent) self.setObjectName("experimentPage") self._compact = compact + self._finger_scroll = FingerScroll() if compact else None self._builders = dict(builders) self._built: dict[str, QWidget] = {} # What actually sits in the stack for each built section: the section @@ -281,14 +282,12 @@ def _build_section(self, key: str) -> None: holder.setFrameShape(QFrame.Shape.NoFrame) holder.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) holder.setWidget(widget) - # Finger scrolling: a drag on the content scrolls it. Not on Zones, - # whose preview is drawn on by dragging, and a scroller would take - # every quick drag for itself. - if key not in _DRAWN_ON: - for area in (holder, *widget.findChildren(QScrollArea)): - QScroller.grabGesture( - area.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture - ) + # Finger scrolling: a drag on the content scrolls it. Not on the + # Zones wrapper, whose preview is drawn on by dragging, and a + # scroller there would take every quick drag; its table still gets + # one. + if self._finger_scroll is not None: + self._finger_scroll.add(widget if key in _DRAWN_ON else holder) self._stack.addWidget(holder) self._built[key] = widget self._holders[key] = holder @@ -311,6 +310,16 @@ def reset(self) -> None: self._stack.setCurrentWidget(self._placeholder) self._placeholder.setText("") + def release_finger_scrolling(self) -> None: + """Drop drag-scrolling from every section, now and for later builds. + + For Switch to Desktop: the page stays compact, but a mouse drag must + reach the editors again. + """ + if self._finger_scroll is not None: + self._finger_scroll.release() + self._finger_scroll = None + def refresh(self) -> None: """Re-show the current section, building it if it is not up yet. diff --git a/src/glider/gui/runner/finger_scroll.py b/src/glider/gui/runner/finger_scroll.py new file mode 100644 index 00000000..0a998c76 --- /dev/null +++ b/src/glider/gui/runner/finger_scroll.py @@ -0,0 +1,71 @@ +"""Drag-to-scroll for the Pi's scrollable panels, and the undo for it. + +On the touchscreen a finger drag on the content is how a list is scrolled; the +thin bars are only indicators. ``FingerScroll`` grabs a ``QScroller`` on every +scroll area under the roots it is given and remembers each one, so Switch to +Desktop can release exactly what was grabbed: a scroller left on a re-hosted +panel swallows the mouse moves that desktop drag-and-drop needs. +""" + +from __future__ import annotations + +from PyQt6 import sip +from PyQt6.QtWidgets import ( + QAbstractItemView, + QAbstractScrollArea, + QComboBox, + QScroller, + QWidget, +) + +_GESTURE = QScroller.ScrollerGestureType.LeftMouseButtonGesture + + +def _in_combo(widget: QWidget) -> bool: + """A combo box's popup list: it scrolls itself, and is not a panel.""" + parent = widget.parentWidget() + while parent is not None: + if isinstance(parent, QComboBox): + return True + parent = parent.parentWidget() + return False + + +class FingerScroll: + """The scroll areas given drag-scrolling, and their item views' old modes.""" + + def __init__(self) -> None: + self._grabbed: list[tuple[QAbstractScrollArea, QAbstractItemView.ScrollMode | None]] = [] + + def add(self, *roots: QWidget) -> None: + """Grab every scroll area in ``roots`` (a root that is one included). + + Item views also scroll per pixel, so a flick glides rather than jumping + a row at a time. + """ + self._grabbed = [(a, m) for a, m in self._grabbed if not sip.isdeleted(a)] + done = {id(area) for area, _ in self._grabbed} + for root in roots: + areas = root.findChildren(QAbstractScrollArea) + if isinstance(root, QAbstractScrollArea): + areas.insert(0, root) + for area in areas: + if id(area) in done or _in_combo(area): + continue + done.add(id(area)) + QScroller.grabGesture(area.viewport(), _GESTURE) + mode = None + if isinstance(area, QAbstractItemView): + mode = area.verticalScrollMode() + area.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self._grabbed.append((area, mode)) + + def release(self) -> None: + """Ungrab everything and put the item views' scroll modes back.""" + for area, mode in self._grabbed: + if sip.isdeleted(area): + continue + QScroller.ungrabGesture(area.viewport()) + if mode is not None: + area.setVerticalScrollMode(mode) + self._grabbed.clear() diff --git a/src/glider/gui/styles/touch.qss b/src/glider/gui/styles/touch.qss index 631936c5..c106e722 100644 --- a/src/glider/gui/styles/touch.qss +++ b/src/glider/gui/styles/touch.qss @@ -275,12 +275,13 @@ QComboBox QAbstractItemView::item { } /* Spin boxes put a finger-sized − and + either side of the value, rather than - * two half-height arrows stacked on the right. */ + * two half-height arrows stacked on the right. Qt's size hint counts only one + * button, so the floor is set high enough for the value or a "No timeout" kind + * of special text to show whole between the two. */ QSpinBox, QDoubleSpinBox { padding: 0 4px; - min-width: 150px; - max-width: 150px; + min-width: 110px; qproperty-alignment: AlignCenter; } diff --git a/tests/unit/gui/test_touch_stylesheet.py b/tests/unit/gui/test_touch_stylesheet.py index 24473fe5..9faeb65b 100644 --- a/tests/unit/gui/test_touch_stylesheet.py +++ b/tests/unit/gui/test_touch_stylesheet.py @@ -6,7 +6,13 @@ from pathlib import Path from PyQt6.QtGui import QPalette -from PyQt6.QtWidgets import QScrollArea, QScroller, QTreeView +from PyQt6.QtWidgets import ( + QAbstractItemView, + QAbstractScrollArea, + QScrollArea, + QScroller, + QTreeView, +) from glider.gui.styles import colors, load_stylesheet @@ -87,24 +93,27 @@ def test_vocabulary_rows_and_remove_buttons_are_44px_on_the_pi(qtbot): assert desk.editors["groups"].remove_button("control").minimumSize().height() < 44 +def _scroll_areas(panel): + """The panel's scroll areas, less combo-box popup lists (they scroll themselves).""" + from glider.gui.runner.finger_scroll import _in_combo + + return [a for a in panel.findChildren(QAbstractScrollArea) if not _in_combo(a)] + + def _finger_scrolled(w) -> dict[str, list[bool]]: """Whether each scrollable viewport of the Pi's panels has a scroller.""" page = w._experiment_page - page.show_section("metadata") - page.show_section("vocabulary") - page.show_section("zones") + for key in ("metadata", "mice", "vocabulary", "zones"): + page.show_section(key) panels = { "nodes": w._node_library_panel, + "properties": w._properties_host, "hardware": w._hardware_panel, "camera": w._camera_panel, - "metadata": page.built_sections()["metadata"], - "vocabulary": page.built_sections()["vocabulary"], + **{key: page.built_sections()[key] for key in ("metadata", "mice", "vocabulary", "zones")}, } found = { - key: [ - QScroller.hasScroller(area.viewport()) - for area in panel.findChildren((QScrollArea, QTreeView)) - ] + key: [QScroller.hasScroller(area.viewport()) for area in _scroll_areas(panel)] for key, panel in panels.items() } holders = page._holders @@ -115,17 +124,84 @@ def _finger_scrolled(w) -> dict[str, list[bool]]: def test_the_pi_scrolls_its_panels_by_finger_and_desktop_does_not(qtbot, main_window_factory): - pi = _finger_scrolled(_pi(qtbot, main_window_factory)) - for key in ("nodes", "hardware", "camera", "vocabulary"): + w = _pi(qtbot, main_window_factory) + pi = _finger_scrolled(w) + # Every scroll area and item view: the Mice table and the Zones table, + # the vocabulary lists, the hardware tree, and Properties' own area too. + for key in ("nodes", "properties", "hardware", "camera", "mice", "vocabulary", "zones"): assert pi[key] and all(pi[key]), key assert pi["metadata_wrapper"] == [True] - # Zones is drawn on by dragging: a scroller would take the drags. + # The Zones section itself: its preview is drawn on by dragging. assert pi["zones_wrapper"] == [False] + mice = w._experiment_page.built_sections()["mice"].findChild(QAbstractItemView) + assert mice.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel desk = _finger_scrolled(main_window_factory(desktop_mode=True)) assert not any(any(flags) for flags in desk.values()) +def _moves_reach(qtbot, widget) -> int: + """Mouse moves a quick drag across ``widget`` delivers to it.""" + from PyQt6.QtCore import QEvent, QObject, QPoint, Qt + + class _Count(QObject): + moves = 0 + + def eventFilter(self, _obj, event): # noqa: N802 (Qt override) + if event.type() == QEvent.Type.MouseMove: + self.moves += 1 + return False + + counter = _Count() + widget.installEventFilter(counter) + start = QPoint(10, widget.height() // 2) + qtbot.mousePress(widget, Qt.MouseButton.LeftButton, pos=start) + for step in range(1, 6): + qtbot.mouseMove(widget, start + QPoint(0, 6 * step), delay=20) + qtbot.mouseRelease(widget, Qt.MouseButton.LeftButton, pos=start + QPoint(0, 30), delay=20) + qtbot.wait(300) + widget.removeEventFilter(counter) + return counter.moves + + +def test_the_desktop_switch_releases_the_pi_scrollers(qtbot, main_window_factory): + from PyQt6.QtWidgets import QLabel + + w = _pi(qtbot, main_window_factory) + w.switch_to_builder() + library = w._node_library_panel + label = next(lab for lab in library.findChildren(QLabel) if lab.isVisible()) + tree = w._hardware_panel.findChild(QTreeView) + assert tree.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel + assert _moves_reach(qtbot, label) == 0 # the scroller takes a quick drag + + w._switch_to_desktop_mode() + w.switch_to_builder() + qtbot.waitUntil(label.isVisible) + # Desktop drag-and-drop needs the moves, starting from the library. + assert _moves_reach(qtbot, label) > 0 + default = QTreeView() + qtbot.addWidget(default) + assert tree.verticalScrollMode() == default.verticalScrollMode() + + +def test_a_tall_properties_form_scrolls_rather_than_being_crushed(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + assert w._load_experiment_path(str(_EXAMPLE)) + w.switch_to_builder() + w._graph_view.set_locked(False) + wait = next(n for n in w._graph_view.nodes if n.lower().startswith("waitforinput")) + w._graph_view.node_selected.emit(wait) + host = w._properties_host + form = host.widget() + qtbot.waitUntil(form.isVisible) + area = host.findChild(QScrollArea) + assert area is not None and area.widget() is form + assert form.height() >= form.minimumSizeHint().height() # laid out whole + assert area.verticalScrollBar().maximum() > 0 # and reachable by scrolling + assert QScroller.hasScroller(area.viewport()) + + def test_an_opened_flow_is_fitted_clear_of_the_lock(qtbot, main_window_factory): w = _pi(qtbot, main_window_factory) w.switch_to_builder() From 6cd2d2ea1bab8669a551d88b3f467bf769fa261b Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 11:24:40 -0500 Subject: [PATCH 16/25] docs(pi): describe the new touchscreen layout Rewrite the Runner docs around what shipped: the two-row header (name with unsaved dot, board dots, run-state pill, gear menu, tabs), the sticky notice bar, the Experiment / Dashboard / Run tabs, the graph lock and the five panel tabs, the Run page (run fields, readiness pill, clock, device cards, camera, Start / Pause / Stop), and Switch to Desktop (refused mid-run, one-way). Replace runner.png with the Run tab render and add runner-dashboard.png. Fix the old four-tab / run banner / Setup tab wording in the Runner index, the home page, concepts, the ecosystem reference, troubleshooting, the dashboard page and the subjects page. mkdocs is not installed in the project venv, so the site was not built; the relative links, anchors and images in docs-site/ were checked with a script. --- .../assets/screenshots/runner-dashboard.png | Bin 0 -> 74348 bytes docs-site/assets/screenshots/runner.png | Bin 138503 -> 43676 bytes docs-site/building/builder-window.md | 2 +- docs-site/building/subjects.md | 10 +- docs-site/getting-started/concepts.md | 9 +- docs-site/index.md | 6 +- docs-site/reference/ecosystem-reference.md | 9 +- docs-site/reference/troubleshooting.md | 4 +- docs-site/runner/index.md | 22 +- docs-site/runner/pi-kiosk.md | 4 +- docs-site/runner/runner.md | 339 ++++++++++-------- 11 files changed, 231 insertions(+), 174 deletions(-) create mode 100644 docs-site/assets/screenshots/runner-dashboard.png diff --git a/docs-site/assets/screenshots/runner-dashboard.png b/docs-site/assets/screenshots/runner-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..803d583b569bc52cb07dd5af8a1acb2b461dd1d0 GIT binary patch literal 74348 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 literal 0 HcmV?d00001 diff --git a/docs-site/assets/screenshots/runner.png b/docs-site/assets/screenshots/runner.png index dd76ed01ea9396c84235a7018538a9818f406c3f..5d48949ad0f4b048f330b5ac2d0edad9b5a8b3f2 100644 GIT binary patch literal 43676 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 JRZ=Fw{|B1FWM}{Y literal 138503 zcmdqJcT|(x*Di{>t*D3!NLLi;MNoRNP^3$jF1-`!ouF)r)J^XoT?hfGks1^!(jh>k zgY*)5O-OPVd+Xl&_kH(|^T!?Me&@VnFp|7wt@W-o*DTL`ChuM-%U-!ebBTn6>WPL&+S-Jr$IYRs z)~zWAFM-@McLqta+=>1VrEkU;eYoAAo%cMJiS>5qE>rfv+33lzsGDq;o~y<&=sv&w z;Lb(1dw0IATVzt*7Ar2~) z^Z&GR)Z5cpFjr?fpQlXiD8;I^@Z=8;{E$x|EG$W>6bs!qbKQu zKG4&V)^cmfD=CML<1hjC3Tm;jP9E(4p1r_$xaGS`H?&1@Xr86exwl_asn-4ajSe=S zC@Y(oE>5TEQJl!mUx&ZX?8`=(yyP+Z&O<5}X{X?HZMK62NGov<#x3w=C6@Q}Kdbb3 z#&l7qe0!V`Bc?zJ?oAe8Ut0*aFJ?Gj+%qxyt3?u$$A;>nLJ66bwM%(OCX>Z^b>km4 zFr;PI&Axx`xs)7Z(Dq3XTJhdCT6?Wt{@wHAyjU%|&I_;pxhL>ldSaA&@SU>;VfFJ9 z#v!h=sTcnxP&e;0Ptn`%UH%prIQqE^OeI{H7Y+O8)kr+0>^G(fPovY1N8-jvAw`%q zF7eQRN!{bDtillu%(Q-ARXdqLB#jb0KY>A-(cH(qW1h23h7t)3Pyh4$ ziS+!p`$?pK`T! z%`GIfG*}o*mXqzI)!@T-lvB9BzmI@S=pqSqZ%au32tr@~d!7>1gzLO?ycF3o)nrwU zv4bWeaJPjn>Qn*3E6tYep$(|vfRJd|^K{)Ibv3n1wIZG$jGKc91WoC=t`UCFCg;-1 zO|2tTxF=TEqCetpLrWN?jI3nW#HG1(RmSlB&CSbKt{9XZ(*}Hg(KqBtS*TIuh7ql_ zMQ6Dz4`2re|G*s}?8ltemU?yQ24L<}^6~8N+E+9=-UbBlb7;YJt4b!kLfTuxpqTHC zT<$OfghPWeq=Acx7W)0#wb$aQfn*HF%Y)wvh-95=N+|-~m@4#@bIqWE^@+Mf>2Hw` zH=7s8CvH8SL;^mZqp>^h(t>ap!!H#uS0W<%>_mQ6ObP}l;}I6Wy>?Tvw-mI;7BH!? z@=X&%1TdrS8f7vzSG-57m zpSGCP1`5nt~k$^T~=p78dg6HJ1yvNGoQEoqvX|}6Vo2K(o;yTtC_2{_qL9p1W6D1 zx~w5&Mz6(3*U zyG#uFH7-XRUFx+3)dfx#=e^IH59#EnR)1yrYT3V?zqYmpJK0XUkKoEwUc^bvn0xsl zx1C!?X--Z+PEJM0y?ghps@T}$JCO46qgAUR?eJ8=QX^zST3Q-MglVy-on2Q=#bP)l zgaVO_x2Rmo9)WW0JPn!Sk~ZBXsg^l*zv=m#c z|M=e4xS_qh9sHtn;c`+khSL39m`x3Fg%uI6)ofQM*3s&kRKt<)tqL3VmR1{Zgf?{} zX$1}$EO1uu+vRyplC)i+Z=dLH-mKqtbDv-tPXnYCn4&dzPS{w z46CZDKz;h;vcA3UZj1IIj1*gR>#=$#LUDVUP{Z~gud8(NVAum}qHhr4os*q?UnS-B@%R3; zhmox<^RZC{Ro3X9m(Bs7U(d=k1?{a?+l|;Q+gnsnbJc#3E;T=;R;c0Nu-@UMs8~5- z$RaCyUY3RY!G2Spkm%TmXvlLryYSDiHn;anbaJ%VrqtfJuj|+Nu-)mr6QI)FQ?ZVj zS(`K4n6_GDy}ev})x*PM81iBYg<4&S=Qwo=z30`i#lV5Xp$pZuVM9Sd<4C8Y?b+OW z-=yBfFxw0jVdm$-&dKv%MXRfK%hFmRkzdrkDTKfwEF2c1RiG3S)tkLsu9fvPP{4ka zMtiZFzyy6u9JjgT?d@%886mdJ7Za<@MY-2|E!t_m18mB+7U`YaOh1l~oGpkwbKLW1 zDWm6d+m@#u&ofBRZOwk2n=_lO%eq-zRk2g5Ut?3XA_Cvvo>zGCfq@Uz_K_DR9YwYKxAd4=5i%+WTN(W zU{=-rot>*=^Bi>zV@F>CB^42tNXH^Ac(u!jpg9rGs>!Z7R5WDKbv&+b zv_A8RtKmZ)5U-V?y56~{eD+TD0>w$27&BXa{r$BGqgoeg@{5-*+wF{UFqNAgG;Ys# zhCScNn6T_Ru74VMsdc-suu!|yFj>g>7@oRtW72|Ow6_+~hGi5OD%Mdb7!t4^SW?wb zvqry}X`V8wKkQ`Jgwd$P|B)oL5D?uKMUNS&IKbVN0Rl~AcDwC zaKjF^M$i^ZXztIOs8?E}9ro8enaaK9J4zF{t4q|wr1AV3>_dNKZS1X1O?}5bk#Z54 zNZ?A%-dPA(2hLY;B;C&TK4o-MQxjs+3X&4OzcO)}V>xF;-XLyy#xwbNnu( z*oc^ZXQ;1A&)pjb#g=|;X>mC8!zU_G7Hqu#V`nNwg^h7`Hi_Rd;TWg0c#KHsrde2r zT=^-W;c8c$xSkBXe>uua+wkOR%uuyWKven@{2b&D?dt?u^~ZHLITmJTQPuW!jrjBI zdXbFc7RRWHh6emw>9B~;-)fu}mFXlCQi>p>mC1aH!<}Q^Ge3CrklQhQ9dOkgnbuuF zUf#{|5&lGO0~eRqK09!P&2TX@Q#?CPyHIT&ySJL}y40VO=qSu7#>vauH#B5FS$|f( z%YygTRYs9rv=LN9L`1-5{F7=O#TQ2)`>OQYky2VHKrUWZE_$d8NjA8U8kb*p4*%` z{QXd}C$lsAKCld=RKC{X(ou|}UNc_;O$Y=-28qe>ymBNdBRm!{C!*rW&(je#k! zEJU0m)a1vk77tP%>37B)-DkXUg<7mHw6(0NDqR%v=K9x_%2CW1V&cLDpbb}c_eJGB z#2g1?$;lYjrjR^1gs|Pxba85bcG$wAZL+hd`}z_Jkr}hC4D@l}6YN~u|P z=w+8!7QYX6F3t+HT@C9v#xCVNEtu_4QG@4d0Yp4ozg;T8JgEUc7iQmPNVU%g(B# zGd};tr`j5ch8HrNE16l2ZW*FbRal~tfap6o-2Px)91d$z&&$o_G$M58ep8qF+S?0R z3%+5|lT@;{U8-h2epoq$F3XYJm_nd;n+zMUxss_c*x1K|AoK?v+pZgK3p`AZ!uV zC`rvijf{+pXTeufB_Smt4{eU?)ehH2D$Zc{9w_tGn0MwI)E%&2)K3?2?LPggrIk^D z^Chp!JE%+)^{QHNQpl1^t=tXC@j6R)-7FsS>GyX`+ad(RoTcU#y8S0a-9tm3To!cr z%?$bED&2R<<($ISQoGRhoKK&OpPkneakN!a+n#=d@5vC)Deg(-$0f!V(9_XL@0LCOOA6jtc!b7@TfkO+V#S>sY{MF+QZ6y*VA|;(vLhB zxi>=Fca&0UYiinBTVG;*l)z3x{qATQSLVQx0OfZ${{2nBxv=LGUVFv1Y3t%%>;b($ zewg5P*F7;Snt7W=njDFoIz_(?_~OW3lz+FI%#V$A9`3g4mp*@)&T83K$Dg64C4wIR z`9RXHysB!*>S2XNF9rpx`}}IDZ!6*|J*u&$?)7J}ikVRv;0+Og+a)I_2MiW8&($_w zJ}=i6ZsbsSFQ3wUQD)iO+sv1WfgwXq?wj}iTCF4A%M*D?+-p}2HpJZ$%Jxp3EuUVQf)qpnRnw(MC4Ij0UQ%OiSKdHt0gw z+C-Tz>;Z7L=jP@Vv0EJCd!X8la!>Sv`P-E-O@!$ z8}@5Qs3Qr-JN=mz9o77?*BFVYsPiHXd)1DeNe>vu8@$m2JH1J<0&?Wfd5oZhrlTJq;e3=?M(xuY!Wos{mui|nJT(KiM;o?>k3a%~3Bkvpo< zvWw6-i1`Jz?#;Vctau-+pFKDE;E1Phmkb3$eWdYh0?i|v(2$DQSVxD}JR@#7Xc6#@ zIY@!=ieZ)yZQncy8iE_-IACbJ-8R(RYU;cnIvgLfpe$~kKVLvaL9vIgt!mW-9`URd zygRkaGKV61W4NSjI;i3^8jb#B$9oIJdYJzlZ?Or{c~#WTvDfWZe#BfIHaGJDAWlKbk0+u*a;;S&!6Q7h)T!897?) zO%?SWZ5xq;O3!t+geghN`8C~pjI~=RxOjicLtx#>Q7(0|dWZw=D~}C%{^CWSWu~(B zfKdyuuB8SQ@;d9|d9V>_{q1dYFZG7xn)Gf;zT9bnMJ>6siP zaSL6$Avj#5sW(CxP~hQc?v^%NjI_6qMEJz+ieH-}u zIHS0iMuyg-`}e($KauTvHH5@%v+bci-)&W952NfsZnkYuGd%MA@tY}tB^({ga%3)R ziwuJeUcb{**RgL&JP|6={a-@$!YnO9ojydK_8HEyK4S8=vQ4vho~vfs6<2+=V62Vw zT(sfo>jS6bs*I}rOc%l6j-(qJXH_hds@e~JyduC%qCd5P^I0~%~B1eF3T|b+OC>mk;9d4x( zwsM=vTrp)GTV=aUdJB0vwHf=H`NOB9e|%$!tw0G;aA-aBgE@7Nx>9Gz&de{{_uE^1 z2A<%KZE_XZ7RxT9;Rn>03zXCOboj#=CEa>6@P?C<)oyMe^4>OkZlb8@62$Tnxn8$R z3(VaFwCQn`t~~@|RIQlY*Y~1gZM@jW$K>e)ZEbSv;rhVkxDAU2@eG@dN%4Fbd9?M^ z>d@+5qS6<3@^?N1#HRKsN&Byh5&k53>S1iNtV9bg-6TxMN90?+R}=kg0T(YjWxsrY zCeM+vO-q-~1#bPSfZf{gd^#|q`DauFeTcs6h7WPu6>MF(8=1yhO>6Y9Y3(N?errpr z=F`Q6w5LuLsHEEsR3=NiJ`ah4UNiP%&(RZvSSxu^q=|d)ubL|aN8Ms(GBh;oXq|0~ zg6q~W#Wq6-ZlgZ_9xl7h03KvB;w*Vt81r)SYAfbwU$3GzNbUVEC(u9p9-F4Cn9Rhm zg48`j9;P1_9Lg(|Sq{u=m#f+PsqYzPQvNumv(JGhEI<>)QcjDAar%5d_GHR7YXUl_ z)X`D3aG?A_l`g>LVrJfulatFoep&C~NZxPtFf5VNe)7lJuo4Cu8k5?U<>dVWXFa{T z%d)Kbxw-f5-)Bd6XQe!{jUc{$JJRZ2=Vnx85-5EH(}HyuzY8z6Qc*Y(qwc4@LBBId zo$IWYd3gByNoQInKvfKiP+}NInbj-`S*X zIuFIlUG$gE)gLKumD~pVaM#FH^CnL;WtR8_GOB3L9V%)Rh|NSE(=n*1-9;-)_%gI)#(65*Unt|GLwsk=3i0RaIO6-W1)wfd|cl2UT1I#H3oi-=Hd zl^q=$BlCtHvM{QAJ7QMU>CZ?AjgZ5UHo{WJDqdu+kzTp-h?~QgoNot3q<_H>YqlZa zyqbMAGP1+T5Sie4=XffrR4U;{xUA$uMtRsr)dKvidcDWiTJS>v?fqD3%JP@1qvS&~Hmi;lcHbObv#N)JMI&Z9T)o&CTK!S^E$AseLG3 zN`)I2xo`b2@f5u^KL+Vup`lzO{h8W*&swJXE>>yOsD-v}g+pj$+_yM`!OTc6HYKj; z(sPY>&!1`gO1D+IG&lFW_idHQnKLG_qgk$@SX5Jpo~TOYiA8@D<-Is(u)Tq%<9ki)3dFF-%p z${kyf8PAg`U}Xnz9h5{yx$VZAa`Vp6_Av-@r@OqA;1_doKJ@+j$6=$WP=((nuV_#L z}+c5%v=?I%VzcA$@(U~_A;ND2`gO0X$j&U@{-dbg(pk_-z z1gJLRhl@+@`~-RC5^UeCfWSCl^u5-*2zd$Cob(%Jj49bz4hc*Y5m?k`J}80)Oz`>h zauGG0owr%%@t+0?b9WPEvRA(@62^Rj-MZ^}1Wx!0c@ z1_WK9Wj~el`PHG#@^#cM`t#>IIA@52vV1L+*m(Wx)msle<~9$yF70B*rha%U6gVfk zk0CbRa|Qz05|m0(P?yDwU8KO!BvjNO~0?B;Ly}6)bPdb>g^9D=6eJ0UC?1d ztD_(;{3+}@_Vq( zaT%tbcQH&Sf<|D!RISBj7DSE33{0MjJ@)2V^6^D(Zu=eQM^P|Cam;GFV2S1Coz2r` zs>I8A_2mtIcr10s$0q?np1Q_UYKNz9t=Clqr;z8g2dvfO23V)=Hb{Ldu zy$jI~(T0+nL3Zo{kxWdUe2Pjr{o&YGm5#?FBm9;wBIzN;I#u!`+di@RCy<3Qyil$1 z+!cmV5X+gncBc$&Z#Zh_`E({_99Q%rS+z^j%2y7}*e5uB5EfPCn=7!=h+D!PF6gBP zrbaeH?D#uCGhp3X*yll-2wx!qt#O){NZEepxHns<{)18cdi2g>Z>d3pt`rD+1LRMO!hz|Jfj8%;IcoX(4#dJiWRayE+V4z&*#j zzeCj5-J0Aam%j!>*~F*lxuoWIr2uG6cT1P@Q+{{K0VXj)kw;euxigj?SJOB#KEBvx z^vBFcv>%=?si;8#T_SG`7hs+vh%<{)ku!@5auk~_in?PaDh`^$xQsf6Xv>@6sn(}9 z^)~9^xsLg@?i)4E=t%(oavLQxmL8}xv#>-l3Xa-+Sn6TfsG$fy^)V4~@c#2NK2CLn zgSaW=oh{mlXjGr}ahJZ%aVMx8)yNM-+u~g@nmTkDu#!(;wN7gg zU}iR7DBzEq?@krmUmNNyHDM;Fe(148%2O?N)LB}1&Ubb(b+E)hmhA=uWuwK=CGr|K zjINDMkuDr;JX*i+m8Q7irKP3qd5%h(z4C3YXDz9aMr!h{ro*M*hB1sUd#R(5Eb~{1_v+kc024A6zj9nI&_Oh4)1goW6Xr z{rtAZvNyfPW!Yrd@?)elrZB|s@P&>C^f1JtJ5^FyQNP@ye+Z_{SNBzLiqpHm*-}u@ zT~BYU*RtDTs<9b>!9a`+y&K?}Mx%-!JCS%TYB~#P-|e1ixE^*Fv$43iIJfla^;S&D zM6E%sOMgq=OO!=-M8kOi&oR={nl9VRFPs2g09+tV2o7Fc8*z@$+y_a!mla;|hNVXJ z9-o65m9IyGkeQ6`;XQoc_mv@7Z-)3r4qI%v@!V=Df?nwL$MSmnRV{PHm0H&+rBp&; z$YmP7hnqd=jL>n2*fZ=}{bM>|C)S4#zoHS9si`=CCACJ;+8)!ufZ4t)n|I~4r2B&1 zRi3y;=hko)Nsi^&yG-a3L!HXkPXv0M@z}k1jzlv0{l;<{kH!7@&cx!9Ax03(ZO%MX zQBfhM7FlZm`c(3Gvtju|U#;tEcZTSckbU!(X9#q5xL@9v)H?iR0T0QR6jIhpQO(*9@d zbY(%GLtGee+wR{7sp^!G_M++O=?9GAK523Hm;g#GS`7iBKt;Z_yGtjuWMtZN`pm}R zk><^4po=8ac-dA-Uc!{91w8r&%%@oS*FnTvrv)EvZTR->+tsUADJKt*H#rgn=y(bJ zIXaoW4=3_a%aQbWVHTF&w?G1UEgHaGAc){$ngVo$Fa@#DFj`5swKUJ2@d1g%p z`-juOz`%f&AX)pjhn^g}5sel&`> z_~dB8i?MFM2L|q~j@2or*S4~qa@-^3=jPsD z8{vm(m)N2A_W%Z*yk6_Ox*!Tc9!-os>R`-?(0TPW;JhBfZhcqA8}+~jRp7e~uDpw4 zq*VaH3y@Wi%yYMz64>2%k(YF5LYoXldf^kHM-Q?-R>#s|R$s1MQ0KKLe^6p@{rU}l z(Y{x_V&S1(*f~H5PzDIPoZOMi!fty=NP*UUevtek%RDv~Aythgjsx*zW0JkfXb8Na z8RMDOEN|6lmBP(;H`+wpth|?|pHx5|$Vf{o;mlUNpPf%t9=2EYRfOE*2l1Pn+&dG^ zS^YXU3|vqYpuocHd;^tyTTu*Lcqh3HL-9E6&d{;0>=QP?P^tMkFi2gcCV89$XGgYu z2ebnZaywMbum8(Yt06rYQtW-dq_{XvCSbaEC^^*{FWKe##0~cLmf9lL1A;Hx0(2sV za33I?)Zzn&kF0>@^&70%+EF@pm1Y3sG)*T>Q_`v88tU9j4QtdqZ`6lTL02*EK@EcS zo?%pRyXaTEiYXx3n!p&^^H!(yEl7TE?{0S(2gb(6N~R_YoN6ZcdOLYk0}oyoWSX~v zUzDlMgGk*QG;)rNdS4Bu1t5Na@R2U`eESy2X%o6eRlQ3;R_)qZ6mN~9_;izF7~~ZI zabgR*Y7bX{Qs@x-I^uas|4ejc@j(ew#!#rkK+aO)jW&e65^W0(AV@BlVR8Hs0Fm_? z2W^%<9!xEdwlRQ@F3+_``mz_wuG!^9Q> zmpY}z)zs9qHG-at32+baFpYPi;q{(7K`~~M+NISxGxJk^>3)1;T>#)m%!hRk*|;hX z7)hfRX)UU4ck&}r1??4);y6sL0DL9WK@}Pf54T9B_S{TqhK)rmnu&mF$W|r~|s>L8<=8u$kEUNQEU*zkV9X3Yd$m8wEwti}|VR5r_Zd~CBc zm9=BhEAjeO=5njPUFhYy@Ru4uBPy1nd((GX<`ojpk2WnCdEsO?CpF#~@1r}Nm;Ykl$Kw@_4)3$D7&N%;sp^lb z_inBchcoarKf|APsH+IQ@& zq5)6Mu#dvC7HD97NlTCfH?F~Vz1)ptW@a`zoIMbOtCaBGx-1A8|KvF%A?>P2Y2VRzENKvmBn|^&dxA?Afre zv^CYe0shHZ1FHA!wLV7!f<{nt-dh5YallIa*$s&phel6{;aP)U+*8m_d%}OvPc1L~ zYW!EfKl>sf`TvNw|H5i1_WGZE>CjZ>e-#3N^9(^$C*)^t_O5|v|0e$5q^VsZs*%_o zbzlDvc6H2K$jR!c3^b(vS0Z(YsQZT#zV?qE`pVsF|DMc18NkKI=KlV+MB1;9h54So zg!!`SzX{csA~fvf*LdnkYblTJ?0+rMun*0XCI5HFieCh~6_k|OmvraggLAo?-8*oh zKmN@pH_rCG#ZYrF2Ktx;^nCcf@h6J&|1owm20OhNkDaGa&`hQGChIp}t(vSx{>OB* z=iXXtV@lJ{Wpo6wOEaj zmr?P zw}K*a^53tTobpwBy7tasCUw!4F=(;l;(u`V$!$cv0nfhVijgblDr8l4V7PNpr!!zr z?qcl+UIP~ou%?cSSMTdf#1|=jYuUN+ulavWp2fX%QvCAka611#nI8YYpOF9mcaOir zDcS&gz^$q2GvJ->WA;{uEmWO=*W&Brvs;KQP4uOpCO0;q;OAcn=m)%vtgI}+88PL( zRn9#Q2#Fm80`NnfEqdjZl)5KQND*sQXhXqUmtorQPawxe$!uUS(Hc&l*p1>gtmGBJ z8art4O1|6``Xvt}By(qNPrusu!CdRMTEYn#sed3WEL;&&im=v$>pmi{p`xpI>$c%2 znO|Dk9M(&hzPIK0qa%w6K;uhH#*Cas7K#{&Z#cNqSUzdQzea zl&GRX2E2=Jf{yZ>RIXCp{zTou&FH8C#ryxL#K*=5#pj!JLLwpn{e>xpy+$n0nQ%+e zLDA#;l+Miy7d}Np;=k2=A^h{+{!H($oo|A;d*uM#cbAHYJA7A z0dQQ4q?ehdf?1e+9^a(Kf3SRST`ntoz?ALiR$bwKE&BWSTLGV^=P?EqGH|uVywe-w zHBLurPfbiH(I8?h)dl1^n;{cZ)1Gwkg0#M3f0>*Ih6++*Vq&y{ei9=0v0adYZie6x z)~KdWEzQjpegW8w3`3x(E?)Rlf?ka{csVkK1OcBeT;SmxA-*$_k%ie@>sx^YTSZuH zZS4@GuL+G7Wcu+rULlbvKeGdcZ^bB*@Ngvz>bpus|uI9VWbe`0Rrm69MLeOc&>2Vo|1}6?^R; zQcN^f?@p<5(Y24gA2Ev9_``LktA6uor!~LDAvQt40eBi9ahAM{l8g)w;pGBwQ_yAK zQX?XvRZ8m@xsy?%vRpVLUwcC$Rx+iCQzf0xb2Xv6)qsX7QQZ3g5F2HrPM0HkaVlG?48_*x-~O1V?7Qi4^^)}*W2qMk~H~5RXTRFH?Gi@TWzW3 z1+_lg$4v0skG6pr(Hc9W_lvQ({S64sHTK|2 zzrCT3c#gf5q2?q`z=U7yF&bQ%Pweh_WotXu;N8o~9sTToiA$+~Li-W}MhXXQT)~zQ zgiJgzK28EbJT*cau4}(s4(UkXvekW=r=|&hws!TVKT4^x)#9Lyh&Tda?nLe03)oP8 zaFWE;+1szk3pGZF!4av7cMZw=g+zNZk(=!^%>aur(^BE#DW%Tz1F2aj?4aCi>zX86 zASAim77byYSMOMx`-fuvx2%##tG+qgB#))Nb?Xo+xvrwZqSMyi~|Ug zf*ctdKU`ukRrXBJJZ6S)<~&H3f=z(;nS1@b9p6FF)I~VhQ`#?-0HDxR7P^TiO9jcZ zM>O4l6NTGdap1jm0?0gTtAwm`>+@~^Za1IUanw|Arid#a8(X2n1onjDrdAktuWqlC zEB@=?@UYuDAoOAZDvy9nx!t|p)Hw!2jcu?lFN4|zX)%wFP@)71wZGKg`H7B_f%4X^ zq&)+`!M@MTyfIR-+Pb$ok7wUq>Q{hk0V;BSE)o<=r3yNPhlUm#)G43v8bfs9fY=iD zfTVg!jx7ynNgw&V{QUK?3fxK+3}l7;@O$WZk~)AE*q}>pTzhTc`3&r#MeVYf5WtlN zd{`lgJA|m6#qxvKe=mT)r7~CmCa2A5l^d-QsuG8_CP#v^ttz*KtUiVe!nIYT04nT0 z-6R2URc4g$HNvZbSjUoO9gUY#0OFKLdnl(fS#G`)zMRRL>1%K>F`B@G(Z>#|LhRB=HY1-+a(OYZ)}ymkj$T!XrOnh2Q_ z9s41ABUL8j&Yc6Isw(feOqU6g2GtP~)5u;luBXYTrs7zktdZz+VepRRiPx)_D ztcc~(5IWv>0C74hj3K08PMBDoquDIxHWTi!lZ!yo0CCdTe54>w#jWTU`<{g4rpQ@= z(}#&hDpG(aFyXYg5C8Cim4}Ci+|kKNSy{QK4u-=OM2s|$Uwe@wqg2rVDho9!Q>v`) zFi{+{v%=aU8G!IL6>moP`=bh!1*}KA21Lf|+*Jxt7WKWagj@lvl41Sp#0hX zW@gfht|3jSlt&OpJl1t~|HFq58)$gTk_V*QqJY-HN4E zItBqO(_Nh=?hHumZcIOx=Hl=j|5V!sJ_kGOiAuJp`nlNNPYZ<|@i10WxRGzw9<03;SftpMJeCi~}z|R3J zn9;JJjEvOpfq2ZSo~~3!eChPk(g&@AE2oSq+zcqS54Lv&48$D)2{a5%|T z91b$;bQT#3aImhANq|UNsFoKu4+LJvF{^&(U@8mns&7OCWCPf0Gc8U&KBk8c?{RV# z15b)6W(Msh{k6IIZZoE)eR9%|@K!osZ*qBj{_B?Mc``zr@zD=v`QHGSa;iiy_exnr zcsMA=edJ)o3pJ$FCN2}NCY4BdypkYun=D(Bqdx!eU*lBLI@>g344`dMQQ>#bfkov@ z4f#GbH6>&w&@@L{-uo8#*KDM+SLt;L7_^1Ay`5c=2ArK~#@LwY`mtH|0wC~OdQ_jg zLbISLoq0>#LLYdKQc?vd=7L-+Ks%d5^-3TQ(MCZ3FL$DCH}$b3-w|=8DsqhG!mO-I zga&Vsbij8Re#Hy$MF{{`71x7nWIQSjh7bWwSM0l#ok(ewf8x-?s0xd(xu0KsqV zUC(4oSQOpfn{uD^E*1;T8i)YoMkhrv0%U7f~t~{t=|WxJT-b!`ip! zr|Ji4T|p<(bI>b9gyj?=M>gu*#FgFM-GS9TPyxrGS6!^ecjv(ts94lBKz{lp1eV&= zG{@0xvSQ=$>(_mlRK!A8a(wd_z?crB;sI2{j1XXYTYPa)F~=Xk$#{twb)^xoS(~1I zA9s@P3ISAe9zY)2rRJADNw<$xZ-{JlZq>bc1N;b^$>KnTGLzwn(S7>5yG-TSq;*g; zNX97gaHm42MIr7*(F5x*!5zDu|2;1^2RO{#SD`uT1tNPZ=|>nBeSJF+Nn}6<23uQY zgnQ9OJenPLW3_oIQv^RhKY&M*Qt>R0!pFdK2Ku3o?1$?Cm@1!q=KTIi`G$Lxuw{R2 z`P1E4o|i~TAE}CkZbp{-+&cA3D?=PLn$W+M)on=~YLeB;Q9#oxd090Iwo3@8Hb!Ds zY?s5B@7?#~Gk1F4?h9KjKrK6f5~~I;SMsf$lLJ_Z&iF4xP#q)N=1R4nO~i(MFQnQ0U?3;#xP7#P_vNm)ZAC*b?m0&?N*qVJ>mwnXR$KaT zt*Lz+5hp8wEd%uyzhBF6Ye8j#ztt$>JP!hi>m}sQBdeH6u{>2T+G-~wBO?pMF`?JK zSW4oMy7jOATA7#ZO5CUDpMD}X((`!D>`W0G44S=UN6*wwE=1%HDOl?ikqR8bP+*QJ{5ZCcetzF z%)r1vXqfU|jn0G~=P=?YEF}zx>!8Zb$nP*f)(?2IoeD;7_V_yDd{rso07@iAigEjV zJ^cEW9x=6_3g*oS(ojWc_g=4Aq(B?&1VWFG-(+Q7`(jq(Gk202#YZ6tORG>EGGhUG zBph>(njvEm8orP((oIw=@EpJrFB12L^&|;pNQQAykF}|xH(7Z58oR${OlT*&Bx91Y zAY|6Vmxy%+%B0fM+1(u^exk&7`<~UgNoa5*YBx5h)md-$Ngs*@hmXq`-10N2l=H}f3@Ks{^DPq|16oi zefMV5UzbU!vwoph|D&6XhHSVvW_|Zv=J)3IHn!dgJbHvbs>0W(xqGXCDQ_~Bf{Pi_ zGZaT&PH>+TbweZC5K|c%`H6G#xdz}(N6pJn4ueCev4nS*8dh;e&iEorBaSn`;C#Gg zGr!fpF~N6OL(9<|V7Zn%zzrS)ygy2?Y?kGWV11TIJtTS| z(G84NXlFFW%{ef#B>#M3wZ0R<8>t~;smJl>|6d~2T$=cZ)j`t}M(l_B@%EYeqi0D- zUddH5V+4r5y*9Qdo@K;Z7~4Zpi$J{mwKT4-O>9L{Ar6FJSyO=%c|#_n5vg>;s-xQ_ zseqwNLXz+z%N<{{QC}G0y(@n*RL&YoltZk-;{)WrJuK7Jz_|dtmcJX@uET{pbc7a+ zeX`P@eF|HFK6ie5o#pO3^$6l%avMBbX>jtjlfUlIS?ZKUStF89e9=!K12BhRZ`aDc`qtYj7Yl$4v*1ZZ`Xn%9Fp?Gvlpa}>6^FIe8*qt|Vma1tY z3Mw-`Sp6CF@$tD+-uk*iW`D=!zj{$}ws!w~_I0m0kN(m%aKlSG@kE1((bs>zuzA6| z%DU;(pDIC-`BgBv{w-RgmGHcozaCY&@#mv{O2ntZ;K?Qv+Xp3oO~Ny!uS0=UO}5e| z;Cw>5&jvOjNa4>BNh;-@jHpKK;Q#Fza0NVYa&W5rU$)8q&rk*bYopwJiKZ@SVKrER zf!%)_ISBSf)YUN&6iiDy3SV1#eS+^{Z`)FZHkx}yG4i+`2~NZ%W&rtlnZdm#pIKFe z&0WteO`^=c_B2T``}6YB`m-%}S?9Wf?7YUuB=4!g(hG6Xio{Trz=x20FCw9C9_zX^ zRPM|?FZsQ{+vn6|g*E|Y+_W7N^<@^W8DdIQJXh8K;In9CW?));nv~Q*%BwYnHOBALP-c`)8;i zs>Ecf>v^W22b*8YNcewZ}fIR+HJSlCP9s(jKXcc-$-=N&{~mtWUwE+ zPjHF*GoRr|aqQ3|F(gi=`SnmnJ#zMuXuW{(QINGAs#9%N8y*yd)rS$vp?AJWM+yoeSEjUr=Zz3IU50!le$Rn zcE?aziT7fJN;5(At0~>wFE42|A!i>c^RB3+V6yfd+0dFS42`rp?Dd$me%vU1giaGG zXa_#&zk7b|#RueCV@`~~&U(VzqWU7Qxkb^7EI46o3{?F1PnTod28^h4tx*A`%G9w) zk=q9jZYl{Enw* z@YUm9-S_rY6&y!3;ssV%_UG%RKA@lvA)OtKOpxB(kv(06+7PMbiC0rld#YVL9)`D` z);`?KEz7O1&w2Vrn)11Lm^@b*OK+lhl#l?y)}HrH>o|_4F*bY*j&kGJ8`*TVbp^`i zl*E%6q(WSeJpSt2p+NOvHdT0`-SjMGwRF+lZzxW%vYs^nv`dl5RgW$-AkCahZVT>C zMV=-E?TZAw4{Z!gUIy8X;Ny}Q&aEWPV|+{dRt6GN@1yG2bPw{<)E9ag()C5XliOu^ zbwoY&_#kg&(4>6dER-Kso4ubE7Q+U^i4AX_ISGhoOyygCyaJENaN6qRR$p=OS`BW+ z+K_3oMK{5KRzvlgq@kpk!tQ0ghJnf_ltp5BKKDyvXLjuqxh#{t2%ig$h^>ZMBYbv< z%+07NgYGE>$O9I#0?%&j3s2thn_rphZ@E}Gl^{(twCtMtD4`!JRVL!!)IB3|Wp34z z$7es+0M1IqI_dpo`YbU7tI#S{XX|jIuKZ|;yH%(4BI}3fCHfSywe+d4-RKJ(YEw!V zrlmIe3C|@m*DluOTx+P{!ZN9U@RcnP8-^h*Wu#e(oiQA>2_k($^a0uwh61 zftv?~>;W3@`a@TkubeJVSG{0i2LfeqpYJCYe@xol8jEZZ_Aq7*TZEo2q#b8_&?DXM z5|Uq|VTfYWFa0zYBlH8(*G!(GbasqQ(bQ}Ar^I40?(Brcv&~G=Wv%dv>dIVJOO`!P z#HG|J&4cbgCvkXQ-^FJ#6g3ugvu7Zw?;*XP{h7LrD{_6NJ2laa(Ehm8?+)`s$yoWk zKJVmZ?_`C%NwKZNnfTT>8@D=Y-7G4H4vWzrK3nT~Pxc!nDl2&=ne@gCd-k~MyeAtx zG}2Nwlxfkf8xI{cY}OFj{ddLiIx}x*VCd@Oo&o zMyQN4<#D=c;{EvyCuiMtiZDY4?SV~43QHM$M9a)#VG2_n*T%jjMnV9e)XfsDL zklHR;iq0HfSLm}bCoyk(lAvXKaXHa)pD1b4qvBD!<8<6#H#U-5bvtJmnGA~I-&-L#X@6n~BpqJnDW01-trujRsP6jYOY$e_0(iMtz~;9>i5`-a=*=(@ zDTU*Ktpj)W`hEKT>tQJVdMA6H09xT5zgGLRzQqixL&+~bk2_U+vphZz@%N-sJ>KQrcAG@>EofjYZ}Xf^(*?x-}$qG{%U3$H|GIaglI6yms#ZzZ>Gt z2ytYISQX(U3z5yndejhw)3_DLSs*&iT&-?ArZFHT0Y4*woaE{cW;nY%E`uf}}k1tpEMzGDtJJC{Uh`G~Mb}CP%_S$C_ z#`(%RpNHa`D?MUnp)E7GF{fR%Ma#n6VaF)9#kAQOeju0t8tGA?>#5j>6Ki$lC?7)F z%vLa1>i(#pO1vd$l#&gRLT04nTWSzr7_JO6m@p)TpU+nJ8re>noq8-(2Pt*}3$Rt2 z*42|9$v{ts6sfj{)auPA&EuUteFzpC#gW6iZe3rex6C~D3ZSbOzrXlEXX&!X$C(~t zu=_}??vua;)@#eP)7#-hkI~h|@{SP!<`&CG$QaE!jmL zT&GXp{iA!gx^9J*F)rvdZu+0jsFRxkDjuhr$ zz3+;YebuhAGhbg%77eFg+PMGAwQ} zb@Ey@#_@7CK~)w^W^SJ-SLLz{q_`ADP)CT$lO;SAoy#hb&3fD#`HPnX@~|4sS=}*P z54xVD$Iz&XxVSMP&NMQVuUk}LTwAH5EOSlT*XTstfQnQ_3V6P=0`i6&I&GMJ2-y5% zsDgMBfscO^G1!7*Xsfl#x*=8np~io8SA=>liYBEu*{Dwd6Smo;S+Y+L87I5G_799g zq(nZF92vQG>Y!R7byY9|{3t>y2}rubCojzt|NQK{SNAX7uv>|S5SUgt^+-BRV-d&9 zvLKf{Rm_gDFd5yye`dbh3I~}h_VkCujBOq2@D)k2spHA1qRZ`_afCtMjrheqzBMRu zeg_<|C|b%XNIB#p);!&gEn6(Kx-NatA7X0(dG`B^Cd)67q#*yBZIT*N(-f;C)N_tZ zV)`Gj0@ty{We*lF%%n1hbBFPrxGelSZNq@S>8SXfT3rsaXc$G_YFP2J_CJ+8+dQo) z>2L&$Cjhz8IGdzS)KwjZM%d*ARL?&~filNY)2pAlZCDZ({H>k<#iIp^mlWHFCdl{1 ze4f^fm|G01V+}B1kgC9JP6fY(c5Ee?a@h2Z1aJF`_d5IT^K$m_T@mrsb1dTaTdZ-< z{z5idm&c@->rYN+3fRzC&Az1H43(Q6iJdmq9kakRBo6&r2&ye2c&>L^j3$%dW2*9; zb~KvCqw5(W$OhD^6aFALWFz^FRC(Fv(An$?oq0W;+@3*-JWi==5auSp;>^;0lE~pi z&PU2sUxRqPyDPt(74GJzV4&8hac5lMx@vaH>>%27(=3hg|{-1}zyS$&|fd;L!JK!C>7i%vu0Wt#;T2N!F zpxl&6W|U%6Ir{N`T>4&usjK~~#TGnK$M&yZlYjoYwT==g86tZN_frhKeSMFZ3OaxL zL5xfa#*v8;&X{FDK9%-9A@|8kz8>NFG~U7d{Et4*XQ9c=hG@V8z)eNxxX_*j-TSm2 zm9zbQ5jIJb_%{lMUqW=ZLS6VFnI9Qspo5SE9kF7N6-KkgmYTQ2_ z2ry8`bkR4k2COWdzD8ju{n`dD)-oofkohz^o0+LpiA}sNs`f-u`k!o9r4}TZCaP`g zHSWZPGHvIWbZvg8Km~ANkn9gC>Uh$2EA#2-UPY5>s2AN2aS410vldxdS+?&Gx<0%Q z$gTCf6hHgIL2T>=vfNAxB&3MgL<$&bx_$HeO=!*!(WKiT#_bKy_{tjH^?(!KrF0bX z+K=6?+=7Ir3pT6^TreI2;E?c zJhs>K$%Jt1-a1fppvrg9SAPtJhyGeQBcQG;&C{hfh^He)^($i|?G50``$1qD>>^N? zk-$^@k71BGbIbEXx6i!cQK7f%?PL|&t2=CwXZ-hF)wP4R1}eudS2hekAO4uC-8Ge;u|{bbdCPxh^28;P zrubsI$A;UlgX#^^*L_6tk;Cr6{vL+zZvGRTKL}dAtM*EmF4!3(y$Sz6gX{G5CUCoG zvJzbfv%<4xwt3y}irx=RwXXQ+G0PF`{-LsFl>;1^hrGO_wPfs%hdSEKGS~j-s&Zj- z`NZ7S-jgD~i4{xZk;!}HmW+Y^OH&5>J0lD&qRFbHcf|m_Q{s%#k}=M+D>3l~IS|}_ z1pIemyR-0L=J9m3N$cOS01!OjTJ4SCWP`!rSI-YbxU1jl&QD^MMKzmDV>*|Z?#0A5 z0sMM)Q{R2w9kA&C!K)HxQ-2kyu>XNlQARnKqkhYB{?|X==rUk5ZtQes(sk?-z4(vG zYS?40ixU2cn`30$tb*{`*EyxhfF3+6i4nF36vofa4#<)pO@a^9|AmkLhO5)(HVq=l z@-pYdVe0;at}Pt=8&m)7nLmMT^RskB|DD_Zf8JKc|6o}7uaTzVkNNao5QG9UnK6$D0n2vIX)uq z@NNy#?~X2^9i`xM);vP^Ov8nHFVB1*tc;C9Ipo^i7Y@xft@5;!27MT$XOL-k|E z2ZzrO!O@170*fUyx~bKr`{@-GL|vCB2Z`8-nR-eHU1Ee1L^P0!X_+M%D#@wV)2FM5 ztIH{^_>fdWzIfpKL;S<5x~Z6I;l-G20z1=I#xdFgqNhUUc>`gh+5OSbz2}*tet@+! zDnFnn&xMGvs%mkTl=2r22yDDE--h=!-Em-8kjY%mGI5sf0?ZR}r6$-~2TnXH@o{=s z^IWE11rZvlJKyA;IajAd&;`@JxQdz0d9QtF$UAQ}PiLskb5n5K5#put6mqtq&1iC{ zxPP!dVgVe1-5qcp(K-%Mn8Ax0O$i`61C3p2-PL*R)*lbg^{4oXC(F(%l_R(&VPY6P z?Q4y1t9!*$0zNyi=J{R|UtLI4lrGr9x;}0jeroV`;8okfsf%EYgt+4huuq?{lDm*Q zJM1qtvxQZHvohs&!YutCVxK+M{3sJquSWYe2WC-wAOABmmrYj~?lF@)si>G&N%fY@UHZy^py6 zb?B0Kw5#|6ZdD|_5zzK^rT%!r1id?F(MFL;ubAhnB?AVe?_ENC?IEpu3*`MB^1#Kj zZS8%`&R3bLcPB#G&@9Yv6}X;IPKWnRGG*-Ki|0`SS8{#5utXcjhbOx! zjrg3P%|k}X3Zm=@qx%E{m3Dgayl8;a@0Qy~i(1`W-N_R4ftR2I1pDMVXWi#V2N{mm zd_W)6225ECrBGX2xSlHu@DZ4y7|e2L@|S}xY#UTWSA53%wA|<#??#UOy2_NK#^hp( zHKep#w+Tp-pe|q0GUsJ-&l|A0oP0^dZPItIOc)j*& zhLof(!lXVV<|9sWxNTdJ`nrm%C^zD%6)#oVwQR=;LG>i5Ebh3|t=Q!~PASl@mkOB9 z)*BA6p(mPJgl-_LdEDPbQ@P)1ByFF?)Y*`Ju&m2`Igpb2yF^}v&d zkBe|d-rH|8E>yCqq1-tLDqmp?p{bIt@SOzTs!syghc^Rhkf!EM>DoA!P(WfB=&t4& zF0TTrq@JmBZ>~{5cZeD1w|f9c&0*b>LA*5PR1$ndalV;Up~;kH9$&8i13KLSrz0z) z`kEc>ygY*1tz<5iKznz`6Bh~OhD@^o1M%WAslwf+tCN0OU0**mLwY9G8p7X2{-ld= zmUe${|2mHuryB~>){+&3WZ20c6$+YLL}>%nrV{_UQkaM+Qy@*&z*0&}W?}f}v58g& zo%!ngY~jUVn#R+|Za6ceH;u|Zma`}S$%(Rw9ly!b8PP%afd%1brM7!|uFU&#UU{@|_~Hgc zZQb=~nW>setrpsTcrpIan#Mp|MsZJI~J5Wn=?q z_3QE0l!;l2o82nnoK;^>!DK#!n0*9`vsy<=&Ju0JHK_`y(PWH78Wim&w|@MTK_sa4 zZa`dqf#1gWJ{p9xuFFuucwll{(99&;;+L%H?vewvfTu|6Kx83b)2U+f9obzrqTN|QQoMJMe+LfT=M2F|-57Y`-fop*>Jsn@qSYBA>@#F!v%BQ=cl`(a^c3yfhHtLm&OqQh9B}{h4r4f9&d1VmKv6Hm(aq9xO{%c+dJgRlURO?jhAv_~oUvcmaG$c$DC+i3`i)?iEn#E(f^V zSo`>dh;Dj3H%PY~p7!<%FkOFpUCN-V?Cc|_-W22ODyplxzDn`A7<*ly29q^IjlX~S!y+^6 z!A(-zYo_Rmv0Ze@!zUXb-|dUY&u{d2f@>yPW>+`3r^J&IzjSpC5!7UX5+AIb3H1e% zF*DkqS*h#}@*o1-{S&Ru#3B?U>&aqM_3{#?A3M;ODmA**vy)s)-nD2yX@1nY?+gz) z``!N?=Td~xA~%|6+)jZAp{jU(;Z-d<~k4~ zQVtfM?PW8bcUl>>ntfik`Oa=yRrHpxZv6()ds$*|Bk|ovszkx-0UGA`x6JVdlL2B{ zA!>DXWKfFdEF8}J{M=G(3eCQF{-t??4+z}&QT~Sns{J4B-TfR1v#)^n1T@aWLO_r~ z4EkOe#rz;Jf)d{3UE+P2*Yx^tx)cy+|4NrOUi$v)PoF=#xr*xtF?d<%S6x7Zf{icC zUo1g8#-sdSdD$wUW2+N?wP?Rq=veXZ>;p3niJ($pjC$%$zNaex0AH}*81`DBXc z5)+v_47J?FhybQa$Kw5uK072RV?du}C|{>y9>#Nt{Nzu0v^&+tgi8_?MVEeF&%K z{42&O4Y6VrdKy>!{r+3t|LW<5O%#0{+PwC33sD35oE>-+~>NP5AI)#78R9(KkW; zPu75kV)5;9RNr8-+9?0|z?Z%93Ewlfq`!pYVO|qm*f@!TUQF7u5~%+yjRSwuc6I~l zJ~4q~`$_i78umLVpL3#Q1+z2s86XO`(nu48Ad+lxb%5Agc#O4=C-crLL+!H}xLWVc9hSn&)F~y=KO^mO zE3>hc*BcbR2EHI<_AH^-R{FE-$?=<0dsoR@!Che3@+kIo6wY^GJloI8!>**UabV9M|s(baS;wXah?RT9?#9$bv zve1Ufe!Udq?p|e+e4<}qZ*OmFYm24r9oDYkZ7{g5MRTq(sQ8x)N_gUlGwWo)gR8F_ z_D+ynt*)tvZtrf_XhxH^MPv_P0U&q(negJ}FRbLnhg|-b|G2f!#G7!JYr>hdEg5n5 zW|e)7a*@}FNl8@cel3~70HuMB%UCzsK#;G(%fxtoZpB%BM8!)i+RmzVi!E~=J8u3- z7By9?c7$wx#rfEm+*MG7WvdG+UmB-E1 zepZE~GRG6Eb2;%JN#6^=|02W&Td7{o8Y3Ls+fp9~i35dDScTPT z>rM99yS(t} z4k@f1_qG374YGZQzWe06LTfqtis*QYL-U>2;Aq$GUkl17raRSpQ!7z0IndkT+At6g zD}I7VrWVbykNZnD&+M;ai2*dR^*b(%#LCQHMEeRn3A225G75jaV3_>1mG+VgmH%iU z%P5gM{I8GELs>a16%EwkV#5pY8d{TwzlFlcXbFm)>~)IdYpT~ODs}VDelKO(53RJG zc=qEbAX$z4&-r$(Ck7LFLuZ^0QxwW8$v8qT1=@9skp=7hE08L)@0A5)_#~G{wlSA) zjF0mcTme$`srY}Z4+S|@?wxVc{X5Y{gFOAlFs8!TfCLES%5(I-~mtACsI z)}&Z6v^!|iVcmeBC0=NlYuQK4dkxPE$BIFpBs&bLHcJ zN`e^m!*=m;qRL#wijHrRkCwJClK-kS7*YgBDIzADd%Nx$)V*^}FE(Cdc4v$%eCm1< zKo7g;QaEmXI%DI$#I_*J0!NBLx%f6y?E{atmvbI@9!M(IHT@LtXTM*Paerq{L1#%3 zYsSXf`6WVYSRpTwvxAlHLS|lno7=bHEH>5ve8}2nzY0pWl7lI}^!8JImae&>8}xFe z&3!S~QQZ8jF;rWMqgjFipV?_c1N8U$?p5@;ulY@gF$aAxBgUA;e!d-TJ5oRDVtpT4 z#SSeNWt9FH_pa0aO=tH6PUNa|tKQOR`T1&g2JvPPi#ir$|0hP*JIidy_SDDSOfKvC z-dA*l44kW_i7I)=i2klZ~p1^0B9f&Byg<9IN-|xgThpeS*nfVx-dB_OX%3miW z6uI8N>T$ngm%H1nG*UN@E#9vD{;=8Tr16MlDb?7J=8EsK}V#kbHCY-bHiOjeOlF`w>m6(v*((Lyh6_l&qKeI+6ysE4mo?o{2L~>Cs_q z9VAEyV2Qls`ElL%GiCly?6h5>b2?_XNd|+B!bL*y-)OZ_)K9flPtHAIy+apHCU1LtVGbkVDH#*pV;yt^l>P zBs`WYo!$!MF1i{P;ELdkSV=H(eR_Rs6P=QCdS?vYqr$#Z=xc`>r1#03;lVa@A<;`h$YUxff7r8I89vC(M_E4Er#PE%jY8zD zT%$8q6A_#3m=gK1lB=XU^;l|tc@`sdm=Igwmw>*w&D72xcUgr_!O+X^MP#wAw~u0Q z=ky& zBz?g5Ra+1fHJL9_cy^b6lrQQk!S-j+H@k8(vW(sFa~pBz0VjgItLq#Oy7Fc5)8L!X zwIlBItO1vNI2Q*|GG5+Ji$f*5%E~X|-OshJf~IlX9KUW%2E{yC*FCCCIu!=`Ui-y= zMS+E`0Da4CBV9**)m2jSaM@9nBdXI3y689Jrf#Ob9%?aB51Ii3; zI<>G(bDu;VTArfr6OW_xgaiDz`Hy0Y!k^WTLJt>8tyGnlx8D($20~xN(}b_aw)Zf3 zu8z7ulG=j&dBar=e`R`_AeP&)qU@$>2EZn3SPsKpYPoBayilm9fH$N`oS?y?@4G%) z)H=*%rOsEx!C)pa?_}AgYqFL%pdA@KJR$CH0$;ZeH0f%u%Ck+s@6JIia?10(qq=_q zw>Hu{dYav18gp#-{xP~sv`UrTFO!T8<7<35(;0%*q%OLH?a@!VSh4(#$#uqk`CA;3 z9mQjOD3tqH-rO@3fTL<-k4jJoRm7S!m*7vUb}y zbCx?U%98cy`#uxB#Fjfb&?57a`T9;(3G`h$X)mNw{@qgIRn`FBtp`E@Y?#l`e6IW5 zW=$~zS6PPSWGG3-bSH4t-O^XwuP5}_Z;kNyjZEJDkevdENQjc`?^*}p=J9`e|9*g| z=!0v~WtOrAY;N~2Iab==HPjxhR&MQFFT1VXrubwaFg!0R(web&&yn)^M893?OpG7r zh`a@=?sJth#XMb?!<%<-x=XCL#@yXM17eoV-svsSPa+sWDvE{(#Mr@px+gnd|1^>g3m7^Kwh* zg=N!O^YgfXCI7PP9oeRQru;O{{^}#T3jXk0dXwxO2(RC7`#+qfSZt+kGsJs6a=!Em ze@XepUfy=|xi&>}zjEiHnYXbz!D{ABuF5(J1+#e>hi9DRBp9V{Xq8sa_G_6r1+RU- z!aX5~sb6pBXAL-`x-pY>Zfh|2jLv5^|CH#_G&^&G*j{9WevOaf;t+bRKCXO+uWyXz zRDv_IJ4li8BvdywRfD!GvtF!hg{^3z*1?!|tY5FvBWyj2#e@bvQH`@Tc>iwD{B~HH z3qf{&<qpdpE8e;P|>^2TPV+e6ftqRb^>!eB$qGj}x^Gc~`@ z{w7%y@559I?XPb0D9SGg$uj7=qTLq%(oV074gr;k3)PQ!h}=g#ft_1Z^!9A(on zEBiNvElp(RM9Qvi49Uw-Vy})}@fK>|7!<7U4M^bl)0;H&09~o{T18j_U3|TMHoI&Z zrkoM%X*^M^)RJ&x&lEFprjM#RrZ`uQa;( zx9<>h6-7$HVR0IG?G#>P?O};Z@k)!n^&q|2E6Pvv&SKBCJ;>k{&yW(Y6n~21Vu(xT@7 z0UxS?<43N>(I~-7FTp|-6lXpK&v+(CcLC#W_q6`V!Ja+feYc^e7;4+8Y_r_dgvmy` zH1Jt*#dBMav*KEJ*lM>uIFN|L>WlWKksPdU`uT^(A7smtbx%qS6GODJr4yNqO2jo! z2vG|Ytd5_QrS6;b_H)Pc#n_jQy4!^HuZFAo`@uijonGa!_7hF+4ubV4N!0vSg2>)g zGml??EMPpM2x#FXq=mEZ(}MX=0I(}>{KT2IwVB zl3^0IOp_RZ&r$P!PAI;ztWsz zw5N~p?LJPPDpSI)8g}T&hYGAD9y1(_G3OlQYCK@LkiBQsZ!3SosYQtQPdjZMlY|yr zfzC!cHS6qoUMcE0f!2HS2WX55#!KDV;1zFfWWAoZYMB@HybBn64?bnChb4-AxsCn! zv98K3!!ZzAhTPlQ&x|+TzE}lxV!tg)*9hFyNqJtH(7Dn zM2eV>fynwE_~8WZYr;}_^^(pV?xtp@4t^NZ;O0~J`_WVg&&M0%*6vGH53YL`5NV0M zoAq?f;mZlUCPF(KR}{nbKO{e8Tm}NomMGJ1TY(bcs)7NVYN~D)07{%Iw|IG31~*Mq z%LVE73ly&}b`itOxs~-33uOJYMs{CDbv6cl!!5Lq&}A`CqM3&Mr<^kATIzKZ?0gS0 zUQuPxw{WlC_Iq2#h7lgi@*Hf`QQ;u9WJEfxzYn6;l?A5Y(KESdK0M+4Ajw0g4``zI zecvqQXq(x#3MnXv5G}s*Bh{*%H$H)O1O8!Ib5df}Sm1<;_1M9~w=efx`oP%c(b$&l zc@e=__bYrIm-%edZtbg=&2k&$CSz~pMnzK4f9+nWC_NQrzQF0G9O%%MsG1iVUKAck z^K@(zGbX1?ZDK51BcpE-EQEjYgrDDLx*d+N91-!(Wlv!0d&ns5RUs~%mK8WLSuK>emaL?vBroW+GP7)p=~1H@3?d(=kCqMJn|(cHm!+ z?7?#RXid7H+xHdac~8GAf#cmd z|0qyZK*Bg5R#q#O-Q!O8>{cqTdf=kQ#fVR4VL{ntjMOYJRy;WE6nC^#^P8(~mo=@E zL<_qtCdr5o~+diwEvUzkF zNKIgvY))JOpc6epv&Q}hG3SbSlG=j^wi*5@v^_X3FC}F* z!FCq^<@0tbhRNY(rFp0r`&$ypRQ0LJUIpSFYDHS485QJZ{lstyl<59aqnGGZOe@L<*m?!KinmyuY-l7R6*wwt=Ux?ucVl-7;f2U0fAC9v(H`^oct6Bc*W)>0gd z2pFJ#-_*0{SVnl|~bsipVTLlRcV{ zm0r^Sz+@D)obZiTQQl%mVNqCC__LZIa5Y0qPeKLyXe@>m76oMu68Ri1%SEoeoUz-O zu`}iXxDOUTsxkNG_a%x;njhBTNlL*FP*VIw&yCkJB6}Y)h<`1OxBJo~8=EN;>RDsy zEsm1do-v!7&rG#?=%xmo<6`y+wn*a-?>fJP>Nq;Rtr5P{XVmxrAAeKom-VFeqhoO2Ce1<*sG)bS&^`{Z?iTwZo**`9rdfc(WIUZeb* z+k5otpUHs#?;pvM`fm})8y$*<?@HEGroVsKlVLuwkqk)xuGc@S zLNuUm9>J{7|ICWKje?rme)iWBNp8lfHq(=XGu~jtzAzJt5-t>(MNzGbBB2xw zKkGp3xcXEj|B^LiJoxXC|L>-h|K~CMvNg5m9}5jujA;BN*;MAO^e7L;+}iVRWlc-G z(re}ag2%gF%UY#=6y^UA{8_Uhs}C<%`A;nU*L}A2N{R<|_vB&w&nTbIg=9Odgn-4= ze&bXB6{h}p6+gd|miV9QWEbh;?+){s3t1Ok;G~DN(uRcc)v-r{lb;etYXW?=2aV$f zDSq8(L&sWo{;|!~2?xV!w>C~;8A?_o=Xr2>0_po%g8IkJZux}Ec74_E8^~@k_@pE0 z?D@+#deN?NcEXtAT$`58n|yVfjc}5WZrApFqR$^cK0xE86dFMRc;`Uf@wHZ6L!HX{9gzJ&@?VvCXMCp&$iU@LV|?+)>m=I<0JJ? zw6q4jIIf1cZS-XzBA7Aj!ZS5HJ@pGKdCdN6eaF1y>`lFCP12M_5q>94_gKo;tMA-t z&Q97!(@3ws9amuSJ}{m9smXSc!d+K;jWHgzYuhZOO$yuuyKbKzF?lU_bo-Dpzcf?%lvu9BJ>~t_fDgk888ZS&#yhg<+Y&W_&4YIM`6!Dns&r~+> z>$w)&os0>fiOMkxO?|ec@${)tKWjj*23!~OLQdb>I{gbc4(_%bWhXpQfFLkc;!4zU zQkpQL)l7h=jg9SyUqMQ(558(v4O{J=`d~U)X`{Z#D5`7H0ke?;{m4K6`gj-Bm+W6P zJGs-n0dC5qd_H3AnJ8iX6i=mXq?Kf=VW1NWEvvEc=8Csl8!glA`SUdj558T=xvg}? z+;ySb`>22f?%rxSpA7ZP`LdJ*4VPy_|Gu(fv%0dKL61*!qfXAr-3@kDk-!??;P^>SFarC&`G7GO!2L z4V!cx2osSw9{mC?@d>2qK5UHFHFjOsF|;2VBo+Vsw{9s;|rL?-Wc?T^98B zKf-kkh~uMMhg7`i-EC#wT8V(o%uC%zJ<5DRG~m9^(*j3-LT}TR4pJJEv|hPYA5VSF zJ;K1jJAoeJ*mYeYsi9hhh{BT2$~$s%)C^)25`RuR&+?jdI|_@X7#3^mc^xt3iKk;UgtL2&o3ip3Pq z(;zHw;6gGA*Ukhm(=^qOV7qSz(kSmw+HOp4MVqz+&~S0JyHb;gD8%*vjz(IL0qfkF zj7NN*o)m66g`PTDB8iOBclnA$XTGs*)E#YL5xZ8YiLdte>qSV3&@W$>d?)Pd zmxnMT3HF8Pm3B62*zOm$D~MC{;xP34_djJ$T?jZS*Mn)>)1-~@idF3qBdE)qGi$)B zv*#;DK`#FnwQ)i{`O4by`TYkGG{#83Y=1kB+PrVf?qu4*?m$obbt^o$8sc>&gT!72wG8E!*KtcCQtVj+$n3!t@4J*n0g!R90D+2z zA#75o{G=)4jC>CNINSlbZHMakt|ZnIaQ>uq6mHym(O8<2^OpZi2g&;$Y0NZSN|;`= zmUMbV%c-VUcI1z{L2Bpb8yOl}FYRy$W^tBfzj}|Gr*>-rY}$0Ph6x3>G9xCv>5bk) zksGyy6d#w)x7RD!Ja8vV84*;hmzhiVj6Jb_ja#h-R}#8Iyzbus zQrfQ%(ZjvSiIt-{)lJG?NX0*v+}@O74FHe1Z@w>(p&S}gPW4-epBL&NL6P7HKzm+M zTfvkO98Myx7S$c%L@c`!&;x2v06g*Nyj!ko&LF#~&tftPEU{_nGc!iax1_&OP0a9v zdy4lQeYu$zy*uq}J) zR!yoyxSrXZR$v;MwuG5H(H%J$EMZevxWeqS`AP^nbr_7p`3_Kx*$ESz=Dy7$olnB# zI+IuLpA^h)z<2Oyek;-v=C_-c%9Z9qMuKE0+aM$a~eJCXuR7s0!IjW9!K~epDd6t^P{E_ z;8;a%kJMk*sjy6<#f7D$)gn?eupeY7OZ3*pR||XO&hd;}FB-d0fMI!L3h?AhQBf+? z(*a*q^;xFO#C5T&cKdDWLO;m!v~AvD;=9~Dxs_K`Gx_l}hz1xB$K%{ZbK%e!&vvFH z@8L1@YsSL*!iRvU8@vxkZZ*S zC*|jfwU-4UgLYmsTdfIuKBmV9i4HY&P}VwKDdY_2Tq&kg6kJUm#I?LOoSV(o>`V`N-$AGPn;j4Zg96n zng4MUiwRhT`ACbIB}Mg9b@V^NoL+jAv~dA-?#Kn=*yco$7Q$&7i=Q{uAJ1R%KT3q7 zMz<5;Bm=+)v9jylc@7MWo4`KmYrQq0cdI5Vn{)_lNx#$hmOXb8p`5d)URFRd_@Iq*J(HW8(!J!Fl%nw=;nLPgtV%|W;XnV^(c|M z-%MTg`^tKx%FNnkENbzmtMK7lD}|U-x6^a*xAGZ4zt0j0;3|cc-ZU9kb=4F>>!Wo~ zt6}7Lxi$Lr3AIEq7Edug@im0DZl@r$ny^%YLmq8lHjn_h*iijQH3??e@|@;A){`Wl z26f+08e6?#*%;vIF=l;4Bh3+QzXr9+`xCQP+7VZO91|vZINuQ)#vbqx) z6WuNDk=UDyE~?w!25;e1OLF@2aM9uO>JK8Jp%r!SEQoR~>yZ$!==?J2k^ny+$+LFchQ{Y}I z_}Bs=bsZkP2cGz(I=`bpn)6r2ZNMTmVOM64&tVIf*nYznVKng%@edz9cr0mPUNpDX zT_4rUrMvHST%y1NJRBNnkVl??^{j7-keDfM3cq^~#31ahU|08XQ_kB;twp8*cEW~m zI52;F)-m?Vn)gK;GvG@1~kL6-R_V#80E z{p92$7*F&o9*o<1=@ZLFi`80BTr#kvX{040MHfDrf+*$lRO7(a16y*y9G;&i<&T+f zr3dm{xgJ`0d{;h#SyGx3i?CbDz9{ZFwzP51s^AQ8EE$C*P~aA3;28i!T0!6r_wRA0 zKCtd@52US}Kpx>Geew|vk!%qNd|+H*-NHDnnz;A3;R(U4`Yx%efw~mL{+Onk)K-Nq zysiE*(0QpNyyszr|Mprv{8q^kGvmg}52K>jBC!f2t0=bWOSkn$c7=(Y@COu6IIz_@ z?Tgt~qL&v8uZV7bn%o-9VF7OPC9r5FNZGG^xW{7RH9gXe`TmDT>d@L)UL5<$e3wW; z#yl*QVF0gdsq9RuC5>(+Bui7araCoB5{}nDnnrXa`|nN%Gl?%+uB8`6!7PZmqjurT&_N`lirAj^2H+p07Q8 zF=Da0x#+t){ecDk|Iqf9L2(6L*XSVxA_Ry52^tb4c!CBGB*7)PySojp2?2r=JZNwR zcee;`VQ`nhT?UuA&GWq9_sIL>R^6&QMNw2S4CkEg-g~XJSNC>W!^G#0bg$Vcsuwsw z4O)=c#%VbP@A2#OM3)R@9HZ{5*_K~eLB?}~#*;ArSQoYWOoiD1?&Q%$!wo3FU zzqmTARLvH?J&zZ(v9>1Je4lRGp6qm;1PBwCY?5c-3a32Sd}pf$4r@D`=mpXR`xxhP*#L(&t!;lCML_NscALBdgA2_+CuJ9g6VpZ zNaV>|{mLCsx#{Dv%hQz=#mIgSGB>0*;su+Og`i;U+U>6=$*_*V5KGZ8Jg zp-FE~3$HT`P0a=%hS-Fh0%{yHnB#X%7KoZ%w98F+qoXg7pKejOq5|7fpG0*Rp2G6r zg=GBefm3_V&1m1WNo(LF;5|Bg%4S?KcGYm8o~w+$Sj$;a7LcVOG44%zARNE{vkN+j zRuG(oH@I>-Eq~W>Ssb7x2cI}Qdz#}^hi}ToQCUghS;Lxfx$W#7FZQ@{%(8mg$%^l4 zHW97W?lZYl^KM(WXH^b3b#Bl9eTP4}8)(G(eX6K&@4bF;<58b?UPHqIkaxNS9Tw$8 zL&*<@;%U*Y=QfERHw+M&$KEL4osprDWpZnd8gWZtVFa&4-(G4ch1X^qmrHYWLE+R< z-Sh0ohL4$n;rz66lq0(pG_~B~&1$|nWU2LK(K#752z&Y+Jv7?vD)$eN4L98PwyEmB zk!hK>U4Kkb>uaD=h`#vii(gQ<=QL^Me0j*lv8_ui{059}Gb#(f2nvt6PyYV?*Qr1t zS!1#`J^g^i+f{ORlGxNyJM%fuOu-d$I4%AfI&3JRu~2K1EX}<$?mG^h)NxB;k`g=x+@WPMWQnc) z2cSVOnaB1)&XR!2W93T~d2Q`)v-`bD-%%irdCv*mS`k!|8>qFDppd%K?fsvWm0hXE zh>`4o_B;;b+deV&Q=|NHJmbz1)%iYSK#Xet*nQjcB<&UU*!O@qwg1og0eho znVnw`pK;GB=yY2# zN-+LVI_Ztx!k;_WOlf-_0I{pXe%jH2t3Oe)Gp?gr*Ti9cJq!F62_h*`Fz`nn9v(Jd zZWTLN5fc-89pr#1i(`>@fL-;1>#=fU-YyG zk{k$jmq)x`?q9P*z@a;uQ@y>qqC0VjnDcUT$1DK388hhD9Cp7wkKcT*WdI~1!X><> zz24v>!8sp-%F61i_1M5iG~12+;G{4R8uDMVc%LqzVcS!#?PSgqXdg@xUP1zb%S98F zi?u#pKw8waw320V3`$i2G(={!DJsjLcYW!&rgnqx|2nZlV&juP6n?XP^2mwg^}?F> zB}2+r3b$1~3y~oTneVLLGZ#Q8YI_toJG~bWr|qFPRa5H7uMeicOo-V=;`SC1a%Y?4tC$+r<~RH2i&i7+B(Z((TPWI zg5^-6kc+EH$@QWC)Ae(x?X|7{wb{zR;bo=Au4!w1#mb?Ki}S%!Eb*&!JW@I zAeaFSK<0E4e!5jYzL`aY-u+FNhd)kQn45pzroz&2p5l$#5q$RLet0{+;KJA6;cxVN zL2C@>ty#2}Gx9e`?eV-XnVG>n2PD_~d~zCJ0M%kOgz5a(o67PyBX;1 z(MCy*BjXfaua;8WuVN*#Xk=3w!1D3CZ@*I+6cjVu*xZb)rcL&+FJi0#iwlSrUdWF& zAr#H$xD?Ho zk`nVmWc7&qPNQ>|c)Or2+7l-~kC+80Lrs?B#WwYaN#*_fqiER}Kb(AZKJ-vjzN8lj zAyBE3b?X)H-8JQe%}2&IMC3hMQoVKO5S8S!Nwm@X;kCGvEy>t|`nR+4+rR8cnPA%vG z`i6~MobrLp)Sv1Gx-3Tt-@ay}YN0@by4YV%EUHg5L>19L3t^mjVrH(@PETJZOSCNnUc1O8cwtW}hLsO-JA;CP zI-_Qa;qcDn@K*g7A|V~d$?EQlu#>~BW*kb7SFZbe?PTS~zN2kn{aeWT^RHUwv7n(;@e@wVNg&opr)qOs zt&4{44Np)#{GegOpD8=rgtN?&TToB{@QF5C0H^6N@5_J4(e-E_jLTd1R)^q&pHFc> zE0xOuQHn~vy$b-o4|is5gob}f@mwq2HT)j^+WghCUhUw(MTGv?RQgP#8~(U)BDcdb zqfV10xb6*W&5=?Apf^ecNlJ8g0V#vk|wH9n_8`J0H>wG3<#)&XJOJbBs1RV zOP~-nGe!AI(?@moq_DYzIcm_}boDwQ0YE^2OGfwvK@2)QNU<3H2q<$h$eP%kMQTla zD(wGErW#+sI$(e70~X8g0DKl^W`UL9m2JT4D8D33Oazd-*(&t^b61X!Y$X%?#uH8o zOl({?)ZW0U$#&z)4q%yF!`B;i!Ry&=pVPYIb%%OCrTA^sty5)Bja@pYSp*eCXuC@a9U%Mw6n7Vc_2IG zpBst_@6QfB8uRcx=bha`WNm=weGOra%EzVo$pp-nmI!9gukc3(bO+M?K&%J+@&rg( zvX;t)_CMpA_;g!b`cnBCOmr68L#on(vXeBnegu%Mu%qy$$qKdUeUtM;hpJKKJ$ z`6O?h!wNHP3Jf79O4fMDIjN`!!@I7C^rUcXWWXt=op%JaT~7rqoYdN#`#=o9%tKwUM}YRBJFZ_fTLykPNM;hw9gmPSo+*wS9ezHu+o4+ z;K=eFjZNA#SAOC;>{OlH_^6{J=+G8oHkc!w%IOGN#;?KLdN~6~-Nq;bNQs4AkfD3B zd{?Sh3g5&QfeJ5h8B*?V+348Z`ys14@GlCT*M<+fAU*s+GOO^npl5{vV+*JLq}SUQr;e)GEJBs(>2aW!vE=O0fHHym%*3!d&7 zJ)&ud^0vopb`Xf?JMCidV)ateeH!?FCXt=m`J%PF-wlNKgi681zrU>;_LgOqmYBj~ zdyTIJA@o_52HW=@HKsDqX(1-de3?0vHURxbJn|~u1uzuVEq{3=tFAE%n5vGZ`{6-9fAz8g zP~@)`>PUcQkz09~%yXC#+;n^8^zY>!$Kw}3c$6GFOW_5 ze@(eM;B(ue!)Kk}&Ai~YX}%m;k>#gG_n^z2Q}v`7wZI5GyVFSYAd6mFNJ!`dYJx$x zISALi-EGTl3%7N~-Nxr@kk(_8UVno796pH57tbOhJOpV9UWY)pbmr`Y#MVP~7JKUY}-dS#@gAM|8_YS7}}ZyBM)8cN6u<&lwgnWU=H(!z?I z&!2yRj-*H1yw9FoGBGg`+;c#fR=@|dDfnPtzFeSnRzbN-V`qcniJ^&!ikS%+*|cv! zxcq<)b6jFP5bAGzum)hDzx@B-bzbx;$5BT`9bjVabBMF22b*UI)a)xBxqFT_QvLE#{ zAe{73$W?4~baqyb-1s_-r%?xu!Rx$3O;92!*`4Rsdc$tswKf+4``2?4zMUrcB-SVP zrJ!IcV`-<~EOiFna14MghM75ctmPC`sw*tn&4*NfyZJ-9Mppc;T|--wRU-osI^+2- zik<25U^jSXf&6So$9PsueE_Z6WTgtrG0vw?;p757^a2AZo@YfG{rKb#wNozw@MrMr zkIxSCBO~RBLzg!<`MiE*;*s%U_*7N(eS6W)&+m4}{~?`VKWRcrN>dwt1EbQ-0Us@4$eSrP1_QUTLBRue3B1WNT|$<-X$;uFandvnn-4vyJ}x^|=CQ zysjrL7?Do083R1dYpm`^nh&3EjlmD@7pJZCrKI60$|(!Dp>Db=Vm#yFSsxqt{rdH5 z?(uO*S5NOXxDyP_TLY%-Jiet4qbWj|XQisCVkRIQmYZC{WBfDHpZ6#6oL;mZ^c3q1 zD=LvC@H%bGc%JNjgMQ?tFH{?8>>V%Er={t)_v^kIEt-4<>zEzxKolL73ip4_J>Ky) zWC}--FR^*jn8n4$ZcmjmUF;!`caYqqc*#F^cXyLmhueF4?m-iEl^RtZOJ!NXI?Y}j z_BC-I|4h3NjTKGTVZ1dr@A`ErIBdnt#N>X;n%yMlX&$^yF zP`7?VCGRoMj|(>_f6TV#$Gry4)`dxSrW;9~+_ycP!1V;3jBK6Ew20oF%XW8l1%-tX zPOR>D3%r`UD1DSe@c1$GL!)||3SvBeByIA_xF<<^!pO+TVWp?eaf7BZt{utmy1&>N zMSHN!LdxZJ0~%t>8!cC1K_BSfoexUp77ZhvsCNc!yLcdIfm!*>V)>?yChO`NTTlGt~1EweI(YgoAVbI?Scw>FNs8|QV)7i~pN)-20gDWN! zvi|hcYJ7Wh;U3i1hMV1=v{kQ{9353tQxhSg>w{j8VaVO$BWFSNCCA6d``{3TZjfNU z)u?v*jG+q(u<+HL)*qmvUjN>fUyCrf(s1wBXtB;Wj`-oBA#D7k{llej!!C9T`ZE%b z*QRTT)}hi5t*zf4%!99;nI^4TbKGQ^TRtGI&*I}mRXnr;*Ey3RLc!~nS%xoV-5%sA z@<(1l1*~fNf8HAFY3qT>kTzZ-naAswn=_3HIsK7u&wZ{iFF4N#A1?~J?!RAE*q!8Cg{o2EKyISfRg9@A2~1dtKfFT?i8r6N5)^ zuplJ@0xK&raR%$>H=)y0SChUZ8Y>c#c}MXzSY2b|JJ534-vxI|P**pnFB$h9E_HHO z*F>E~wYB!U09ThIGvtIa45QDyM~~9&?9BUe*zW7-U*$see0ZM3PLJ-Ztt~Qayt8`n zes%Q;eO^#H3QK{2> z5y|-0I5ELf*6>0+JtL!73zyOgr&}bG^869M?cB3Rj~*dXw%L?koiUgWw&C{EEAJle zZoW9*ePekv!I7UE7fTo`&jXfqutx|fr~BAP77*g8X=zP{I`sDT>=u`L#?h~yNR78E zs36!7qOSPv7ps)6Yy9RFm7$@TeLOsMgaj0drmI#1c;jkn0r;fPtY}4P&Few5`f}%y z`1wd-VlZJJQOK1%Xf*GzMeN;&TAdD-5(L-9Qk-UpT$&5UA?kVLzUkNE^8sfis>&#% z4e*+16f>l|yy=}guB61lT_yjBMrvqa01F}y=Q17=yS_vg7UwgRSteLFz^$b%=?nL# zm;TS}Rz9qTT~bx$q^+IOP^YxM(;UF4RYeK`V@Dryzri2RfYtk^*xkGPvZ*|^4((1q z*Ar;_lPo5NQhpoP*Ht~#q<%mtbV_g#g)Mj^_U_%g2JO;M=C_z?J~KkLwNtqrU%i)m zx;i`4IGt9<%tki7zYrEKmW(?K+Z3xc26f>UwvlGDTOnY>369Il%b#Dk9&c~`!a5M3 zqPp#Af>UlwP37XZ9#t!7V6XnNiSTIr?vwMwO-OdXFmv+7#Oi zBT92?R8fr`tAn&&cV@A1L&V>;(I$vwNPk~`H&LwJ*g)YV+okmR^9>CiUQD0z^5EE5 zC$FXg9p%8lz@??7OqohMU+l=br^9UN>47Rmn#P`IFX1NQ;%lEg-9bloJcx#dhCC$& zTwKSScpgP+Gp5karp$NH=?%gDTR}(jU}Z4-*_SL_?$PIl0LhUhw$N^CzU5lDn9 zrWD9hr1E=8WH*+UmQIPvKPr>`?};LfT@n{JTA)@A8r{-1YV@b@12v7qb>EO%+ikH! z4ul~bkv{0Hf`UbtVrt_#ejYqufuypc0`lq8&4nvQG6Y_ATQc0Jrh~(W+FB*ifV(z* z1SeWNe*9Q-vM!m=(O;1k+sAskerKj!_T?E41j7d9k(pcgWCTf3Up5`$?H7igf?2JY zxtM}~`?;i}yrre3FN+^)ZB*x#U$jWZ_}EyHdbu?!zVx#SRVa9J)mvjmqnUSN%JU1- zh5b!*b!{*Hzy&VP6as(U**WcYbvq(Zh^Va&pf%(3ux+Lp(1HFAj=l)p+_XeQ>;)Ft zD5logwTCInTOW5VNs@2#^G~@S?QLo)YEqr=!eB6QaPjtK4pffirIpmvE_^d-?`~zK z9<+sSqzZb|Y|%(3b_vAS9WI=fuk@b;4n;n%hjuxT3~GBsP_|U^NHH235CF+=y54Dh zrgHGho&o%MkMhXH(1j>I1!(Ln@l@RKy$hRrz44Kg)nr9icQ@9j@V({U4XO&pz&Tu2 zPU~k0?5PO}>(znHc`%aED&H$|#wAO8K4lRaT#~%J+=!zz-}5YI@+JNxkB_|0mT~7| z=3;GY|Bh~jy{qc(K$8;_H$ZaCh<;)V1P)NF#e_seL?$Nm>Hm(r7ZSPxVdrpTgp}89 zv75-NBlBHAn1BG=qC2RvY>dI#Vt$;i&U+$Ehx3NNKA6eu)i1s1Pd#^DTd2%eY4kkz zx3nA@>#BSHlEZQ0p8P#-K|vdI6FXrK31T7`yZ6#2DA#*bR)b+Wi6 zMMtCdd~J)WBKG(9OG@f9nHmdFJIh=3)tP#FluY`^t3NRje}{%9=IYm+OvfU<;~dwg zzCUJ;{ZqEU77`Sc2crT22E0h|0=z9-+g-TWD-Z_ncBURQ=KgRn>HpZh#H?EKRXtXf zkb_&9aG4<01EAFIxYr0?hc~4ALY>a{_MapFD?_VJNBe*_^)zWZqN1Yv^11I4IjuUF zp|62~-y{i4CCt^=YhGu-_4C}FfC0kK0tdoF%8A)WVK8^9h2WjLHfa2#^a4 z>>QOH;gpbW8!R=q<2C}cQ${TpPKt3%j95)12|6pA@(ss>CaUCPo7La`cw)3nPa8Du z(9lG|(+l{_5Q~)-%25WLdEWtZ$!HM$)8sD7Lm}W{+8(BrS+c%biY!uQEM{2U{645r zb@C0-1ptC@hE|2H7fg{Vyj|hr#}kunJ_95+G&i0zOa3Xcp>VQheQgcnt&@|Js%rcy z^?SOP^mmJ>dwvXtu9H-oj@u{n!}8X5fIwl$P2eXH(|3{}G|e02pPilUf1>oop{%?q zF8a2WVkX;7(cG*;D|3Aw*qCj9i63IPFOK-H(gu%Z_fzLk;4D1U$T6ODikC*xS>a)oXK~JoN>W3VM2)QnrT@>NT0UUOm%o z>ruNN|M0mt?#E8Fq1``!vKkN8)w_Q+rowRpz`_sbQ(01wzOlIsvDtGwHEds354K^B1aWH;Jjuth#)dK3J z)V4RYI3~ei*&*AGrk~`!G|K(8UlwJ@^M3@2YYP6CqQLq_%QJ3lMpdFZ&h%!AhYz2> z_~pk5K$YgbyQG|!Hz8F{bE;W}bV2g)b_RO-OzktNXORpqoYm!&sgd(tEGbsFq9`scX-ASOlNeImL+m0T#H-4vqs#=9sa zO(E!cax*(i`STMAJ=WF8^yXpU_pWmv#T$q(edhnrn|e7?ett^7bco~RY5oU4KQs%- z>_37oa#R+rCFJGh-7dxk6li7HEw;z13N`Xa3ds^UatMpDAU3vg+@{2yo}Mb3Nr@^` z^F5;ck-P=@8!+ssAc4HxJh2_AT&2Zi@AD&R`BVeZaMb#ij6?>DRu$j#=Sc-zjKSul z8j7kX918loYy%f(|ZpWK4`_AGc#b9YNYt{2qnwl9I73MS8g(8sc;XFK*;5prZNJunD zNG89R8w38|RrIA_2{7> z4#Yp5O3x=)LqVf{7H@xWhMUIpbnWT!?_WUYxB-E({R<3TfNnuHfS%DTf5-}+mTw|> zoSBt%r@6R5O8NPQkyKAicU+!a&ym6h^UB)qwSSpqTu*&(=?Rl)saML$DaXP%0S~`& zg%7bhse!?IzM-(&`lCOFfba_SP4Ef5kOGgn+LCNRUZIC(^*RYjl!!!-lu|->cef6o z_YuHa)AiTTx~;9sAr35|!{_7G&?}?7{*BS%ZGZpR*x2Qz`F9y~1oTTZS?xOVI#tW!W&8|GnHNG_J0$?lbpaxnV_NbKOIVfs$kPwNu49twYoN z-dBuwp!#}uOO%$5wziCtJ>J~{;WHc6F@Ckr8cLB7yD98L9PH~)hr7kludfgYL?E*L z&SA6T3V94SlllH1JB~ZnrK%Ti(rf*)@`8d^i!r{8ahM4c2c}-7-e?&a;mq9oVE;!( z=|k(Ep?eVcd%!W_Ti`M8%rts}V4X6}ODmJW>e(i>PwKe(cg@=LB}7F{`wg;12JMzm zNzoiAuWDiY-hpOb_rV|!1tiu^1q8g%SB--n z#?aeTugiDB!gsMGZi}fz8Dxn`NKxV9N=swD``uIgFv=f8x2I-LV9?~oTNte3_-h(R zbDc*gE3mNrOVC>F&;B@y=`%N{E032T@BR(Uu1MW=*zq zLZtufvXvq#uJsHCA`W#hQP>mCOlgLdi~#)LltpB~mw^nCx+^*E6{Ar9itOloHdPXZN;gaiqz*|KrjuGyx>dMw#}CHsPxw7iMG=f7He zpUiowyAeaHBu^@=k$D9LL$#7B(22G@I8uEomZLD`%dy}?{>H|3v7LgvbbZEmZHASl z^b0j0DfwdKf14RElS3YD@kSic#{RiCG%KCarp(M-D5fFNwcIsq!J<{`w$?Q~VDM-5 zF>_qK&CWJJjgfJ^WXWPk2UbC7XL~$g=v7>%cvLnOU~Oh*a416cn;1WA^%YGN-DPM9 zF+%BnHc4IZQ?|GWMBy>tJT)pNV>&v)=$JG{IkXSn8TWkoo#LsPaA6 z0AO=vCcpj6(SrFVHlODiI}3}7lC;vUEfdq0NN6D=35W5kcmOy@i~I^SOACr~+;wD+ zC#$W$34oNvl&o==Tq%@$b1Xf})&@eJwTT>t(Bp|q$Vfc@sZJgabd^pZYTmun!fOJ) z=u5x@gy{<93CEXgTXz=hYl&{0dKSD=V&yY0$eXZLi+*t z3*XeBY2hiuY^sJ8T3ZYA{WzrO$sC4REa@Olo`~?$#sNzb~lgIC?z>Gj` z2)^wq1u_J``_c2Q?zqnky*Vc3-LZ_?Vs%ePxvosMJ=QLl91Ez{o~|i=bOof~b2mMX z+20>&<&qYNu%NKK)2t=Q|3w>TpMwMFe*WXdWj&QU4Ded~_wU~(%0Gj+FvWi!!MTCX zgVEE-%~Za7g_oI`xi~Q*y*maHyk?^la^f(t{&<^UikyOi z%X{0BzItrxm$0o3$MSCc>tBAs7`i2KM0cpl$BpQ81i<%B@j&Qp#(+rZLz%K`F`eYT z%j=V_C5BNR*e2CcMsDO=^Z}LqRHgedhVRv~%{@My1GdqrIu3@;Pz7bsV%@B~&fkB` zdW4hHpe^WBcj;^QlJ(J~*hJf5s3(_IY;*vR<=Aa{FV~}~?-D9RSJFKR{OuX~GuYVJ zR5*)icU>=o(2_WD%7s}yEylfxQEdk+{o+W#%z<{2$#1ui31rJa{=c2@*84189LU_S zx7St8FE?Ee2*r3tTK?Le_f2P_U_d15OP-{{mHi92U3)kgwUvg3h8o_t?o$0dmxis$ z>WHe?*rRnv5~|*cB?c9G2GF)k(a^A$g-P^(i8GhX7`oSFFYtmIo!H>kTSv&0437iJ z0c>`ox+4q7=BKNxX5D+M;X;KE@{4Vt8;v2&x|vuMX%gIyCGu#Q^!mG7neUT&kc9Ucwjeuz|aG8r?UMR%L5V; zk|!rqST{AF;rx^bh58b`7k#&G$`SZl^k1Iq4GauqZf$Sdovhxm+DaYK%^qBH zSYcAF(x<-j7TCJBrW^CCM3vqFjBUL&k^08bX+PS`u=_YNavCUR+>0N2%0Y<;`lzTl zRb~|s5I|dL%$b&(TcbO_pj>#;2?wfr=1=5amDRM=1X%O|;p9ZUOIy>E20K@ni^unY zpS|8``-ju16^ejwq+C`f&7iQLu>brPCp;JO-v1-_(=j7!veZhl(l;7V^#Tw)8eI<( zDyFCT!^i~T85s;?LMkfGD@Bb!&i>W_V$fn7OLObay}Z}|l~k02TP3|UfKc2XmU_?o z0$8h*b!LB9)jPEvp60ux2ztjdN(V)R1O*3){cal}g!ByzYBh_1EEy`o>$$A?*Ux8a zN|TN40BnN1P55XVloX7YM$UTVj|#Jm7rP76J~wDf`o z$8)^mw0D4;WEB++0Q5{y*W|Qi?&X!DDNqMif)h|X+@8WHKfdzNuCB5kpBkEJGE;De zN=htYhi8RfzI=%%>Mt)V0}MBTv$Fbxo2g!zNv8^i{`78<5R_5)!oIpu0DpOF`A+LB z87V1g0%(dG1d3CnRA;a_X3JueeUE6L_7yq@<)gJPKl~s;+5nFB!ZKFnYoe3lUM#t=Vdmg-kkj;?SSE*87uX zrhABZB9;ZD!Nw?;llcImrzd2cl;CNp&8+PB1;Ues?$o(6-GEl+TN#GZb7UzfWKK*@ zO0GquqzD*SaG4crHJYP;zRQdk3Xbi=r|@fUZEyY4(9z#*F;ruh$XPTLj;~rEo9HpB z;@G3Fb-OP5JKffZ%}m2uUw>u}(GK=bCP=BiHk2au{ z&40-N7+O%DI7t46oxcE4_)x5o#Vw_e0M$}qz3%j1zfKR(rwKp9qix>+`+By`WNG4` z3iWC`tLZkS67*!XjWVMK{ou-T-vIpW9Fxo_$FBgc`}(qUZ1ix%B_<*{PIgXl2t!ch zxDc?BRaI4`S2|tSK6LJ;ymnA7#PdH~L;75Kg78xZ+mgUG7|jgSilRyP{r=jMzS4Nt z=7Xf92Kd@6Y7Zx8R;zf$|Dq8QrP)ZVZ>`7OYr-Qw%)!9{)WJ+sF2aqd>f_sKsdN`? z?#ml0EGvDcJc)L7RnEw=IC$p!N}UFmnP!{REnesG zNnQv_{#jD({LD%SWC-8`rnxiDkyDgL3Gd=*_O091uKh31f$yaV^$+jRDB7?>ooX5e zHU(P)d;7B4>`;K=>FF5@@;{}`_Ex9=LLjgzkZ|)hc?wiw-?|UDMr7{RapXg2 zZqKE&v(o|neQRximxCz=wrp1m?d}`(bm(bw*=VoWzWJQ9WM2znpaP9_4Qwy{F98r| zz)B(+Yrc-XpVQ!AAKz|!I5yrYiQ7t;?h2SR7?*sQ7yTwCMsi46+VM(D#7x_-saoE|`;;ff~?Idy)=SZd07?r0+uNFs( z*8?Z;8t4k#-gOmXXmmNmM!nSis`w{RFi#>Po}D@E@7s?SX>*vqoyEWN%W#QGhp1pf_-OXoJHUr>vV{2{owh;sZL_-xtIXZPYxl*tbpO1WJdb0ZSPel&LL!i#R zWT4B>gDFdLTjAc@t4wi9iQ+2eZq@_WAJ3PdVzqfpTyytkHrDtGZVQ!pPZ1XmU z&AzOhl@+1WhzX^f&$y?*Jh^r^37hp2t;H>MFlXg$x;Y*sD79ltNGN}=;>*I z3_*p+7KDoj2C}xj80KJ4;IOdPHT{uMsGzBc;$UN&zdQ%xDTg~(GaZ4Ekw!fYXH^xb zoquc9r}j$E!vg-}e^IZ<40}8hXz_{JWq7O)&_(a;%+fY^>x^Owc^80~WME+Mp*k)u zPSEvvL-s$kLuES8a*)@4Jk#0CqVDYKdECVo%L2**RA-5ai4Mox(-ZX{dqEW!yhaaS zo-81d3&>U~YIF|RRSBGffzo}kAI!}qz!wJ|x-~` z|NRnx-y%TH@Fl!!={+B_ija^Tb50&EP%eRnBb)Lb_)^MBa!ZN=d_TrqzIITRRgsmJ z=4U(DC^wM>v)PpkC8HJopO-0|+qYYz2}KxW#$@NgY2zLNH&hVJUsJ~KKmRQlDKyp+ zDJRtN8gZ7&fD!t?e(gu%4Q!@w%b&L;HzS+McxKSxZ=dexC1qOx2r z7zcn3D5AIUH<5cTj{HezX0X7x(LDt9`6as=uCA`?>cqjVZbzs)0lz6H<1Xq$giN58 z%6!U!0lD;BeF^-c8XC7C0dE-rkJTTh0h~ov?nj$gkZkJvfG?VI%F60BrgW9ZsI@4t z79=T-rd)A`hKI$^K>-p3^>k05$sy{i103>|&+6P@*uL$^rRC+O@Ni(;Q16K82Iv;S zvX?{5@3i?DE127|Do3<FkuAa*x&Z?)cSVLa zZGck3MPw%p&S;f2zZ+_C&qWlE;^N}eK?@i2-}TdoXZIkq6W=+?YV7Y#i20U|RKHm9 z#{7jd957>WK(<-+m*X9kqLX8=0}ja1BfVvE2GEbewR8+!e$Pwss2qeT`#%(ug%dlF z4|4l~Mdl2(fe(d}Xn=k(c~c_B4S`5fxZTXot>m*o1A0CS&#iSZ0q5M zb_M1=_4U)`_%p5_{j2JVXQBgZs4PiBr2||s07zFZ*qk$uU-De$>};JM|K_xsdgj*6 z$VgJCQ9T3yAo(*CSWn-l#A zQWh?^O;!7o(iZ`p^OZc~h9(v!lKyh4CsyUN*>%L&|FKSmRBsTc5to)#2{`~Cwjkf`KWGvV^))Pb8wi%6$zq7h2g|CYNY)^p9N6G{H zBGrm{>j)IwZnQ*Zb%VffVRHfSaBv)BVS^6t(0p`8ZeHHE*TAw}T+{|mdHA`3Md%cOgv@iGz7YL+fWoQ2k!FD4ksc6Gxa*8z@+bni}Gyl!QT;l6{TF9I(Hc&xH2|m0bj~R$DPB}rGF)i`C0KD^ zbYB#>+wCky$sypd4EEY*1=CARQk+t}sFlDdcFCw~$jr&fDf1Gz=jzj{gYV+h!#042 zK@`$`6|Ygfv$--}e{$}8{jfGh&gGj4YMhuB#bFh|d(wP`+0)yL@gvk?64)&Illi0K z;@nZIdZ}w{g;thshfesl_G(SP2^Yt6Y{WWZ&h?JXcOELqAJ|OLCkt)sY*uoO|Hreu zZ2k@H+L-8~n;iLXQf&@*$7g0zrG@@)?;Z@s9^U?Km_+ivA=4 zN7TukNCrN=1*&~x%o}~O$(&k3LVCchQb`OdKQZ~EHOK2h_F)^EU=`-Wuxq_bM-hhL z?5%(0Ow=vV1au1&Mdan=uzV1aAnHr3-t$*NwsBTDk#e5*CXNC>NqVa>mOZ3N(5i)QM5aNvWxctEmw{plT;6A>nt# z`a~+SOr#GR0&59+8!9a-IturTDk_=*PYQ`d=IT<0ida}04Hu|6fGw~I`%7DiT;Nj7 zS^H)m1@vmy8xMzST(GN2;&iXB=GN4_iG+`*x5{G70cHs*2AB|Gu#vvLaLynZX#(8B z^s(2u$f2Fx@-3f?^z=8RVPnB{wUt~2CMO)o{M8~1U#byr&eyN^_K77X;(S?{HJ>#J zYyn3K*byx4r2(}7?fME6TOOw@QllTaw|{W(9)IZXkfP#8Mg}IjtE*&`qCWjMtvqvc zLrFb3%7EUYwnvo3l9Fe(;8HdwDtRFfhL<(YJ4Kn+!tZyyl#OYkIu(_aFn!X~pO>|; z9e(}B#7^AXH8Jrs?J49TCiXQr7}&MFw%>E}@VtH^#rlgQUpZox#SYb>hu<$il|5Mj z=f49vuMhcchK7b_fr$664(6!PiJJZ8VuTDAZ0uk>$no-d53mu#?R5r{$U#t=BFnkYRA*ucP0 zf9RsL#M59uyME`)jF7A>8qAzMJG;;2lF>4W#ud|(Al2P?a^Xc@g^7M3l z;FsB+YLY&*Gi%j>R4p%8a&tRI3MXIE(;ux3?)?3UDeMkBtJ6~xm{7p)K-UMz&HcgQ zcG>v%&%c7IRlU(82T?o>j6h~lHySzP%SNI+kZp%RTcx82yFXAr+dVx! zI*Jd4k@8g7A>Hn~{4ws0w>Gj`+TvG#rx3;S4&3s>2ORpsAkAVgengGy0XE$}<4b1F z*qc6`Y*-MJ#(vxtZj*~pt^*8)iepI?-!lh92uwqaXJAtVxJ{_#hmiNE2o>%U;d{;0p7JV}w1I)yI~I&PcSr+=3jN!xZN~KLs(1-Qe#jop`fPSwbL+{9(wum<=7-|68>$_KQ=MMgpGsa zQGKJFK|Y&F{~Zx;3~=j?2g`+EyBAmtM0bT(*VYQv8%HX)!f$l!lbMhKdouN&Ak@*> zhvC!FaeKcEl*s72x)f!(xUMF>FIMM~?H4CSb0+$_reavTz#2s52E5B}5l>#dmC6Qw zi^~o%asii!~e@Kv*PVa5`zTu@XwSn-+DM-~;>S ziinZIi3kzJ*2buBtJ68A=4N*tQ@Gua*i)Z=#`pjnj)Rlla}mIuu|}l-_cRp|$D;H8 znD_~CMB8Zb(@eQ$p?3(ELkTI$J(XhZNRi+E7uNFxkIH})oqoBbKS|ikq9cF_g^FlY z8aL{`Rf#n2P0*<$U6>yi&1D0!<5X7nQn!lu-@1uRkHIdN@|*p-=FhJt<$?NuSYl*k z3{T}7&azNR1LdOC^>tv^?3uuVw;lhRrYkZJckX>0q+?c*8$`zPMK6y(x5>o9uw= zT#Q^Bz(7wA;}dID+T(71?<01;3pK%c17!*PiIc+vZvNAq?R#948T?JcT zw3|pT1ep7JetCes6(Ibq6BB!$AK&spg`f^h`i)IYxZkj!yYJoy@%`h8`>t+jU|}Us z-vp-}!Z5E_-}9yq(Ave7Una}gEU&)T4}xL;&T=GyKs<4K@aXq^v~23@M>i3lg??N) zfYNR7p*Tt%|JSyOiOI&=ni{A16v(agA)+7U=70Qv@MY(R!6aoAM_QYjX4%lA`p8su z?0#ecPeMNf|Ce9Ow#?YTG6S4{z|?-7ZzzDPS5I#KAV>E7dG}sm?t0c8!1hc`u7vZz zv?@dpeAsDvOh_1e?MSoMY*yF2Xmp5UR(xr<9}SP935&D0)NMHhB~9p=`q^YpPk|DH z3W8>>0hCCFSn$Pql*A-HJRbk+ld75&xlaw)o22AqZpt@5qZcDN5;KwvxSRtDIj^vMGG7wt^wuOg57vy@gut*S6c)I!}}8qU13n zqv4tzgc8!Y(d%+u|1VZ{Ws*J_h0~#B3K8qva;ml9smJGO#F0VZf=eP_M~%k zXLu61K79XayQZe5RME>n)4!w6zPC=-U))^*mg$hETwxVJAhOYHKDu8LoTp=lTBR(-saKv%^SuT9cbUqHj?TxbJN1E`hIua;WE0q3 zfgZ}i?ds(9}yQ94e7sx?Z4LgeTccy0=OpFws6Y_5VAwkV#jgw%`JfQ_)A^Bj1QR`D$kz}EqRyf?Epx5aSC;PU1$B$;{czNVGcf0Rpg?(b zSOlCx3_Nb2Fm>ya{|VFm>OfQ_^D23E|nY*PGPX;TW|#$elJu+ z1t>�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Μ)R6x@fjjjGGEJkC_{UyOoNY*_T=I;9FIm?v+|wF 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 Sy0*K7?x ![The Runner touchscreen layout with large tap targets](../assets/screenshots/runner.png) -
Runner mode: large, finger-friendly controls and a four-tab bottom bar, built for a bench touchscreen.
+
Runner mode: large, finger-friendly controls under a header with a ⚙ menu and three tabs, built for a bench touchscreen.
## In this section -- **[The Runner Screen](runner.md)** — the four tabs (Setup, Run, Manual, - Camera), the run banner, the auto-generated manual device controls, the - Functions buttons, and how to run an experiment from the touchscreen. +- **[The Runner Screen](runner.md)** — the header and ⚙ menu, the Experiment, + Dashboard (graph, panels, manual device controls, Functions), and Run tabs, + and how to run an experiment from the touchscreen. - **[Raspberry Pi Kiosk](pi-kiosk.md)** — the prebuilt Raspberry Pi SD-card image, the systemd service that auto-launches GLIDER in Runner mode on boot, and the update script that moves a kiosk to a newer release. @@ -45,9 +46,10 @@ glider --builder # Force Desktop (Builder / editor) mode !!! note "One mode per launch" GLIDER runs in a single mode for the life of the process. You can switch - from Runner to Desktop while it is running (**⚙ → Switch to Desktop** on the - Setup tab), but you cannot switch back to Runner without restarting the app. - On a dedicated Pi kiosk you normally never leave Runner mode. + from Runner to Desktop while it is running (**⚙ → Switch to Desktop**; it is + refused while a run is live), but you cannot switch back to Runner without + restarting the app. On a dedicated Pi kiosk you normally never leave Runner + mode. ## Where to start diff --git a/docs-site/runner/pi-kiosk.md b/docs-site/runner/pi-kiosk.md index 444d929b..a06da726 100644 --- a/docs-site/runner/pi-kiosk.md +++ b/docs-site/runner/pi-kiosk.md @@ -75,8 +75,8 @@ easier. The `--runner` flag forces the [touchscreen layout](runner.md) regardless of the detected screen size. - If GLIDER **crashes**, systemd restarts it after a few seconds. If GLIDER - **exits cleanly** (you quit it on purpose), systemd leaves it closed rather - than fighting you. + **exits cleanly** (you quit it on purpose, for example with **⚙ › Exit** on + the touchscreen), systemd leaves it closed rather than fighting you. - All of GLIDER's log output goes to the system journal. ### Handy commands diff --git a/docs-site/runner/runner.md b/docs-site/runner/runner.md index e7ed8edc..1685270e 100644 --- a/docs-site/runner/runner.md +++ b/docs-site/runner/runner.md @@ -1,154 +1,147 @@ # The Runner Screen -The Runner screen is GLIDER's touchscreen layout. Everything happens on four -tabs behind a persistent bottom tab bar: **Setup**, **Run**, **Manual**, and -**Camera**. This page walks through each tab, the controls you'll find there, -and how to run an experiment start to finish without a keyboard. - -## Getting oriented - -Two elements are always available no matter which tab you're on: - -- **The bottom tab bar.** A row of four large buttons — **Setup**, **Run**, - **Manual**, **Camera** — pinned to the bottom of the screen. Tap one to switch - tabs. The buttons are tall (sized for fingertips, not a mouse), and the - current tab stays highlighted. -- **The run banner.** A slim strip that appears at the top of the screen while - an experiment is live (running or paused) and you are on a tab *other than* - Run. It carries the essentials with you so you never lose them: the elapsed - **timer**, the current **state**, a **● REC** indicator when recording, and a - **■ STOP** button. On the Run tab itself the banner is hidden, because the Run - tab already shows all of that full-size. - -!!! note "Where is the emergency stop?" - There is deliberately no separate emergency-stop button in Runner mode. Use - **STOP** (on the Run tab or in the run banner) to end a run. A full - emergency-stop is a Desktop-mode menu action. - -## The Setup tab - -Setup is the landing tab and your home base between runs. It's a single -scrollable page with four parts, top to bottom. +The Runner screen is GLIDER's touchscreen layout. A header runs across the top +of every page, and under it sit three tabs: **Experiment**, **Dashboard**, and +**Run**. This page walks through the header and each tab, and how to run an +experiment start to finish without a keyboard.
- ![The Runner Setup tab: status line, large file buttons, and a hardware roster](../assets/screenshots/runner.png) -
The Setup tab — status line up top, finger-sized file and connect buttons, the hardware roster, and the persistent bottom tab bar.
+ ![The Runner Run tab: header with experiment name, board dots and the gear menu, the Experiment / Dashboard / Run tabs, run details, the clock, device cards and the Start run bar](../assets/screenshots/runner.png) +
The Run tab before a run — the header and tabs on top, the run details, the clock and a card per device, and the Start run bar at the bottom with a hint about what's still missing.
-### Status line +## The header -At the top, two short status readouts tell you whether you're ready to run: +The header is on every page, so you never lose the essentials. It has two rows. -- **Board:** either `✓` with the connected board's description, or - `✗ not connected`. -- **Experiment:** either `✓` with the experiment's name, or `✗ none loaded`. +**Row one** carries, left to right: -These refresh automatically about twice a second, so if you plug in a board or -open a file the status updates on its own — you don't have to reload anything. +- The **experiment name**, with a small dot beside it while there is unsaved + work. +- **One dot per board**, labelled with the board's ID — green when the board + is connected. +- The **run-state pill**. It is hidden while GLIDER is idle and appears once a + run is under way: **Running 03:12** while a run is live, **Recording 12:41** + when data is being recorded, **Running Paused** while paused, and **Error** + if the session errors. **Tap the pill to jump to the Run tab** from anywhere. +- The **⚙ menu** (below). -### The housekeeping menu (⚙) +**Row two** is the tabs: **Experiment**, **Dashboard**, **Run**. There is no +**Analyze** tab on the Pi — the analysis tools are a desktop feature (see +[Switch to Desktop](#switch-to-desktop)). -The gear button in the top-right corner opens a small menu with three items: +### The ⚙ menu + +The gear opens a menu with everything that used to be a button on a file page: | Item | What it does | | --- | --- | +| **New** | Start a fresh, empty experiment. | +| **Open…** | Load an existing experiment file. | +| **Save** | Save the current experiment. | +| **Save As…** | Save the current experiment under a new name. | +| **Connect boards…** | Open the board/port settings to connect your hardware. | | **Help** | Opens the in-app help. | -| **Switch to Desktop** | Leaves Runner mode for the full Desktop editor (see the caution below). | +| **Switch to Desktop** | Opens the desktop editor (see [below](#switch-to-desktop)). | | **Exit** | Closes GLIDER. | -!!! warning "Switching to Desktop is one-way" - Once you switch to Desktop mode you cannot return to the Runner screen - without restarting GLIDER. On a dedicated bench kiosk you normally leave this - alone. See [Runner Mode](index.md#how-glider-chooses-runner-mode) for why. +**New** and **Open…** are refused while an experiment is running or paused — +they would clear the flow and hardware out from under the run. GLIDER says so on +the notice bar instead. -### Experiment file actions +### The notice bar -Below the experiment name is a grid of large buttons for working with -experiment files, plus a full-width connect button: +Warnings and errors appear in a bar just under the header — a board that lost +its connection mid-run, a connection or start error, a New, Open, or Switch to +Desktop that was refused because a run is live. The bar is deliberately sticky: +a touchscreen operator never sees a small status-bar message that vanishes, so +**a notice stays until you tap it**. -| Button | What it does | -| --- | --- | -| **New** | Start a fresh, empty experiment. | -| **Open** | Load an existing experiment file. | -| **Save** | Save the current experiment. | -| **Save As** | Save the current experiment under a new name. | -| **Connect / Ports** | Open the board/port settings to connect your hardware. | - -An experiment is the node graph you built in Desktop mode — see -[Building Experiments](../building/index.md). In Runner mode you load and run -these files; you don't edit the graph. - -### Hardware panel +A **critical** notice — the "UNSAFE STATE" warning after a run ends in an error, +for example, which means a heater, relay, PWM output, valve, or motor may still +be active — is never replaced by a lesser one. A "Saved" or an informational +message won't bury it; tap it once you've read it. -The bottom of the Setup tab embeds the hardware panel, where connected devices -appear. Use **Connect / Ports** to open the port settings if your board isn't -showing up. For what the device types mean and how to wire them, see -[Devices & Hardware](../building/devices.md). +!!! note "Where is the emergency stop?" + There is deliberately no separate emergency-stop button in Runner mode. Use + **Stop** on the Run tab (tap the run pill to get there from another tab). A + full emergency-stop is a Desktop-mode menu action. -## The Run tab +## The Experiment tab -The Run tab is where you start, watch, and stop a recorded experiment. +The Experiment tab is where you say what this experiment *is*. A row of +**section chips** runs across the top — **Metadata**, **Mice**, **Zones**, **Lab +Vocabulary**, and **Plugins** — and tapping one shows that section at full width +below. Each section scrolls by dragging with a finger. -### Header +These are the same editors the desktop's **Experiment** tab uses, so what you +change here is saved in the same `.glider` file. See +[Subjects & Lab Vocabulary](../building/subjects.md) for the mice and the +vocabulary lists and [Plugins](../building/plugins.md) for the plugin catalogue; +zones are covered under [Camera & Recording](../camera-behavior/camera.md#zones-regions-of-interest). -Across the top: +## The Dashboard tab -- **Experiment name** on the left (read-only here — set it when you build or - save the experiment). -- A large **elapsed timer** in the middle, counting up while a run is live. -- A **state pill** on the right showing the current state (for example `IDLE`, - `RUNNING`, `PAUSED`). +The Dashboard tab is the experiment's node graph with the panels you need +beside it. The graph takes the top half of the screen; under it, a **panel +tabs** row switches between **Nodes**, **Properties**, **Hardware**, +**Camera**, and **Control**. The **grip** between the two halves is draggable — +pull it up for more panel, down for more graph. -A **● REC** indicator appears while data is being recorded. +
+ ![The Runner Dashboard tab: the node graph on top, a View only pill and zoom buttons over it, and the Nodes panel under the grip](../assets/screenshots/runner-dashboard.png) +
The Dashboard tab — the graph on the top half (locked, with its zoom and fit buttons), the grip, and the panel tabs with the Nodes list underneath.
+
-### Device status cards +### Viewing and editing the graph -The middle of the tab is a scrollable list of device cards, one per connected -device. Each card shows the device's name, its type, a live state readout, and a -**Ready** marker once the device is initialized. The readout depends on the -device: +The graph starts **locked**: a pill in its top-left corner reads **View only · +tap to edit**. While it is locked you can: -- A digital output reads **HIGH** or **LOW**. -- An analog input shows its raw reading and the equivalent voltage (for example - `512` / `2.50V`). -- Other devices show their current value. +- **Pan** with a one-finger drag over empty canvas. +- **Zoom** with the **+** and **−** buttons, and **fit** the whole flow to the + view with the third button. -If nothing is connected you'll see "Connect hardware to see devices." +You cannot move, connect, delete, or add nodes. **Properties** is read-only and +**Hardware** hides its add, edit, and remove controls, so an accidental tap +can't change the experiment. -### START and STOP +Tap the pill to switch it to **Editing · tap to lock**, which unlocks all of +that. Tap it again to lock the graph. -At the bottom are two big buttons: +!!! note "A running experiment locks the graph" + While an experiment is running or paused the lock is forced on and the pill + can't be changed. The graph stays locked after the run ends; unlock it when + you want to edit again. -- **▶ START** begins the experiment and starts recording. It is **enabled only - when you're ready** — that means a board is connected *and* a runnable - experiment is loaded. Until both are true, START is greyed out and a - **"Not ready — check Setup"** hint appears; go back to the Setup tab to see - which piece is missing. -- **■ STOP** ends the run at any time. +### The panel tabs -While a run is live the header timer hands off to the run banner, so the timer -keeps following you if you switch to another tab. +| Tab | What it holds | +| --- | --- | +| **Nodes** | The node library. **Tap a node to drop it in the middle of the graph.** Dragging one onto a precise spot needs a press-and-hold first. Both are refused while the graph is locked. | +| **Properties** | The selected node's settings. **Tapping a node on the graph while editing opens this tab.** Read-only while the graph is locked. | +| **Hardware** | Your boards and devices. **Connect boards…** at the top opens the board/port settings. While the graph is unlocked, **+ Board** and **+ Device** appear and boards and devices can be edited or removed; while it is locked those are off (connecting and disconnecting still work). For what the device types mean and how to wire them, see [Devices & Hardware](../building/devices.md). | +| **Camera** | The live camera view and recording controls. For how the camera, recording, and behavior tracking work, see [Camera & Recording](../camera-behavior/camera.md) and [Tracking](../camera-behavior/tracking.md). | +| **Control** | Drive hardware by hand, and run your saved functions. See [below](#the-control-tab). | -!!! tip "Stopping loads a clean time" - When a flow finishes, the timer snaps to the experiment's true logical - duration rather than the last timer tick, so repeated runs of the same flow - report a consistent elapsed time. +Every panel scrolls by dragging with a finger; the thin bar at the edge is only +an indicator. -## The Manual tab +### The Control tab -The Manual tab lets you drive hardware by hand — turn an output on, nudge a -value, take a reading, or run a saved function — without starting a recorded -experiment. The controls here are **generated automatically** from whatever -devices are connected: GLIDER inspects each device's actions and builds the -right control for each one. That means custom and plugin devices get correct, -range-aware controls with no extra work. +Control lets you drive hardware by hand — turn an output on, nudge a value, take +a reading, or run a saved function — without starting a recorded experiment. The +controls are **generated automatically** from whatever devices are connected: +GLIDER inspects each device's actions and builds the right control for each one. +That means custom and plugin devices get correct, range-aware controls with no +extra work. -!!! note "The Manual tab reflects your current hardware" - Every time you open the Manual tab, its controls rebuild to match what's - connected right now. If you connect a board from the Setup tab and then tap - Manual, the new device's controls appear. +!!! note "Control reflects your current hardware" + Every time you open the Control tab, its controls rebuild to match what's + connected right now. If you connect a board from **Hardware** and then tap + **Control**, the new device's controls appear. -### Per-device controls +#### Per-device controls For each device you'll see a heading with the device's name, followed by one control per action: @@ -168,7 +161,7 @@ output shows one slider rather than duplicate controls. All values are clamped to each device's declared safe range before they're sent, and commands to a single device are sent one at a time. -### The Functions section +#### The Functions section If the loaded experiment defines any **functions** — reusable action sequences built as a `StartFunction → … → EndFunction` chain in the graph — a @@ -194,43 +187,103 @@ can never overlap a real experiment: To learn how functions are built, see [Functions](../building/functions.md). -### The status strip +#### The status strip + +A status strip sits at the bottom of the Control tab. It shows failures and +warnings as an icon plus text and **keeps them visible until something replaces +them** — for example, if a command fails, if a value had to be clamped, or if a +function couldn't run. If you tap an ON/OFF switch and the command fails, the +switch flips back to its real state and the reason appears here. + +## The Run tab + +The Run tab is where you start, watch, and stop a recorded experiment. Top to +bottom: + +- **Run details.** The per-run fields your experiment asks for — **Dam ID** or + **Animal ID** and **Group**, say, plus **Experimenter** and **Notes**. Tap a + field to type its value. Fields marked required **block Start** until they're + filled in. (The fields come from the experiment file you opened; there is no + preset picker on the Pi, so choose or edit presets on the desktop.) The + details lock while a run is live. +- **A status pill** at the right of the details: **NOT READY** (no board + connected, or no runnable flow), **NEEDS DETAILS** (a required field is + empty), or **READY**. While a run is live it shows the state instead — + **RUNNING**, **PAUSED**. +- **The clock.** A large clock counts up while a run is live and freezes while + paused. +- **Device cards.** A card per device, in two columns, showing the device's name, + its type, a live value badge (an output's level, an input's reading), and a + link word under it — **Ready** once the device is initialized, **Disconnected** + while its board isn't connected. +- **The camera video.** When the camera is streaming, its live view appears + between the cards and the run bar. +- **The run bar.** At the bottom, a line about the board and the buttons. + +### Start, Pause, and Stop + +When idle, the run bar shows a big **▶ Start run** button. It is enabled only +when you're ready: a board is connected, the experiment has a Start Experiment +node, and every required run field is filled in. Until then it is greyed out and +a hint under it says what's missing: + +| Hint | What to do | +| --- | --- | +| **Fill in Dam ID to start.** (or whichever field is required) | Tap the field in the run details and enter a value. | +| **Connect a board to start (⚙ › Connect boards…).** | Open the ⚙ menu and choose **Connect boards…**. | +| **Add a Start Experiment node to the flow to start.** | Open the **Dashboard** tab, unlock the graph, and add one. | + +While a run is live the bar becomes **Pause** (which turns into **Resume** when +paused) and **■ Stop**. Stop ends the run at any time. + +To stop from another tab, **tap the run pill in the header** — that jumps to the +Run tab — and tap **Stop**. + +!!! tip "Stopping loads a clean time" + When a flow finishes, the clock snaps to the experiment's true logical + duration rather than the last timer tick, so repeated runs of the same flow + report a consistent elapsed time. -A status strip sits at the bottom of the Manual tab. Because a touchscreen -operator never sees the small status-bar messages a desktop user would, this -strip shows failures and warnings as an icon plus text and **keeps them visible -until something replaces them** — for example, if a command fails, if a value -had to be clamped, or if a function couldn't run. If you tap an ON/OFF switch and -the command fails, the switch flips back to its real state and the reason -appears here. +## Switch to Desktop -## The Camera tab +**⚙ › Switch to Desktop** opens the desktop editor in place: the normal header +with the **Analyze** tab, and the node graph with its side panels. Nothing is +reloaded — your experiment, hardware, and camera carry over. The **Run** tab +stays the Pi's run page. -The Camera tab holds GLIDER's live camera view and recording controls in the -touchscreen layout. For how the camera, recording, and behavior tracking work, -see [Camera & Recording](../camera-behavior/camera.md) and -[Tracking](../camera-behavior/tracking.md). +- It is **refused while a run is live** — GLIDER tells you to stop the + experiment first. +- It is **one-way**. You cannot return to the Runner layout without restarting + GLIDER. On a dedicated bench kiosk you normally leave this alone. See + [Runner Mode](index.md#how-glider-chooses-runner-mode) for why. ## Running an experiment from the touchscreen -Here's the whole loop, tab by tab: - -1. **Connect your hardware.** On the **Setup** tab, tap **Connect / Ports** and - connect your board. The **Board** status line should turn to `✓`. -2. **Load your experiment.** Still on Setup, tap **Open** and choose your - experiment file (or **New** to start fresh). The **Experiment** status line - should turn to `✓`. -3. **(Optional) Check your rig by hand.** Tap **Manual** and use the - auto-generated controls — toggle an output, nudge a value, take a reading, or - run a Function — to confirm everything responds before you record. -4. **Start the run.** Tap **Run**. When both Board and Experiment are ready, - **▶ START** lights up. Tap it. The timer begins, the state pill shows - `RUNNING`, and **● REC** appears while recording. -5. **Watch it go.** Stay on the Run tab, or move to **Manual** or **Camera** — - the run banner carries the timer, state, REC indicator, and **STOP** button - with you. -6. **Stop when done.** Tap **■ STOP** (on the Run tab or in the banner). The run - ends and the timer settles on the experiment's final duration. +Here's the whole loop: + +1. **Connect your hardware.** Tap **⚙ › Connect boards…** and connect your board. + Its dot in the header turns green. +2. **Load your experiment.** Tap **⚙ › Open…** and choose your experiment file + (or **New** to start fresh). Check the details on the **Experiment** tab if + you like. +3. **(Optional) Look over the flow.** On the **Dashboard** tab the graph is + locked, so you can pan, zoom, and fit it without changing anything. +4. **(Optional) Check your rig by hand.** On the Dashboard, open **Control** and + use the auto-generated controls — toggle an output, nudge a value, take a + reading, or run a Function — to confirm everything responds before you + record. +5. **Fill in the run details.** On the **Run** tab, tap each field the + experiment asks for (a Dam ID, say). The pill changes from **NEEDS DETAILS** + to **READY**. +6. **Start the run.** Tap **▶ Start run**. The clock starts, the pill turns to + **RUNNING**, and the header pill shows **Running** (or **Recording**) with the + elapsed time. +7. **Watch it go.** Stay on the Run tab, or move to another tab — the header pill + keeps the state and time with you, and the graph is locked so nothing can be + changed by accident. +8. **Stop when done.** Tap **■ Stop** on the Run tab (or tap the header pill to + jump there first). The run ends and the clock settles on the experiment's + final duration. !!! tip "First launch" The very first time GLIDER starts, it shows a short welcome dialog and tells From 4c352dd259632eb3092cf0419063ffb352067d85 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 12:17:20 -0500 Subject: [PATCH 17/25] fix(pi): bound the notice bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A critical notice with a long unbreakable token widened the window (833 px at 480) and a wrapped one grew without limit. The text now has a 1 px minimum width; info and warning take one elided line with the whole text in the tooltip, and critical wraps to two lines at most, the second elided. The spec's icon is a level glyph before the text (ⓘ, ⚠), and a 44 px ✕ at the right dismisses, as a tap anywhere still does. text() is unchanged. --- src/glider/gui/runner/notice_bar.py | 72 +++++++++++++++++++----- src/glider/gui/styles/touch.qss | 19 ++++++- tests/unit/gui/runner/test_notice_bar.py | 65 +++++++++++++++++++-- tests/unit/gui/runner/test_pi_header.py | 9 +++ 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/src/glider/gui/runner/notice_bar.py b/src/glider/gui/runner/notice_bar.py index 0b96f369..f4b430e5 100644 --- a/src/glider/gui/runner/notice_bar.py +++ b/src/glider/gui/runner/notice_bar.py @@ -1,20 +1,25 @@ """A one-line-or-two notice bar for the Pi's header. -Shows a short title and message at a level (``info`` | ``warning`` | -``critical``), styled by the ``level`` property. A tap dismisses it. A lower -level never replaces a visible ``critical`` notice: an "UNSAFE STATE" warning -has to stay up until somebody has seen it, not be buried by a "Saved". +Shows a level glyph, a short title and message at a level (``info`` | +``warning`` | ``critical``), styled by the ``level`` property, and a ✕. A tap +anywhere on it dismisses it. Info and warning take one line, elided, with the +whole text in the tooltip; a critical notice wraps to two lines, so more of an +error is readable before anything is cut. Either way the bar never grows past +two lines or widens the window. A lower level never replaces a visible +``critical`` notice: an "UNSAFE STATE" warning has to stay up until somebody has +seen it, not be buried by a "Saved". """ from __future__ import annotations from PyQt6.QtCore import Qt -from PyQt6.QtGui import QMouseEvent -from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel +from PyQt6.QtGui import QMouseEvent, QTextLayout, QTextOption +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QToolButton from glider.gui.styles import restyle _RANK = {"info": 0, "warning": 1, "critical": 2} +_GLYPH = {"info": "ⓘ", "warning": "⚠", "critical": "⚠"} class NoticeBar(QFrame): @@ -22,30 +27,71 @@ def __init__(self, parent=None): super().__init__(parent) self.setObjectName("piNoticeBar") self.setProperty("level", "info") + self._full = "" self._label = QLabel() - self._label.setWordWrap(True) self._label.setTextFormat(Qt.TextFormat.PlainText) + # A long unbreakable token must clip or elide, not widen the window. + self._label.setMinimumWidth(1) + self._close = QToolButton() + self._close.setObjectName("piNoticeClose") + self._close.setText("✕") + self._close.setAccessibleName("Dismiss notice") + self._close.setFixedSize(44, 44) + self._close.clicked.connect(self.dismiss) row = QHBoxLayout(self) - row.addWidget(self._label) + row.setContentsMargins(0, 0, 0, 0) + row.addWidget(self._label, 1) + row.addWidget(self._close) self.hide() def show_notice(self, title: str, message: str, level: str) -> None: if self.property("level") == "critical" and not self.isHidden() and _RANK[level] < 2: return - self._label.setText(f"{title}: {message}") + self._full = f"{title}: {message}" + self._label.setToolTip(self._full) self.setProperty("level", level) - # The label too: touch.qss colours it by the bar's level, and Qt + # The label and ✕ too: touch.qss colours them by the bar's level, and Qt # re-resolves a descendant selector only when the descendant is polished. - restyle(self) - restyle(self._label) + for widget in (self, self._label, self._close): + restyle(widget) + self._fit() self.show() def text(self) -> str: - return self._label.text() + return self._full def dismiss(self) -> None: self.hide() + def resizeEvent(self, event) -> None: # noqa: N802 (Qt override) + super().resizeEvent(event) + self._fit() + + def _fit(self) -> None: + """Lay the text out in one line, or two for critical, cut to the width.""" + level = self.property("level") + label = self._label + width = max(1, label.width()) + text = f"{_GLYPH[level]} {' '.join(self._full.split())}" + if level == "critical": + layout = QTextLayout(text, label.font()) + option = QTextOption() + option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) + layout.setTextOption(option) + layout.beginLayout() + first = layout.createLine() + first.setLineWidth(width) + layout.endLayout() + cut = first.textLength() + if cut < len(text): + tail = label.fontMetrics().elidedText( + text[cut:], Qt.TextElideMode.ElideRight, width + ) + text = f"{text[:cut].rstrip()}\n{tail}" + else: + text = label.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, width) + label.setText(text) + def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802 (Qt override) if event.button() == Qt.MouseButton.LeftButton: self.dismiss() diff --git a/src/glider/gui/styles/touch.qss b/src/glider/gui/styles/touch.qss index c106e722..4d1a8305 100644 --- a/src/glider/gui/styles/touch.qss +++ b/src/glider/gui/styles/touch.qss @@ -737,7 +737,7 @@ QToolButton#shellTab:checked { border-bottom-color: #38bdf8; /* ACCENT */ } -/* The notice bar under the header: tinted by level, tap to dismiss. */ +/* The notice bar under the header: tinted by level, tap (or ✕) to dismiss. */ QFrame#piNoticeBar { border: none; border-bottom: 1px solid #1e2530; @@ -749,12 +749,20 @@ QFrame#piNoticeBar QLabel { font-size: 13px; } +QFrame#piNoticeBar QToolButton#piNoticeClose { + background-color: transparent; + border: none; + font-size: 16px; + padding: 0; +} + QFrame#piNoticeBar[level="info"] { background-color: rgba(56, 189, 248, 0.10); /* ACCENT @ 10% */ border-bottom-color: rgba(56, 189, 248, 0.30); } -QFrame#piNoticeBar[level="info"] QLabel { +QFrame#piNoticeBar[level="info"] QLabel, +QFrame#piNoticeBar[level="info"] QToolButton#piNoticeClose { color: #38bdf8; /* ACCENT */ } @@ -763,7 +771,8 @@ QFrame#piNoticeBar[level="warning"] { border-bottom-color: rgba(251, 191, 36, 0.35); } -QFrame#piNoticeBar[level="warning"] QLabel { +QFrame#piNoticeBar[level="warning"] QLabel, +QFrame#piNoticeBar[level="warning"] QToolButton#piNoticeClose { color: #fbbf24; /* WARNING */ } @@ -777,6 +786,10 @@ QFrame#piNoticeBar[level="critical"] QLabel { font-weight: 600; } +QFrame#piNoticeBar[level="critical"] QToolButton#piNoticeClose { + color: #f87171; /* ERROR */ +} + /* ============================================================================ * Dashboard: the graph over the panel tabs. * ========================================================================= */ diff --git a/tests/unit/gui/runner/test_notice_bar.py b/tests/unit/gui/runner/test_notice_bar.py index 2fb3a9c0..52804a36 100644 --- a/tests/unit/gui/runner/test_notice_bar.py +++ b/tests/unit/gui/runner/test_notice_bar.py @@ -1,5 +1,6 @@ import pytest from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QVBoxLayout, QWidget from glider.gui.runner.notice_bar import NoticeBar @@ -17,11 +18,67 @@ def test_hidden_until_a_notice_arrives(qtbot): assert bar.property("level") == "info" -def test_the_message_wraps(qtbot): +def _bar_at(qtbot, width=480): + """A bar in a column ``width`` wide, the way the window holds it.""" + host = QWidget() + qtbot.addWidget(host) bar = NoticeBar() - qtbot.addWidget(bar) - bar.show_notice("Saved", "x", "info") - assert bar._label.wordWrap() + QVBoxLayout(host).addWidget(bar) + host.resize(width, 200) + host.show() + return host, bar + + +def _lines(bar): + return bar._label.text().split("\n") + + +def test_info_and_warning_are_one_elided_line_with_the_text_in_the_tooltip(qtbot): + host, bar = _bar_at(qtbot) + long = "the camera stopped. " * 20 + for level in ("info", "warning"): + bar.show_notice("Heads up", long, level) + qtbot.wait(10) + assert len(_lines(bar)) == 1 and _lines(bar)[0].endswith("…") + assert bar._label.sizeHint().height() < 2 * bar._label.fontMetrics().lineSpacing() + assert bar._label.toolTip() == bar.text() == f"Heads up: {long}" + + +def test_critical_wraps_to_two_lines_at_most(qtbot): + host, bar = _bar_at(qtbot) + unbreakable = "/dev/" + "x" * 120 + for message in ("Hardware may still be active. " * 10, unbreakable): + bar.show_notice("UNSAFE STATE", message, "critical") + qtbot.wait(10) + metrics = bar._label.fontMetrics() + assert len(_lines(bar)) == 2 and _lines(bar)[1].endswith("…") + assert all(metrics.horizontalAdvance(line) <= bar._label.width() for line in _lines(bar)) + assert bar._label.sizeHint().height() < 3 * metrics.lineSpacing() + assert bar.text() == f"UNSAFE STATE: {message}" + assert host.minimumSizeHint().width() <= 480 and bar.width() <= 480 + + +def test_a_short_critical_notice_stays_on_one_line(qtbot): + host, bar = _bar_at(qtbot) + bar.show_notice("UNSAFE", "Check outputs.", "critical") + qtbot.wait(10) + assert _lines(bar) == ["⚠ UNSAFE: Check outputs."] + + +def test_each_level_has_its_glyph(qtbot): + host, bar = _bar_at(qtbot) + for level, glyph in (("info", "ⓘ"), ("warning", "⚠"), ("critical", "⚠")): + bar.show_notice("T", "m", level) + bar.dismiss() + assert bar._label.text().startswith(glyph) + + +def test_the_close_button_dismisses(qtbot): + host, bar = _bar_at(qtbot) + bar.show_notice("UNSAFE", "c", "critical") + assert bar._close.text() == "✕" and bar._close.height() >= 44 + bar._close.click() + assert bar.isHidden() def test_critical_is_not_replaced_by_info(qtbot): diff --git a/tests/unit/gui/runner/test_pi_header.py b/tests/unit/gui/runner/test_pi_header.py index 7b901e88..40f9ae17 100644 --- a/tests/unit/gui/runner/test_pi_header.py +++ b/tests/unit/gui/runner/test_pi_header.py @@ -97,3 +97,12 @@ def test_long_name_does_not_widen_the_window(qtbot, main_window_factory): w._core.session.metadata.name = "x" * 60 w._refresh_strip_experiment() assert w._pi_header.minimumSizeHint().width() <= 480 + + +def test_a_long_critical_notice_does_not_widen_the_window(qtbot, main_window_factory): + w = _pi(qtbot, main_window_factory) + w._notify_user("Connection Error", "/dev/" + "x" * 120, level="critical") + qtbot.waitUntil(w._notice_bar.isVisible) + assert w.minimumSizeHint().width() <= 480 + assert w.width() == 480 + assert w._notice_bar.width() <= 480 From 22a504198ebe4e61b74a52673c06bb4dbec90239 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 12:17:35 -0500 Subject: [PATCH 18/25] fix(pi): fit 800x480 landscape The runner window needed 642 px at the least (444 before this branch), so on an 800x480 screen held landscape, which the view manager runs as Runner, Start was below the screen. The Pi dashboard took the graph's desktop 300 px floor plus the panel tabs' tallest page, and the Run page 136 px for the device cards' scroll area. Runner only: the graph's floor drops to 120 px (Switch to Desktop restores 400x300), the panel tabs' to 140 px (a taller page scrolls, or the grip gives it room), and the cards and the camera slot on the Run page give up their room first. At 800x480 the window now needs 434 px, 479 with a critical notice up, and every page fits. --- src/glider/gui/main_window.py | 10 +++++- src/glider/gui/runner/device_grid.py | 3 ++ src/glider/gui/runner/pi_dashboard.py | 4 +++ src/glider/gui/runner/run_page.py | 2 ++ .../gui/runner/test_pi_dashboard_window.py | 2 ++ tests/unit/gui/runner/test_pi_run_window.py | 36 +++++++++++++++++++ 6 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index 0ab76685..966c5ebb 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -90,6 +90,10 @@ # On the Pi these hold the graph locked, and make the header pill a way to Run. _PI_LOCKED_STATES = ("RUNNING", "PAUSED", "INITIALIZING", "STOPPING") +# The node graph's floor in the desktop frame. The Pi lowers the height, and +# Switch to Desktop puts this back. +_GRAPH_MIN_SIZE = (400, 300) + # QSettings flag, namespaced alongside the existing first_run/* keys and kept # separate from ``first_run/tour_complete``: one shared flag would mean sitting # through the walkthrough silences the setup form nobody has seen yet. @@ -1270,7 +1274,7 @@ def _create_builder_view(self) -> None: ``_populate_builder_panels`` fills them once the panel widgets exist. """ self._graph_view = NodeGraphView() - self._graph_view.setMinimumSize(400, 300) + self._graph_view.setMinimumSize(*_GRAPH_MIN_SIZE) # Create node editor controller self._node_editor = NodeEditorController( @@ -1781,6 +1785,9 @@ def _build_pi_dashboard(self) -> PiDashboard: graph = self._graph_view graph.set_touch_mode(True) graph.set_locked(True) + # The desktop's 300 px floor plus the panel tabs made the window 642 px + # tall at the least, past the bottom of an 800x480 screen held landscape. + graph.setMinimumHeight(120) graph.locked_changed.connect(self._apply_pi_lock) self._apply_pi_lock(True) @@ -3122,6 +3129,7 @@ def _switch_to_desktop_mode(self) -> None: graph.node_selected.disconnect(dashboard._on_node_selected) graph.set_locked(False) graph.set_touch_mode(False) + graph.setMinimumSize(*_GRAPH_MIN_SIZE) # A scroller swallows the mouse moves that desktop drags need. self._finger_scroll.release() self._experiment_page.release_finger_scrolling() diff --git a/src/glider/gui/runner/device_grid.py b/src/glider/gui/runner/device_grid.py index 0ec3ceb7..8acff970 100644 --- a/src/glider/gui/runner/device_grid.py +++ b/src/glider/gui/runner/device_grid.py @@ -64,6 +64,9 @@ def __init__(self, core: GliderCore, parent=None): self._scroll.setFrameShape(QFrame.Shape.NoFrame) self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._scroll.setWidget(body) + # The cards give up their room first when the page is short (800x480 + # landscape), so the run bar under them stays on the screen. + self._scroll.setMinimumHeight(1) # Let the page behind show through, as the old runner list did. self._scroll.viewport().setAutoFillBackground(False) body.setAutoFillBackground(False) diff --git a/src/glider/gui/runner/pi_dashboard.py b/src/glider/gui/runner/pi_dashboard.py index 8e008adc..e6fbd51c 100644 --- a/src/glider/gui/runner/pi_dashboard.py +++ b/src/glider/gui/runner/pi_dashboard.py @@ -33,6 +33,10 @@ def __init__(self, graph: NodeGraphView, parent: QWidget | None = None) -> None: self._tabs.setObjectName("piDashboardTabs") self._tabs.setDocumentMode(True) self._tabs.tabBar().setExpanding(True) + # Not the tallest panel's height: in 800x480 landscape that would push + # the window past the screen. A taller panel scrolls, or the grip gives + # it room. + self._tabs.setMinimumHeight(140) self._splitter.addWidget(graph) self._splitter.addWidget(self._tabs) diff --git a/src/glider/gui/runner/run_page.py b/src/glider/gui/runner/run_page.py index 2240eb2d..ab009912 100644 --- a/src/glider/gui/runner/run_page.py +++ b/src/glider/gui/runner/run_page.py @@ -38,6 +38,8 @@ def __init__(self, core: GliderCore, parent=None): self.devices = DeviceCardGrid(core) self.camera_slot = QWidget() QVBoxLayout(self.camera_slot).setContentsMargins(0, 0, 0, 0) + # Like the cards, the video gives way on a short screen (it clips). + self.camera_slot.setMinimumHeight(1) self.camera_slot.hide() self.control = RunControlPanel(core, touch=True) diff --git a/tests/unit/gui/runner/test_pi_dashboard_window.py b/tests/unit/gui/runner/test_pi_dashboard_window.py index a3d853cc..a03cf393 100644 --- a/tests/unit/gui/runner/test_pi_dashboard_window.py +++ b/tests/unit/gui/runner/test_pi_dashboard_window.py @@ -108,6 +108,8 @@ def test_switch_to_desktop_rehosts_without_duplicates(qtbot, main_window_factory assert w._tab_bar.buttons()["analyze"].isVisibleTo(w) assert not w._gear_button.isVisibleTo(w) assert graph.dragMode().name == "RubberBandDrag" and not graph.is_locked() + # The Pi lowered the graph's floor for landscape; the desktop gets its own back. + assert (graph.minimumWidth(), graph.minimumHeight()) == (400, 300) def test_switch_to_desktop_restores_the_desktop_chrome(qtbot, main_window_factory): diff --git a/tests/unit/gui/runner/test_pi_run_window.py b/tests/unit/gui/runner/test_pi_run_window.py index 7fd10866..85821b7e 100644 --- a/tests/unit/gui/runner/test_pi_run_window.py +++ b/tests/unit/gui/runner/test_pi_run_window.py @@ -7,6 +7,7 @@ from __future__ import annotations +from PyQt6.QtCore import QPoint from PyQt6.QtWidgets import QStatusBar from glider.gui.main_window import PAGE_OPERATOR @@ -125,3 +126,38 @@ def test_camera_status_still_reaches_the_desktop_status_bar(qtbot, main_window_f w.switch_to_builder() w._camera_panel._show_status("Camera settings saved") assert w.statusBar().currentMessage() == "Camera settings saved" + + +def test_landscape_800x480_fits_and_start_is_on_the_screen(qtbot, main_window_factory, monkeypatch): + """Landscape is not tuned, but nothing may push the window past 480 px. + + The stack is as tall as its tallest page, so every page counts, whichever + is showing. A window pushed taller does not shrink back afterwards. + """ + w = main_window_factory(desktop_mode=False) + w.resize(800, 480) + w.show() + qtbot.waitUntil(w._pi_header.isVisible) + for go in (w._show_experiment_tab, w.switch_to_builder, w._enter_dashboard): + go() + qtbot.wait(10) + hint = w.minimumSizeHint() + assert hint.height() <= 480 and hint.width() <= 800 + for key in w._pi_dashboard.keys(): + w._pi_dashboard.set_current(key) + assert w.minimumSizeHint().height() <= 480 + w._enter_dashboard() + # The Run page with a critical notice up, then with the camera streaming. + w._notify_user("UNSAFE STATE", "Hardware may still be active. " * 6, level="critical") + qtbot.wait(10) + assert w.minimumSizeHint().height() <= 480 + w._notice_bar.dismiss() + monkeypatch.setattr(type(w._core.camera_manager), "is_streaming", property(lambda _: True)) + w._enter_dashboard() + qtbot.wait(10) + assert w._pi_run_page.camera_slot.isVisibleTo(w) + assert w.minimumSizeHint().height() <= 480 + assert w.size().height() == 480 + start = w._pi_run_page.control._start_btn + assert start.isVisible() + assert start.mapTo(w, QPoint(0, start.height())).y() <= w.height() From ef8b62c5fb62018c5ef19d2c76bf918e984d88db Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 12:17:47 -0500 Subject: [PATCH 19/25] fix(pi): Connect boards connects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board dialog behind ⚙ › Connect boards… and the Hardware tab's button only edits each board's port (Cancel / Save), so the Pi had no tap path to connect-all while START waits for a connected board and the Run hint and the docs send operators there. Both now go through one method that opens the dialog and then connects every board, unless a run is live. --- src/glider/gui/main_window.py | 18 ++++++++++--- .../gui/runner/test_pi_dashboard_window.py | 27 ++++++++++++++----- tests/unit/gui/runner/test_pi_header.py | 1 + 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index 966c5ebb..af7aecb1 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -792,7 +792,8 @@ def _build_pi_header(self, parent: QWidget) -> QWidget: widgets keep their attribute names, since a lot of code refreshes them. The gear's "Connect boards" resolves ``self._hardware_panel`` at click - time: the runner-mode panel is built after this header is. + time (in ``_on_pi_connect_boards``): the runner-mode panel is built + after this header is. """ header = QWidget(parent) header.setObjectName("piHeader") @@ -820,7 +821,7 @@ def _build_pi_header(self, parent: QWidget) -> QWidget: ("Save", self._on_save), ("Save As…", self._on_save_as), None, - ("Connect boards…", lambda: self._hardware_panel.show_board_settings_dialog()), + ("Connect boards…", self._on_pi_connect_boards), None, ("Help", self._on_help), ("Switch to Desktop", self._switch_to_desktop_mode), @@ -1797,7 +1798,7 @@ def _build_pi_dashboard(self) -> PiDashboard: connect = QPushButton("Connect boards…") connect.setObjectName("piConnectBoardsButton") connect.setMinimumHeight(44) - connect.clicked.connect(lambda: self._hardware_panel.show_board_settings_dialog()) + connect.clicked.connect(self._on_pi_connect_boards) hardware_layout.addWidget(connect) hardware_layout.addWidget(self._hardware_panel, 1) @@ -1838,6 +1839,17 @@ def _apply_pi_lock(self, locked: bool) -> None: self._properties_host.setEnabled(not locked) self._hardware_panel.set_editable(not locked) + def _on_pi_connect_boards(self) -> None: + """⚙ › Connect boards… and the Hardware tab's button: ports, then connect. + + The board dialog only edits the port each board uses, and START waits + for a connected board, so this is the Pi's one tap path to connecting. + Not while a run is live: connecting sets the hardware up again. + """ + self._hardware_panel.show_board_settings_dialog() + if not self._run_is_live(): + self._on_connect_hardware() + def _heal_stale_paint(self, *_args) -> None: """Schedule a deferred full-window repaint after a central relayout. diff --git a/tests/unit/gui/runner/test_pi_dashboard_window.py b/tests/unit/gui/runner/test_pi_dashboard_window.py index a03cf393..c39e2077 100644 --- a/tests/unit/gui/runner/test_pi_dashboard_window.py +++ b/tests/unit/gui/runner/test_pi_dashboard_window.py @@ -7,6 +7,7 @@ from __future__ import annotations +import pytest from PyQt6.QtWidgets import QPushButton from glider.gui.main_window import PAGE_BUILDER @@ -58,14 +59,28 @@ def test_panel_tabs_hold_the_real_widgets(qtbot, main_window_factory): assert connect.minimumHeight() >= 44 -def test_connect_boards_opens_the_board_dialog(qtbot, main_window_factory, monkeypatch): +@pytest.mark.parametrize("live", [False, True]) +def test_connect_boards_sets_the_ports_then_connects_unless_live( + qtbot, main_window_factory, monkeypatch, live +): + """Both doors, the gear item and the Hardware tab's button, connect. + + The board dialog only edits ports; START waits for a connected board. + """ w = _pi(qtbot, main_window_factory) - opened = [] - monkeypatch.setattr(w._hardware_panel, "show_board_settings_dialog", lambda: opened.append(1)) + calls = [] + monkeypatch.setattr( + w._hardware_panel, "show_board_settings_dialog", lambda: calls.append("dialog") + ) + monkeypatch.setattr(w, "_on_connect_hardware", lambda: calls.append("connect")) + monkeypatch.setattr(w, "_run_is_live", lambda: live) + gear = next(a for a in w._gear_button.menu().actions() if a.text() == "Connect boards…") hardware = w._pi_dashboard.tabs().widget(2) - (connect,) = [b for b in hardware.findChildren(QPushButton) if b.text() == "Connect boards…"] - connect.click() - assert opened == [1] + (button,) = [b for b in hardware.findChildren(QPushButton) if b.text() == "Connect boards…"] + for tap in (gear.trigger, button.click): + calls.clear() + tap() + assert calls == (["dialog"] if live else ["dialog", "connect"]) def test_control_tab_refreshes_the_manual_controls_when_shown( diff --git a/tests/unit/gui/runner/test_pi_header.py b/tests/unit/gui/runner/test_pi_header.py index 40f9ae17..7f34c505 100644 --- a/tests/unit/gui/runner/test_pi_header.py +++ b/tests/unit/gui/runner/test_pi_header.py @@ -74,6 +74,7 @@ def test_connect_boards_resolves_the_panel_when_clicked(qtbot, main_window_facto w = _pi(qtbot, main_window_factory) opened = [] monkeypatch.setattr(w._hardware_panel, "show_board_settings_dialog", lambda: opened.append(1)) + monkeypatch.setattr(w, "_on_connect_hardware", lambda: None) connect = next(a for a in w._gear_button.menu().actions() if a.text() == "Connect boards…") connect.trigger() assert opened == [1] From 3daf0c9251cacc599b2a13c932673907c029e0c1 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 12:17:53 -0500 Subject: [PATCH 20/25] fix(pi): load --file on the Pi; stale strings `glider --runner --file X` showed an empty graph: the core had loaded the file, but only the desktop branch of _setup_ui opened it into the window. Both branches now do, so the Pi dashboard's graph shows the flow. A manual function in progress is on the Control tab, not the Manual tab; the comments and the run_timer docstring that still named the run banner, the Runner panel and the Manual tab now name what replaced them. --- src/glider/gui/main_window.py | 22 +++++------ src/glider/gui/runner/run_timer.py | 2 +- .../gui/runner/test_pi_dashboard_window.py | 37 +++++++++++++++++++ 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index af7aecb1..dc742c1e 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -150,7 +150,7 @@ # # Hardware and Run satisfy it for the actions anyone reaches for. The Hardware # panel carries Add Board and Add Device; the toolbar carries Connect, Start and -# Stop; Stop is on the run banner and the Runner panel besides. +# Stop; on the Pi, Stop is on the Run page besides. # # Experiment and Tools did not satisfy it at all, which is the bug this list now # exists to prevent. Experiment Settings, Add Subject and Lab Setup have exactly @@ -729,13 +729,13 @@ def _setup_ui(self) -> None: else: self._stack.setCurrentIndex(PAGE_BUILDER) self._populate_builder_panels() - if self._has_open_experiment(): - # --file loaded the experiment into the core before this - # window existed, so nothing has drawn its graph, zones or - # panels yet. Open it the way File > Open does. - self._load_experiment_path(self._core.session.file_path) - else: - self._show_landing() + if self._has_open_experiment(): + # --file loaded the experiment into the core before this window + # existed, so nothing has drawn its graph, zones or panels yet. + # Open it the way File > Open does. + self._load_experiment_path(self._core.session.file_path) + elif not self._view_manager.is_runner_mode: + self._show_landing() self._install_palette_shortcut() @@ -1809,8 +1809,8 @@ def _build_pi_dashboard(self) -> PiDashboard: dashboard.add_tab("camera", "Camera", self._camera_tab_slot) dashboard.add_tab("control", "Control", self._runner_device_controls) - # Re-sync the manual controls each time their tab is shown, as the old - # Manual tab did: a board may have connected since they were last seen. + # Re-sync the manual controls each time their tab is shown: a board may + # have connected since they were last seen. def _on_tab_changed(index: int) -> None: if dashboard.tabs().widget(index) is self._runner_device_controls: self._runner_device_controls.refresh() @@ -4019,7 +4019,7 @@ async def _start_async(self) -> None: if self._manual_run_busy: self._notify_user( "Function running", - "A function is running on the Manual tab. Let it finish before starting.", + "A function is running on the Control tab. Let it finish before starting.", level="warning", ) return diff --git a/src/glider/gui/runner/run_timer.py b/src/glider/gui/runner/run_timer.py index 9eea7c60..22fe462d 100644 --- a/src/glider/gui/runner/run_timer.py +++ b/src/glider/gui/runner/run_timer.py @@ -1,4 +1,4 @@ -"""Pure elapsed-time formatting shared by the Dashboard header and run banner.""" +"""Pure elapsed-time formatting for the run clock (desktop Dashboard and Pi Run page).""" from __future__ import annotations diff --git a/tests/unit/gui/runner/test_pi_dashboard_window.py b/tests/unit/gui/runner/test_pi_dashboard_window.py index c39e2077..659838a0 100644 --- a/tests/unit/gui/runner/test_pi_dashboard_window.py +++ b/tests/unit/gui/runner/test_pi_dashboard_window.py @@ -148,3 +148,40 @@ def test_switch_to_desktop_is_refused_mid_run(qtbot, main_window_factory, monkey assert w.is_runner_mode assert _is_descendant(w._graph_view, w._pi_dashboard) assert w._notice_bar.text().startswith("Experiment running") + + +def test_startup_with_a_file_draws_its_graph_on_the_pi(qtbot, tmp_path): + """``glider --runner --file X`` showed an empty graph: only the desktop + branch opened the file the core had already loaded.""" + import asyncio + import shutil + from pathlib import Path + + from PyQt6.QtCore import QSettings + from PyQt6.QtWidgets import QApplication + + from glider.core.glider_core import GliderCore + from glider.gui.main_window import PAGE_OPERATOR, MainWindow + from glider.gui.view_manager import ViewManager, ViewMode + + experiment = tmp_path / "box_pi_side.glider" + shutil.copy(Path(__file__).parents[4] / "examples" / "box_pi_side.glider", experiment) + core = GliderCore() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(core.initialize(load_plugins=False)) + try: + loop.run_until_complete(core.load_experiment(experiment)) # what --file does + vm = ViewManager(QApplication.instance()) + vm.mode = ViewMode.RUNNER + settings = QSettings(str(tmp_path / "s.ini"), QSettings.Format.IniFormat) + window = MainWindow(core, view_manager=vm, settings=settings) + qtbot.addWidget(window, before_close_func=lambda _w: core.session._mark_clean()) + + expected = {n.id for n in core.session.flow.nodes} + assert expected + assert set(window._graph_view.nodes) == expected + assert window._stack.currentIndex() == PAGE_OPERATOR # still opens on Run + finally: + loop.run_until_complete(core.shutdown()) + loop.close() From d147aa13597849ba126730e04fdb479d8fac8676 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 12:18:00 -0500 Subject: [PATCH 21/25] test(pi): every live state locks; Pause, Resume and run values reach the window The lock-during-run test covers RUNNING, PAUSED, INITIALIZING and STOPPING, and two window-level tests pin the Run page's pause/resume wiring and that editing a run value refreshes the run info. --- tests/unit/gui/runner/test_pi_run_window.py | 25 +++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/unit/gui/runner/test_pi_run_window.py b/tests/unit/gui/runner/test_pi_run_window.py index 85821b7e..c88c7a78 100644 --- a/tests/unit/gui/runner/test_pi_run_window.py +++ b/tests/unit/gui/runner/test_pi_run_window.py @@ -7,6 +7,7 @@ from __future__ import annotations +import pytest from PyQt6.QtCore import QPoint from PyQt6.QtWidgets import QStatusBar @@ -42,16 +43,36 @@ def test_run_page_is_the_runs_header_and_controls(qtbot, main_window_factory): assert w._camera_slot is page.camera_slot -def test_a_run_forces_the_lock_and_frees_the_toggle_after(qtbot, main_window_factory): +@pytest.mark.parametrize("live", ["RUNNING", "PAUSED", "INITIALIZING", "STOPPING"]) +def test_a_run_forces_the_lock_and_frees_the_toggle_after(qtbot, main_window_factory, live): w = _pi(qtbot, main_window_factory) g = w._graph_view g.set_locked(False) - w._on_core_state_change(_state("RUNNING")) + w._on_core_state_change(_state(live)) assert g.is_locked() and not g._overlay.lock_btn.isEnabled() w._on_core_state_change(_state("READY")) assert g.is_locked() and g._overlay.lock_btn.isEnabled() +def test_pause_and_resume_reach_the_core(qtbot, main_window_factory, monkeypatch): + w = _pi(qtbot, main_window_factory) + sent = [] + monkeypatch.setattr(w, "_run_async", sent.append) + monkeypatch.setattr(w._core, "pause_experiment", lambda: "pause") + monkeypatch.setattr(w._core, "resume_experiment", lambda: "resume") + w._pi_run_page.control.pause_requested.emit() + w._pi_run_page.control.resume_requested.emit() + assert sent == ["pause", "resume"] + + +def test_editing_a_run_value_refreshes_the_run_info(qtbot, main_window_factory, monkeypatch): + w = _pi(qtbot, main_window_factory) + seen = [] + monkeypatch.setattr(w._run_header, "set_run_info", lambda *args: seen.append(args)) + w._pi_run_page.header.run_values_changed.emit() + assert seen + + def test_run_state_reaches_the_page_and_the_manual_controls(qtbot, main_window_factory): w = _pi(qtbot, main_window_factory) seen = [] From 5b5532f284f821f6c67794d0bbaa5b2bcb6cd6fe Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 12:18:00 -0500 Subject: [PATCH 22/25] docs(pi): correct the header, notice bar, readiness and connect steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header's order is name, "— edited", run pill on the left, board dots and ⚙ on the right; the pill also reads Starting and Stopping and jumps to Run only while a run is live. The Run pill's position and its INITIALIZING, STOPPING and ERROR states, readiness with no boards configured, the notice bar's glyph and ✕, and that Connect boards… now connects. Open comes before Connect in the walkthrough, since the dialog needs a loaded experiment. "⚙ ›" throughout, the ecosystem reference lists the whole menu, and a long troubleshooting line is re-wrapped. --- docs-site/reference/ecosystem-reference.md | 2 +- docs-site/reference/troubleshooting.md | 5 +- docs-site/runner/index.md | 2 +- docs-site/runner/runner.md | 62 ++++++++++++++-------- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/docs-site/reference/ecosystem-reference.md b/docs-site/reference/ecosystem-reference.md index c8b8952b..97314e90 100644 --- a/docs-site/reference/ecosystem-reference.md +++ b/docs-site/reference/ecosystem-reference.md @@ -59,7 +59,7 @@ This distinction matters in explanations of GLIDER: data flow alone does not tri ### Runner is the touch-first operating workspace -Runner is intended for an operator at a bench or a Raspberry Pi touchscreen. It can load the same `.glider` file but uses larger controls, a header with a ⚙ menu (New, Open, Save, Connect boards, Switch to Desktop), and three tabs: +Runner is intended for an operator at a bench or a Raspberry Pi touchscreen. It can load the same `.glider` file but uses larger controls, a header with a ⚙ menu (New, Open, Save, Save As, Connect boards, Help, Switch to Desktop, Exit), and three tabs: - **Experiment** edits metadata, mice, zones, lab vocabulary, and plugins. - **Dashboard** shows the node graph (view-only until unlocked) over panels for nodes, properties, hardware, camera, and manual **Control**, which generates controls from a device's declared actions and ranges. diff --git a/docs-site/reference/troubleshooting.md b/docs-site/reference/troubleshooting.md index ce2cd1ca..fc997397 100644 --- a/docs-site/reference/troubleshooting.md +++ b/docs-site/reference/troubleshooting.md @@ -61,8 +61,9 @@ Open**, then confirm. You only need to do this once. See Runner mode is one-mode-per-process by design. To get back to the desktop editor, use **⚙ › Switch to Desktop** (it is refused while a run is live, so -stop the run first), or relaunch without the `--runner` flag. On a Raspberry Pi kiosk you can log in as the `pi` user over -SSH to recover. See [Raspberry Pi Kiosk](../runner/pi-kiosk.md). +stop the run first), or relaunch without the `--runner` flag. On a Raspberry +Pi kiosk you can log in as the `pi` user over SSH to recover. See +[Raspberry Pi Kiosk](../runner/pi-kiosk.md). ## Still stuck? diff --git a/docs-site/runner/index.md b/docs-site/runner/index.md index 27d0afc0..ef5353d6 100644 --- a/docs-site/runner/index.md +++ b/docs-site/runner/index.md @@ -46,7 +46,7 @@ glider --builder # Force Desktop (Builder / editor) mode !!! note "One mode per launch" GLIDER runs in a single mode for the life of the process. You can switch - from Runner to Desktop while it is running (**⚙ → Switch to Desktop**; it is + from Runner to Desktop while it is running (**⚙ › Switch to Desktop**; it is refused while a run is live), but you cannot switch back to Runner without restarting the app. On a dedicated Pi kiosk you normally never leave Runner mode. diff --git a/docs-site/runner/runner.md b/docs-site/runner/runner.md index 1685270e..64061c12 100644 --- a/docs-site/runner/runner.md +++ b/docs-site/runner/runner.md @@ -14,16 +14,22 @@ experiment start to finish without a keyboard. The header is on every page, so you never lose the essentials. It has two rows. -**Row one** carries, left to right: +**Row one** carries, on the left: + +- The **experiment name**, followed by an amber **— edited** while there is + unsaved work. +- The **run-state pill**, just after the name. It is hidden while GLIDER is + idle and appears once a run is under way: **Running Starting** as it starts, + **Running 03:12** while a run is live, **Recording 12:41** when data is being + recorded, **Running Paused** while paused, **Running Stopping** as it stops, + and **Error** if the session errors. While a run is live (starting, running, + paused, or stopping), **tap the pill to jump to the Run tab** from any other + tab. + +and on the right: -- The **experiment name**, with a small dot beside it while there is unsaved - work. - **One dot per board**, labelled with the board's ID — green when the board is connected. -- The **run-state pill**. It is hidden while GLIDER is idle and appears once a - run is under way: **Running 03:12** while a run is live, **Recording 12:41** - when data is being recorded, **Running Paused** while paused, and **Error** - if the session errors. **Tap the pill to jump to the Run tab** from anywhere. - The **⚙ menu** (below). **Row two** is the tabs: **Experiment**, **Dashboard**, **Run**. There is no @@ -40,7 +46,7 @@ The gear opens a menu with everything that used to be a button on a file page: | **Open…** | Load an existing experiment file. | | **Save** | Save the current experiment. | | **Save As…** | Save the current experiment under a new name. | -| **Connect boards…** | Open the board/port settings to connect your hardware. | +| **Connect boards…** | Open the board/port settings; when you close them, GLIDER connects every board (not while a run is live). | | **Help** | Opens the in-app help. | | **Switch to Desktop** | Opens the desktop editor (see [below](#switch-to-desktop)). | | **Exit** | Closes GLIDER. | @@ -55,7 +61,14 @@ Warnings and errors appear in a bar just under the header — a board that lost its connection mid-run, a connection or start error, a New, Open, or Switch to Desktop that was refused because a run is live. The bar is deliberately sticky: a touchscreen operator never sees a small status-bar message that vanishes, so -**a notice stays until you tap it**. +**a notice stays until you tap it** — anywhere on the bar, or the **✕** at its +right end. + +Each notice starts with a glyph for its level — **ⓘ** for information, **⚠** for +a warning or a critical notice — and is tinted to match: blue, amber, or red. +An information or warning notice takes one line; if it is longer than the bar, +it ends in "…". A critical notice wraps onto a second line instead, so more of +it is readable. A **critical** notice — the "UNSAFE STATE" warning after a run ends in an error, for example, which means a heater, relay, PWM output, valve, or motor may still @@ -120,7 +133,7 @@ that. Tap it again to lock the graph. | --- | --- | | **Nodes** | The node library. **Tap a node to drop it in the middle of the graph.** Dragging one onto a precise spot needs a press-and-hold first. Both are refused while the graph is locked. | | **Properties** | The selected node's settings. **Tapping a node on the graph while editing opens this tab.** Read-only while the graph is locked. | -| **Hardware** | Your boards and devices. **Connect boards…** at the top opens the board/port settings. While the graph is unlocked, **+ Board** and **+ Device** appear and boards and devices can be edited or removed; while it is locked those are off (connecting and disconnecting still work). For what the device types mean and how to wire them, see [Devices & Hardware](../building/devices.md). | +| **Hardware** | Your boards and devices. **Connect boards…** at the top opens the board/port settings and then connects every board, the same as the ⚙ menu item. While the graph is unlocked, **+ Board** and **+ Device** appear and boards and devices can be edited or removed; while it is locked those are off (connecting and disconnecting still work). For what the device types mean and how to wire them, see [Devices & Hardware](../building/devices.md). | | **Camera** | The live camera view and recording controls. For how the camera, recording, and behavior tracking work, see [Camera & Recording](../camera-behavior/camera.md) and [Tracking](../camera-behavior/tracking.md). | | **Control** | Drive hardware by hand, and run your saved functions. See [below](#the-control-tab). | @@ -206,10 +219,13 @@ bottom: filled in. (The fields come from the experiment file you opened; there is no preset picker on the Pi, so choose or edit presets on the desktop.) The details lock while a run is live. -- **A status pill** at the right of the details: **NOT READY** (no board - connected, or no runnable flow), **NEEDS DETAILS** (a required field is - empty), or **READY**. While a run is live it shows the state instead — - **RUNNING**, **PAUSED**. +- **A status pill** at the top right, above the details: **NOT READY** (no + board connected, or no runnable flow), **NEEDS DETAILS** (a required field is + empty), or **READY**. An experiment with no boards configured (video only, + say) needs no board, so it can be **READY** with nothing connected. While a + run is under way the pill shows the state instead — **INITIALIZING**, + **RUNNING**, **PAUSED**, or **STOPPING** — and **ERROR** if the session + errors. - **The clock.** A large clock counts up while a run is live and freezes while paused. - **Device cards.** A card per device, in two columns, showing the device's name, @@ -223,14 +239,15 @@ bottom: ### Start, Pause, and Stop When idle, the run bar shows a big **▶ Start run** button. It is enabled only -when you're ready: a board is connected, the experiment has a Start Experiment -node, and every required run field is filled in. Until then it is greyed out and -a hint under it says what's missing: +when you're ready: a board is connected (or the experiment configures no +boards), the experiment has a Start Experiment node, and every required run +field is filled in. Until then it is greyed out and a hint under it says what's +missing: | Hint | What to do | | --- | --- | | **Fill in Dam ID to start.** (or whichever field is required) | Tap the field in the run details and enter a value. | -| **Connect a board to start (⚙ › Connect boards…).** | Open the ⚙ menu and choose **Connect boards…**. | +| **Connect a board to start (⚙ › Connect boards…).** | Open the ⚙ menu, choose **Connect boards…**, check each board's port, and tap **Save**; GLIDER then connects the boards. | | **Add a Start Experiment node to the flow to start.** | Open the **Dashboard** tab, unlock the graph, and add one. | While a run is live the bar becomes **Pause** (which turns into **Resume** when @@ -261,11 +278,12 @@ stays the Pi's run page. Here's the whole loop: -1. **Connect your hardware.** Tap **⚙ › Connect boards…** and connect your board. - Its dot in the header turns green. -2. **Load your experiment.** Tap **⚙ › Open…** and choose your experiment file +1. **Load your experiment.** Tap **⚙ › Open…** and choose your experiment file (or **New** to start fresh). Check the details on the **Experiment** tab if - you like. + you like. The experiment says which boards it uses, so load it first. +2. **Connect your hardware.** Tap **⚙ › Connect boards…**, check each board's + port, and tap **Save**. GLIDER connects the boards, and each board's dot in + the header turns green. 3. **(Optional) Look over the flow.** On the **Dashboard** tab the graph is locked, so you can pan, zoom, and fit it without changing anything. 4. **(Optional) Check your rig by hand.** On the Dashboard, open **Control** and From 000df85cffb22b0de11cbb6fb75350756a9b85bd Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 13:19:18 -0500 Subject: [PATCH 23/25] fix(pi): leave table and tree headers out of finger scrolling Setting a scroll mode on a header view that had not laid out its sections segfaulted the macOS CI run while the Pi dashboard was being built, and a drag on a header is a column resize, not a scroll. FingerScroll now skips QHeaderView alongside combo-box popups; a test checks no header is grabbed. --- src/glider/gui/runner/finger_scroll.py | 14 +++++++++++--- tests/unit/gui/test_touch_stylesheet.py | 13 ++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/glider/gui/runner/finger_scroll.py b/src/glider/gui/runner/finger_scroll.py index 0a998c76..f96e86fe 100644 --- a/src/glider/gui/runner/finger_scroll.py +++ b/src/glider/gui/runner/finger_scroll.py @@ -14,6 +14,7 @@ QAbstractItemView, QAbstractScrollArea, QComboBox, + QHeaderView, QScroller, QWidget, ) @@ -21,8 +22,15 @@ _GESTURE = QScroller.ScrollerGestureType.LeftMouseButtonGesture -def _in_combo(widget: QWidget) -> bool: - """A combo box's popup list: it scrolls itself, and is not a panel.""" +def _skipped(widget: QWidget) -> bool: + """Not a panel to drag-scroll. + + A table's or tree's header is a scroll area too, but dragging on it + resizes columns; and setting a scroll mode on a header that has not laid + out its sections crashes on macOS. A combo box's popup list scrolls itself. + """ + if isinstance(widget, QHeaderView): + return True parent = widget.parentWidget() while parent is not None: if isinstance(parent, QComboBox): @@ -50,7 +58,7 @@ def add(self, *roots: QWidget) -> None: if isinstance(root, QAbstractScrollArea): areas.insert(0, root) for area in areas: - if id(area) in done or _in_combo(area): + if id(area) in done or _skipped(area): continue done.add(id(area)) QScroller.grabGesture(area.viewport(), _GESTURE) diff --git a/tests/unit/gui/test_touch_stylesheet.py b/tests/unit/gui/test_touch_stylesheet.py index 9faeb65b..234b67d0 100644 --- a/tests/unit/gui/test_touch_stylesheet.py +++ b/tests/unit/gui/test_touch_stylesheet.py @@ -94,10 +94,10 @@ def test_vocabulary_rows_and_remove_buttons_are_44px_on_the_pi(qtbot): def _scroll_areas(panel): - """The panel's scroll areas, less combo-box popup lists (they scroll themselves).""" - from glider.gui.runner.finger_scroll import _in_combo + """The panel's scroll areas, less headers and combo-box popup lists.""" + from glider.gui.runner.finger_scroll import _skipped - return [a for a in panel.findChildren(QAbstractScrollArea) if not _in_combo(a)] + return [a for a in panel.findChildren(QAbstractScrollArea) if not _skipped(a)] def _finger_scrolled(w) -> dict[str, list[bool]]: @@ -135,6 +135,13 @@ def test_the_pi_scrolls_its_panels_by_finger_and_desktop_does_not(qtbot, main_wi assert pi["zones_wrapper"] == [False] mice = w._experiment_page.built_sections()["mice"].findChild(QAbstractItemView) assert mice.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel + # Headers are left alone: a drag on one resizes a column. + from PyQt6.QtWidgets import QHeaderView, QScroller + + headers = w._hardware_panel.findChildren(QHeaderView) + mice.parentWidget().findChildren( + QHeaderView + ) + assert headers and not any(QScroller.hasScroller(h.viewport()) for h in headers) desk = _finger_scrolled(main_window_factory(desktop_mode=True)) assert not any(any(flags) for flags in desk.values()) From 22e890798873c02b29bf98d706796665b9f6efe8 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 13:19:19 -0500 Subject: [PATCH 24/25] fix(pi): fit the Pi chrome to whatever font the platform falls back to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit touch.qss asks for -apple-system first, so Linux and Windows fall back to wider faces and CI measured the Pi pages past 480 px. Make the widths independent of the font rather than tuned to macOS metrics: - Experiment section chips sit in a finger-scrolled strip that hugs its row, so their labels never set the page's minimum width. - The touch tab bar's tabs take an Ignored width: the stretch alone splits the row in thirds. - The status strip in touch mode can shrink a board name to its dot, marks unsaved work with a single dot, and labels a recording pill "● REC" (the mockup's word). - The Metadata form puts labels above fields on the Pi, and the compact Zones editor stacks its two button rows. - The field-tags elision test no longer assumes a label fits at one width. Verified locally with touch.qss and the app font forced to Verdana and to Courier New (wider than the macOS face, as on CI). --- docs-site/runner/runner.md | 6 ++-- src/glider/gui/dialogs/experiment_dialog.py | 5 ++- src/glider/gui/dialogs/zone_dialog.py | 8 +++-- src/glider/gui/panels/experiment_page.py | 35 ++++++++++++++++++- src/glider/gui/shell/status_strip.py | 20 +++++++++-- src/glider/gui/shell/tab_bar.py | 9 +++-- .../gui/dashboard/panels/test_field_tags.py | 10 ++++-- 7 files changed, 77 insertions(+), 16 deletions(-) diff --git a/docs-site/runner/runner.md b/docs-site/runner/runner.md index 64061c12..08770b5f 100644 --- a/docs-site/runner/runner.md +++ b/docs-site/runner/runner.md @@ -16,11 +16,11 @@ The header is on every page, so you never lose the essentials. It has two rows. **Row one** carries, on the left: -- The **experiment name**, followed by an amber **— edited** while there is +- The **experiment name**, followed by an amber **●** while there is unsaved work. - The **run-state pill**, just after the name. It is hidden while GLIDER is idle and appears once a run is under way: **Running Starting** as it starts, - **Running 03:12** while a run is live, **Recording 12:41** when data is being + **Running 03:12** while a run is live, **● REC 12:41** when data is being recorded, **Running Paused** while paused, **Running Stopping** as it stops, and **Error** if the session errors. While a run is live (starting, running, paused, or stopping), **tap the pill to jump to the Run tab** from any other @@ -294,7 +294,7 @@ Here's the whole loop: experiment asks for (a Dam ID, say). The pill changes from **NEEDS DETAILS** to **READY**. 6. **Start the run.** Tap **▶ Start run**. The clock starts, the pill turns to - **RUNNING**, and the header pill shows **Running** (or **Recording**) with the + **RUNNING**, and the header pill shows **Running** (or **● REC**) with the elapsed time. 7. **Watch it go.** Stay on the Run tab, or move to another tab — the header pill keeps the state and time with you, and the graph is locked so nothing can be diff --git a/src/glider/gui/dialogs/experiment_dialog.py b/src/glider/gui/dialogs/experiment_dialog.py index 55458c77..2c14dc2f 100644 --- a/src/glider/gui/dialogs/experiment_dialog.py +++ b/src/glider/gui/dialogs/experiment_dialog.py @@ -322,8 +322,11 @@ def _fit_480(self) -> None: The buttons' fixed widths were picked for the desktop font, and at touch padding "Browse" and "Remove" are clipped, so they size to their labels instead. Four buttons at that size are wider than the screen, - so Set Active drops to a row of its own. + so Set Active drops to a row of its own. The form's labels go above + their fields, so the label column never takes width from a field + whatever the platform's font makes of "Experimenter:". """ + self._info_layout.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapAllRows) for button in (self._browse_dir_btn, self._add_btn, self._edit_btn, self._remove_btn): button.setMinimumWidth(0) button.setMaximumWidth(_NO_MAX_WIDTH) diff --git a/src/glider/gui/dialogs/zone_dialog.py b/src/glider/gui/dialogs/zone_dialog.py index ffbae729..e4cf083e 100644 --- a/src/glider/gui/dialogs/zone_dialog.py +++ b/src/glider/gui/dialogs/zone_dialog.py @@ -479,7 +479,7 @@ def _setup_ui(self) -> None: left_layout.addWidget(shape_group) # Drawing controls - draw_layout = QHBoxLayout() + draw_layout = self._draw_layout = QHBoxLayout() self._draw_btn = QPushButton("Draw Zone") self._draw_btn.setCheckable(True) @@ -570,7 +570,7 @@ def _setup_ui(self) -> None: right_layout.addWidget(info_group) # File operations - file_layout = QHBoxLayout() + file_layout = self._file_layout = QHBoxLayout() self._save_btn = QPushButton("Save...") self._save_btn.clicked.connect(self._save_config) @@ -875,6 +875,10 @@ def embed(self, *, compact: bool = False) -> "ZoneDialog": self.setMinimumSize(0, 0) self._preview.setMinimumSize(240, 180) self._preview.refit_on_resize = True + # Three buttons abreast are wider than 480px in a wide platform + # font at touch padding; stacked they never clip. + for row in (self._draw_layout, self._file_layout): + row.setDirection(QBoxLayout.Direction.TopToBottom) self._update_table() # rows built in __init__ have the desktop delete button return self diff --git a/src/glider/gui/panels/experiment_page.py b/src/glider/gui/panels/experiment_page.py index fd9ddd5f..805df4dc 100644 --- a/src/glider/gui/panels/experiment_page.py +++ b/src/glider/gui/panels/experiment_page.py @@ -107,6 +107,24 @@ RAIL_WIDTH = 168 +class _ChipStrip(QScrollArea): + """A one-row scroll area exactly as tall as the row it holds. + + A plain scroll area asks for the default scroll-area height, which left a + band of empty space above and below the chips. + """ + + def _row_height(self) -> int: + row = self.widget() + return (row.sizeHint().height() if row is not None else 0) + 2 * self.frameWidth() + + def sizeHint(self) -> QSize: # noqa: N802 - Qt override + return QSize(super().sizeHint().width(), self._row_height()) + + def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt override + return QSize(super().minimumSizeHint().width(), self._row_height()) + + class ExperimentPage(QWidget): """A section rail beside a stack of lazily-built editors. @@ -211,6 +229,10 @@ def _build_chips(self) -> QWidget: Text only, no glyphs: at 480px five chips and their icons do not share a row, and the label is what you read anyway. + + The row sits in a strip that scrolls sideways by finger, so the full + labels never set the page's minimum width: with a wider platform font + the last chip slides off the edge instead of the page outgrowing 480px. """ bar = QFrame(self) bar.setObjectName("experimentChips") @@ -229,7 +251,18 @@ def _build_chips(self) -> QWidget: chip.clicked.connect(lambda _checked, k=key: self.show_section(k)) row.addWidget(chip) self._buttons[key] = chip - return bar + + strip = _ChipStrip(self) + strip.setObjectName("experimentChipsStrip") + strip.setWidgetResizable(True) + strip.setFrameShape(QFrame.Shape.NoFrame) + strip.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + strip.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + strip.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + strip.setWidget(bar) + if self._finger_scroll is not None: + self._finger_scroll.add(strip) + return strip # --------------------------------------------------------------- sections diff --git a/src/glider/gui/shell/status_strip.py b/src/glider/gui/shell/status_strip.py index 3ac2e65e..e2ef5d36 100644 --- a/src/glider/gui/shell/status_strip.py +++ b/src/glider/gui/shell/status_strip.py @@ -134,6 +134,12 @@ "error": "Error", } +#: The same words in touch mode, where the pill shares 480 px with five boards +#: and whatever font the platform falls back to. Only recording is shortened: +#: the mockup's "● REC" is the Pi's word for it, and the clock after it is what +#: the operator reads. +_TOUCH_RUN_STATE_TEXT = {**RUN_STATE_TEXT, "recording": "\u25cf REC"} + #: The device states this strip knows how to paint. Anything else becomes #: ``"unknown"``; see the module docstring for why that is never ``"ok"``. DEVICE_STATES = ("ok", "warn", "error", "unknown") @@ -147,12 +153,18 @@ #: name back gets the name. _DIRTY_MARK = "— edited" +#: The dirty marker in touch mode: a dot, because "— edited" costs a board's +#: worth of width on the Pi header and a frameless kiosk shows nothing else. +_TOUCH_DIRTY_MARK = "\u25cf" + #: Narrowest an eliding name may get in touch mode, in pixels. Deliberately tiny: #: it is only the floor the layout can squeeze to, reached when the dirty marker, #: a running pill and five boards all compete for 480 px, and the pill and marker -#: do not shrink. With room to spare the names take it. +#: do not shrink. With room to spare the names take it. A device name may go all +#: the way to (almost) nothing, leaving its dot: how wide names are depends on the +#: platform's fallback font, and the dots are what have to stay. _TOUCH_NAME_MIN = 32 -_TOUCH_DEVICE_MIN = 12 +_TOUCH_DEVICE_MIN = 1 # not 0: a zero minimum falls back to the text width #: The strip's fixed height in touch mode: a 44 px tap target, to match the gear #: button beside it. @@ -529,7 +541,9 @@ def set_touch_mode(self, enabled: bool) -> None: widget.setVisible(self._desktop_visible.pop(widget, True)) self._set_gaps(_TOUCH_GAPS if enabled else _GAPS) self.setFixedHeight(_TOUCH_HEIGHT if enabled else STRIP_HEIGHT) + self._dirty.setText(_TOUCH_DIRTY_MARK if self._touch else _DIRTY_MARK) self._dirty.setVisible(self._touch and self._is_dirty) + self.set_run_state(self._run_state, self._run_detail or None) self._name.set_elide_min(_TOUCH_NAME_MIN if self._touch else None) for label in self._names: label.set_elide_min(_TOUCH_DEVICE_MIN if self._touch else None) @@ -597,7 +611,7 @@ def set_run_state(self, state: str, detail: str | None = None) -> None: ) self._run_state = state self._run_detail = str(detail).strip() if detail else "" - text = RUN_STATE_TEXT[state] + text = (_TOUCH_RUN_STATE_TEXT if self._touch else RUN_STATE_TEXT)[state] self._pill.setText(f"{text} {self._run_detail}" if self._run_detail else text) self._pill.setProperty("state", state) # Shown only when it has something to say. Idle is the state the app is diff --git a/src/glider/gui/shell/tab_bar.py b/src/glider/gui/shell/tab_bar.py index b6f3935c..94b76971 100644 --- a/src/glider/gui/shell/tab_bar.py +++ b/src/glider/gui/shell/tab_bar.py @@ -118,8 +118,11 @@ def __init__(self, parent: QWidget | None = None) -> None: row.addStretch(1) self._desktop_policy = self._buttons[TAB_KEYS[0]].sizePolicy() + # Ignored, not Expanding: the stretch alone sets the width, so the + # three tabs split the row evenly whatever the platform's font makes + # of each label (Expanding still floors each tab at its text width). self._touch_policy = QSizePolicy( - QSizePolicy.Policy.Expanding, self._desktop_policy.verticalPolicy() + QSizePolicy.Policy.Ignored, self._desktop_policy.verticalPolicy() ) self._buttons[TAB_KEYS[0]].setChecked(True) self._current = TAB_KEYS[0] @@ -131,8 +134,8 @@ def set_touch_mode(self, enabled: bool) -> None: The two end stretches go to zero and the tabs take stretch one, so the tabs are the only thing that can take the width (a zero-stretch spacer - still expands, and would otherwise claim a share of it); Expanding and - equal stretch make the shares equal. + still expands, and would otherwise claim a share of it); an Ignored + width and equal stretch make the shares equal. """ for key, button in self._buttons.items(): if key == "analyze": diff --git a/tests/unit/gui/dashboard/panels/test_field_tags.py b/tests/unit/gui/dashboard/panels/test_field_tags.py index fe768ee2..0df4e1f8 100644 --- a/tests/unit/gui/dashboard/panels/test_field_tags.py +++ b/tests/unit/gui/dashboard/panels/test_field_tags.py @@ -192,11 +192,15 @@ def test_grid_chips_elide_to_their_column_and_keep_the_full_value_in_the_tooltip chip = w.chip(key) assert chip.width() < 160 # two columns share 300 px assert chip.text().endswith("…") and chip.text() != _FULL[key] - assert chip.text().startswith(_LABEL[key]) # the label stays readable + # Elided from the right, so what shows is the start of the full text: + # the label first. How much of it fits is up to the platform's font. + assert _FULL[key].startswith(chip.text()[:-1]) assert chip.fontMetrics().horizontalAdvance(chip.text()) <= chip.width() assert chip.toolTip() == ch * 40 - # Plenty of room: nothing is elided. - w.resize(1400, 120) + # Plenty of room: nothing is elided. "Plenty" is measured in the font in + # use, which is wider on some platforms than others. + chip = w.chip("a") + w.resize(2 * chip.fontMetrics().horizontalAdvance(_FULL["a"]) + 400, 120) assert w.chip("a").text() == _FULL["a"] From 1c5c8004c1cb7945c2cf06e87e0238f36cc92b0d Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Wed, 21 Oct 2026 13:48:53 -0500 Subject: [PATCH 25/25] fix(pi): let the run clock shrink to fit; stack the zone shape choices Windows CI still measured the Pi Run page at 528 px and the Zones section at 490 px. The run page's width came from the clock: 64 px of eight digits in whatever monospace face the platform falls back to (none of DejaVu Sans Mono, Liberation Mono, Menlo or SF Mono is on Windows). The clock now asks for no width of its own and, given too little, paints its digits at the largest size that fits. The Zones section's widest row was the three shape radio buttons abreast; on the Pi they stack, like the button rows already do. Tests: a 200 px clock no longer widens the page (750 px before the fix), and the shape choices stack on the Pi but not on the desktop. --- src/glider/gui/dialogs/zone_dialog.py | 8 ++-- src/glider/gui/runner/run_page.py | 41 +++++++++++++++++-- tests/unit/gui/runner/test_run_page.py | 14 +++++++ .../unit/gui/test_experiment_page_compact.py | 8 ++++ 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/glider/gui/dialogs/zone_dialog.py b/src/glider/gui/dialogs/zone_dialog.py index e4cf083e..e3f1a2d5 100644 --- a/src/glider/gui/dialogs/zone_dialog.py +++ b/src/glider/gui/dialogs/zone_dialog.py @@ -458,7 +458,7 @@ def _setup_ui(self) -> None: # Shape selector shape_group = QGroupBox("Zone Shape") - shape_layout = QHBoxLayout(shape_group) + shape_layout = self._shape_layout = QHBoxLayout(shape_group) self._shape_group = QButtonGroup(self) @@ -875,9 +875,9 @@ def embed(self, *, compact: bool = False) -> "ZoneDialog": self.setMinimumSize(0, 0) self._preview.setMinimumSize(240, 180) self._preview.refit_on_resize = True - # Three buttons abreast are wider than 480px in a wide platform - # font at touch padding; stacked they never clip. - for row in (self._draw_layout, self._file_layout): + # Three buttons (or shapes) abreast are wider than 480px in a wide + # platform font at touch padding; stacked they never clip. + for row in (self._shape_layout, self._draw_layout, self._file_layout): row.setDirection(QBoxLayout.Direction.TopToBottom) self._update_table() # rows built in __init__ have the desktop delete button return self diff --git a/src/glider/gui/runner/run_page.py b/src/glider/gui/runner/run_page.py index ab009912..9ae9b0c4 100644 --- a/src/glider/gui/runner/run_page.py +++ b/src/glider/gui/runner/run_page.py @@ -9,8 +9,9 @@ from typing import TYPE_CHECKING -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget +from PyQt6.QtCore import QSize, Qt, pyqtSignal +from PyQt6.QtGui import QFontMetrics, QPainter, QPalette +from PyQt6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget from glider.gui.dashboard.panels.run_control_panel import RunControlPanel from glider.gui.dashboard.panels.run_header import RunHeader @@ -20,6 +21,40 @@ from glider.core.glider_core import GliderCore +class _FitClock(QLabel): + """The big clock: its digits shrink to fit rather than widening the page. + + The stylesheet asks for 64 px of a monospace face, and how wide eight digits + of that are depends on which face the platform has; a page that took its + minimum width from them could be pushed past the screen. So the clock asks + for no width of its own and, when the width it gets is too little, paints + the text at the largest size that fits. + """ + + def __init__(self, text: str, parent: QWidget | None = None) -> None: + super().__init__(text, parent) + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + + def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt override + return QSize(1, super().minimumSizeHint().height()) + + def paintEvent(self, event) -> None: # noqa: N802 - Qt override + area = self.contentsRect() + font = self.font() + width = QFontMetrics(font).horizontalAdvance(self.text()) + if width <= area.width() or width <= 0: + super().paintEvent(event) + return + if font.pixelSize() > 0: + font.setPixelSize(max(1, font.pixelSize() * area.width() // width)) + else: + font.setPointSizeF(max(1.0, font.pointSizeF() * area.width() / width)) + painter = QPainter(self) + painter.setFont(font) + painter.setPen(self.palette().color(QPalette.ColorRole.WindowText)) + painter.drawText(area, int(self.alignment()), self.text()) + + class PiRunPage(QWidget): start_requested = pyqtSignal() stop_requested = pyqtSignal() @@ -31,7 +66,7 @@ def __init__(self, core: GliderCore, parent=None): self.setObjectName("piRunPage") self.header = RunHeader(core, touch=True) - self.clock = QLabel("00:00.00") + self.clock = _FitClock("00:00.00") self.clock.setObjectName("piRunClock") self.clock.setAlignment(Qt.AlignmentFlag.AlignCenter) self.clock.setProperty("live", False) diff --git a/tests/unit/gui/runner/test_run_page.py b/tests/unit/gui/runner/test_run_page.py index b2bc2bb1..21b59097 100644 --- a/tests/unit/gui/runner/test_run_page.py +++ b/tests/unit/gui/runner/test_run_page.py @@ -143,3 +143,17 @@ def test_long_run_field_values_do_not_widen_the_page_past_480(qtbot, mock_core): qtbot.waitExposed(page) assert page.minimumSizeHint().width() <= 480 assert page.width() == 480 + + +def test_the_clock_shrinks_to_fit_instead_of_widening_the_page(qtbot, mock_core): + """How wide 64 px of digits are depends on the platform's monospace face.""" + _real_session(mock_core) + page = _page(qtbot, mock_core) + page.clock.setStyleSheet("font-size: 200px;") # far wider than 480 px + page.clock.setText("12:41.36") + page.resize(480, 800) + page.show() + qtbot.waitExposed(page) + assert page.minimumSizeHint().width() <= 480 + assert page.clock.width() <= 480 + assert not page.grab().isNull() # the shrunk text paints diff --git a/tests/unit/gui/test_experiment_page_compact.py b/tests/unit/gui/test_experiment_page_compact.py index 26f4bdfb..08b48029 100644 --- a/tests/unit/gui/test_experiment_page_compact.py +++ b/tests/unit/gui/test_experiment_page_compact.py @@ -89,6 +89,11 @@ def test_compact_zone_editor_is_a_column_and_keeps_its_delete_label(qtbot): editor = ZoneDialog(camera_manager=None, zone_config=_one_zone()).embed(compact=True) qtbot.addWidget(editor) assert _width_needed(editor) <= 480 + # Rows of three stack on the Pi, so a wide platform font cannot push them past 480 px. + from PyQt6.QtWidgets import QBoxLayout + + for row in (editor._shape_layout, editor._draw_layout, editor._file_layout): + assert row.direction() == QBoxLayout.Direction.TopToBottom # Fixed at 40px it is narrower than the touch button's own padding. assert editor._table.cellWidget(0, 3).maximumWidth() > 40 @@ -98,3 +103,6 @@ def test_desktop_zone_editor_embed_is_unchanged(qtbot): qtbot.addWidget(editor) assert editor.minimumWidth() == 1000 assert editor._table.cellWidget(0, 3).maximumWidth() == 40 + from PyQt6.QtWidgets import QBoxLayout + + assert editor._shape_layout.direction() == QBoxLayout.Direction.LeftToRight