diff --git a/docs-site/assets/screenshots/runner-dashboard.png b/docs-site/assets/screenshots/runner-dashboard.png new file mode 100644 index 00000000..803d583b Binary files /dev/null and b/docs-site/assets/screenshots/runner-dashboard.png differ diff --git a/docs-site/assets/screenshots/runner.png b/docs-site/assets/screenshots/runner.png index dd76ed01..5d48949a 100644 Binary files a/docs-site/assets/screenshots/runner.png and b/docs-site/assets/screenshots/runner.png differ diff --git a/docs-site/building/builder-window.md b/docs-site/building/builder-window.md index 75c35015..67d38190 100644 --- a/docs-site/building/builder-window.md +++ b/docs-site/building/builder-window.md @@ -225,7 +225,7 @@ about. | Menu | Its commands | How to reach them now | |---|---|---| | **Hardware** | Add Board…, Add Device…, New Custom Device Type…, Connect All, Disconnect All | **Add Board** and **Add Device** are buttons in the **Hardware** tab, which is where you were probably already looking. The rest: ++ctrl+k++ | -| **Run** | Start (++f5++), Stop (++shift+f5++), Emergency Stop (++ctrl+shift+escape++) | The toolbar, the run banner, the shortcuts, or ++ctrl+k++ | +| **Run** | Start (++f5++), Stop (++shift+f5++), Emergency Stop (++ctrl+shift+escape++) | The toolbar, the **Run** tab, the shortcuts, or ++ctrl+k++ | | **Tools** | Behavior Analysis…, Batch Pose Tracking…, Session Review…, Multi-Camera Recording…, GPU / Device Check… | The **Analyze** tab, which has a card for each — including, when a tool needs an optional dependency stack, the exact install line. **Plugins** moved to the **Experiment** tab instead | Every row in the palette names the menu it came from, so if you remember that diff --git a/docs-site/building/subjects.md b/docs-site/building/subjects.md index acc31b8b..d87f6cc0 100644 --- a/docs-site/building/subjects.md +++ b/docs-site/building/subjects.md @@ -79,10 +79,12 @@ change to it, even one that ends on the same term, counts as you choosing the term, and it is learned again. !!! note "Runner mode" - The setup form is a desktop feature. It is never offered on the Raspberry Pi - runner screen — a 480-pixel touch surface with no menu bar is the wrong - shape for five editable lists. A runner uses whatever vocabulary was defined - on the desktop side. + The one-time setup form is a desktop feature. It is never offered at first + launch on the Raspberry Pi runner screen — a 480-pixel touch surface is the + wrong place to meet five editable lists unprompted. The lists are still + there when you want them: the Runner's **Experiment** tab has a **Lab + Vocabulary** section, and a runner otherwise uses whatever vocabulary was + defined on the desktop side. !!! warning "If saving fails" If GLIDER cannot write the file — a read-only home directory, a missing diff --git a/docs-site/getting-started/concepts.md b/docs-site/getting-started/concepts.md index ab4f329a..ade386b6 100644 --- a/docs-site/getting-started/concepts.md +++ b/docs-site/getting-started/concepts.md @@ -91,10 +91,11 @@ See [Camera & Recording](../camera-behavior/camera.md). collapsible panel either side of the canvas and a [command palette](../building/builder-window.md#command-palette) on ++ctrl+k++. -- **Runner mode** is where you *operate* — a touch-first, four-tab kiosk - (**Setup, Run, Manual, Camera**) meant for a bench touchscreen or a Raspberry - Pi. It runs the same `.glider` experiments, adds big manual device controls, - and lets you trigger saved [functions](../building/functions.md) with one tap. +- **Runner mode** is where you *operate* — a touch-first kiosk with three tabs + (**Experiment, Dashboard, Run**) and a ⚙ menu, meant for a bench touchscreen + or a Raspberry Pi. It runs the same `.glider` experiments, shows the graph + view-only by default, adds big manual device controls, and lets you trigger + saved [functions](../building/functions.md) with one tap. Design once, run anywhere. Ready to build something? Continue to [Your First Experiment](first-experiment.md). diff --git a/docs-site/index.md b/docs-site/index.md index a386bbdb..b99bc054 100644 --- a/docs-site/index.md +++ b/docs-site/index.md @@ -59,15 +59,15 @@ behavior rig. - **Tracking & behavior analysis.** With optional extras, GLIDER runs pose tracking on your video and helps you cluster and classify behavior. - **Touchscreen Runner.** Flip into a large-button kiosk mode for running - experiments at the bench, with manual device controls and one-tap function - buttons. + experiments at the bench, with a view-only graph, manual device controls, and + one-tap function buttons. ## Two ways to use it | | Desktop mode | Runner mode | |---|---|---| | **Best for** | Designing experiments | Running them at the bench | -| **Interface** | Full node-graph editor, two collapsible side panels, a command palette | Four big touch tabs: Setup, Run, Manual, Camera | +| **Interface** | Full node-graph editor, two collapsible side panels, a command palette | Three big touch tabs: Experiment, Dashboard, Run | | **Typical device** | Laptop / workstation | Touchscreen or Raspberry Pi kiosk | You design an experiment once in desktop mode, save it as a `.glider` file, and diff --git a/docs-site/reference/ecosystem-reference.md b/docs-site/reference/ecosystem-reference.md index 39a18cd0..97314e90 100644 --- a/docs-site/reference/ecosystem-reference.md +++ b/docs-site/reference/ecosystem-reference.md @@ -59,12 +59,11 @@ 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 and a four-tab structure: +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: -- **Setup** loads/saves experiments and connects hardware. -- **Run** starts and stops a ready experiment and shows run state. -- **Manual** generates controls from a device's declared actions and ranges. -- **Camera** provides the camera and recording interface. +- **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. +- **Run** shows the per-run fields, the clock, and live device cards, and starts and stops a ready experiment. Runner does not create a separate kind of experiment. It is another view over the same experiment session and hardware model. Manual functions are deliberately prevented from overlapping an active experiment because both could drive the same hardware. diff --git a/docs-site/reference/troubleshooting.md b/docs-site/reference/troubleshooting.md index 922af3f9..fc997397 100644 --- a/docs-site/reference/troubleshooting.md +++ b/docs-site/reference/troubleshooting.md @@ -60,9 +60,10 @@ Open**, then confirm. You only need to do this once. See ## The Runner is stuck in kiosk mode Runner mode is one-mode-per-process by design. To get back to the desktop -editor, use the **Setup** tab's switch-to-desktop control, 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). +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). ## Still stuck? diff --git a/docs-site/runner/index.md b/docs-site/runner/index.md index b45b78da..ef5353d6 100644 --- a/docs-site/runner/index.md +++ b/docs-site/runner/index.md @@ -1,9 +1,10 @@ # Runner Mode Runner mode is GLIDER's touchscreen "kiosk" layout for running experiments at -the bench. It replaces the full desktop editor with four large, finger-friendly -tabs so an operator can load an experiment, drive hardware by hand, and start a -recorded run without a keyboard or mouse. +the bench. It replaces the full desktop editor with a compact header and three +finger-friendly tabs — **Experiment**, **Dashboard**, and **Run** — so an +operator can load an experiment, look it over, drive hardware by hand, and start +a recorded run without a keyboard or mouse. Runner mode is designed for a small touchscreen — typically a Raspberry Pi with the official 480×800 display — but it runs on any machine. GLIDER picks the @@ -12,14 +13,14 @@ command-line flag.
![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..08770b5f 100644 --- a/docs-site/runner/runner.md +++ b/docs-site/runner/runner.md @@ -1,154 +1,160 @@ # 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. +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. -!!! 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 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.
+
-## The Setup tab +## The header -Setup is the landing tab and your home base between runs. It's a single -scrollable page with four parts, top to bottom. +The header is on every page, so you never lose the essentials. It has two rows. -
- ![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.
-
+**Row one** carries, on the left: -### Status line +- 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, **● 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 + tab. -At the top, two short status readouts tell you whether you're ready to run: +and on the right: -- **Board:** either `✓` with the connected board's description, or - `✗ not connected`. -- **Experiment:** either `✓` with the experiment's name, or `✗ none loaded`. +- **One dot per board**, labelled with the board's ID — green when the board + is connected. +- The **⚙ menu** (below). -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. +**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 housekeeping menu (⚙) +### The ⚙ menu -The gear button in the top-right corner opens a small menu with three items: +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; when you close them, GLIDER connects every board (not while a run is live). | | **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** — anywhere on the bar, or the **✕** at its +right end. -| 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. +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. -### 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 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). | -!!! 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 +174,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 +200,108 @@ 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 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, + 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 (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, 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 +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. **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. 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 + 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 **● 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 + 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 diff --git a/src/glider/gui/dashboard/panels/field_tags.py b/src/glider/gui/dashboard/panels/field_tags.py index 9299e85d..9ac92700 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,17 @@ 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, + QSizePolicy, + QStyle, + QStyleOptionButton, + QWidget, +) _MAX_VALUE_CHARS = 40 @@ -34,22 +47,52 @@ 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) - 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 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. + self._trailing.setWordWrap(True) + self._trailing.setMinimumWidth(1) # --- public API --- @@ -95,8 +138,9 @@ def _rebuild(self) -> None: w.setParent(None) w.deleteLater() self._chips.clear() - for f in self._fields: - chip = QPushButton(self._chip_text(f)) + for i, f in enumerate(self._fields): + 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) @@ -104,8 +148,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 +179,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..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 @@ -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" @@ -57,6 +61,10 @@ def __init__(self, core: "GliderCore", parent=None): 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() @@ -68,11 +76,16 @@ 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) 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") @@ -197,7 +210,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/dialogs/experiment_dialog.py b/src/glider/gui/dialogs/experiment_dialog.py index c4fc33e4..2c14dc2f 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,28 @@ 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. 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) + 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..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) @@ -258,11 +268,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 +292,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..e3f1a2d5 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() @@ -443,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) @@ -464,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) @@ -555,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) @@ -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,17 @@ 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 + # 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 def get_zone_configuration(self) -> ZoneConfiguration: diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index 9c1855cf..dc742c1e 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -27,10 +27,12 @@ QMainWindow, QMenu, QMessageBox, + QScrollArea, QSizePolicy, QStackedWidget, QStatusBar, QToolBar, + QToolButton, QVBoxLayout, QWidget, ) @@ -53,6 +55,9 @@ 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 from glider.gui.shell import ( AppShell, @@ -75,12 +80,19 @@ 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") + +# 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 @@ -138,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 @@ -326,13 +338,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.""" @@ -344,11 +365,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( @@ -448,6 +475,18 @@ 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 + # 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 + 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. @@ -523,12 +562,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. @@ -536,9 +577,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 @@ -638,7 +676,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. @@ -657,10 +701,15 @@ def _setup_ui(self) -> None: self._create_builder_view() self._create_runner_view() - self._stack.addWidget(self._builder_view) # Index 0 -- PAGE_BUILDER - # Index 1 is the operator view: RunnerShell (runner) or DashboardView + 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: 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() @@ -680,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() @@ -709,6 +758,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") @@ -716,10 +768,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, @@ -728,6 +782,83 @@ 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 (in ``_on_pi_connect_boards``): 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…", self._on_pi_connect_boards), + 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 _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. @@ -756,6 +887,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 +921,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 +950,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 +973,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. @@ -1039,9 +1173,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) @@ -1138,7 +1275,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( @@ -1155,7 +1292,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: @@ -1202,13 +1342,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() @@ -1319,36 +1459,49 @@ 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 and CameraPanel that - the desktop docks re-host on a runner->desktop switch (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) - - # --- Shared Hardware panel (desktop docks re-host this same instance) --- + 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. 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) - # --- 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 ) @@ -1372,40 +1525,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) --- - self._runner_shell = RunnerShell( - self._core, - self._runner_setup_page, - self._runner_panel, - self._runner_device_controls, - 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. @@ -1443,25 +1589,21 @@ 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. - 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 @@ -1479,6 +1621,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 @@ -1494,7 +1648,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")) @@ -1512,22 +1666,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 @@ -1545,11 +1688,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 @@ -1612,6 +1774,82 @@ 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) + # 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) + + 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(self._on_pi_connect_boards) + 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: 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) + + # Finger scrolling: a drag on the content scrolls it, and the thin bars + # 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: + """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) + 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. @@ -2272,15 +2510,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": @@ -2291,13 +2530,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 @@ -2371,14 +2606,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; @@ -2424,8 +2657,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_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) @@ -2774,6 +3020,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 @@ -2875,22 +3124,43 @@ 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 + # 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) + 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() + 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 @@ -2904,18 +3174,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.""" @@ -2995,6 +3264,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: @@ -3056,6 +3326,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: @@ -3748,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 @@ -4202,7 +4473,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/node_graph/graph_view.py b/src/glider/gui/node_graph/graph_view.py index 6c951965..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 ( @@ -30,7 +31,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 +139,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 +215,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 +240,9 @@ 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 + self._touch = False # Zoom settings self._zoom = 1.0 @@ -228,6 +258,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 +279,44 @@ 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._touch = enabled + 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.""" @@ -454,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() @@ -463,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. @@ -518,7 +606,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 +617,11 @@ 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: """Handle mouse press for panning and connection creation.""" @@ -543,7 +636,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 +766,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 +796,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 +857,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 +869,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 +881,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/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/src/glider/gui/panels/experiment_page.py b/src/glider/gui/panels/experiment_page.py index b3f05630..805df4dc 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, @@ -61,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__) @@ -86,6 +91,10 @@ "plugins": "Plugins", } +#: 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 #: 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 @@ -98,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. @@ -107,28 +134,43 @@ 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._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 + # 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 +224,46 @@ 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. + + 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") + + 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 + + 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 def show_section(self, key: str) -> None: @@ -199,15 +281,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 +303,27 @@ 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) + # 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 - return widget + self._holders[key] = holder def reset(self) -> None: """Drop every built section so the next visit rebuilds against the @@ -238,14 +334,25 @@ 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("") + 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. @@ -262,9 +369,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/src/glider/gui/panels/hardware_panel.py b/src/glider/gui/panels/hardware_panel.py index 7d62e745..e3e2fd67 100644 --- a/src/glider/gui/panels/hardware_panel.py +++ b/src/glider/gui/panels/hardware_panel.py @@ -60,15 +60,18 @@ 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 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 @@ -87,10 +90,11 @@ 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: - 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/panels/node_library_panel.py b/src/glider/gui/panels/node_library_panel.py index ef749577..e40a6409 100644 --- a/src/glider/gui/panels/node_library_panel.py +++ b/src/glider/gui/panels/node_library_panel.py @@ -220,7 +220,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 +303,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 +337,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 +365,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 +473,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 +486,12 @@ 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 _on_node_tapped(self, node_type: str) -> None: + """Drop a node in the middle of the visible graph (a button click or tap). - def _add_node_to_center(self, node_type: str) -> None: - """Add a node to the center of the graph view.""" + 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/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/device_grid.py b/src/glider/gui/runner/device_grid.py new file mode 100644 index 00000000..8acff970 --- /dev/null +++ b/src/glider/gui/runner/device_grid.py @@ -0,0 +1,186 @@ +"""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) + # 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) + 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/finger_scroll.py b/src/glider/gui/runner/finger_scroll.py new file mode 100644 index 00000000..f96e86fe --- /dev/null +++ b/src/glider/gui/runner/finger_scroll.py @@ -0,0 +1,79 @@ +"""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, + QHeaderView, + QScroller, + QWidget, +) + +_GESTURE = QScroller.ScrollerGestureType.LeftMouseButtonGesture + + +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): + 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 _skipped(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/runner/notice_bar.py b/src/glider/gui/runner/notice_bar.py new file mode 100644 index 00000000..f4b430e5 --- /dev/null +++ b/src/glider/gui/runner/notice_bar.py @@ -0,0 +1,98 @@ +"""A one-line-or-two notice bar for the Pi's header. + +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, 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): + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("piNoticeBar") + self.setProperty("level", "info") + self._full = "" + self._label = QLabel() + 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.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._full = f"{title}: {message}" + self._label.setToolTip(self._full) + self.setProperty("level", level) + # 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. + for widget in (self, self._label, self._close): + restyle(widget) + self._fit() + self.show() + + def text(self) -> str: + 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() + super().mouseReleaseEvent(event) diff --git a/src/glider/gui/runner/pi_dashboard.py b/src/glider/gui/runner/pi_dashboard.py new file mode 100644 index 00000000..e6fbd51c --- /dev/null +++ b/src/glider/gui/runner/pi_dashboard.py @@ -0,0 +1,97 @@ +"""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, QTimer +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._graph = graph + self._fit_pending = True # frame the flow on the first show + + 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) + # 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) + 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]) + 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") + + 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/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/run_page.py b/src/glider/gui/runner/run_page.py new file mode 100644 index 00000000..9ae9b0c4 --- /dev/null +++ b/src/glider/gui/runner/run_page.py @@ -0,0 +1,115 @@ +"""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 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 +from glider.gui.runner.device_grid import DeviceCardGrid + +if TYPE_CHECKING: + 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() + 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 = _FitClock("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) + # 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) + + 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/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/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/src/glider/gui/shell/status_strip.py b/src/glider/gui/shell/status_strip.py index 32847575..e2ef5d36 100644 --- a/src/glider/gui/shell/status_strip.py +++ b/src/glider/gui/shell/status_strip.py @@ -63,14 +63,35 @@ 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. 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 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, @@ -113,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") @@ -126,6 +153,29 @@ #: 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. 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 = 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. +_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. +_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 +369,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 +430,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 +442,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 +476,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 +486,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 +510,44 @@ 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, 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: + 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.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) + # ------------------------------------------------------------ experiment def set_experiment(self, name: str, dirty: bool = False) -> None: @@ -435,12 +558,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.""" @@ -485,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 @@ -500,6 +626,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 +689,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..94b76971 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,36 @@ def __init__(self, parent: QWidget | None = None) -> None: self._buttons[key] = button 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.Ignored, 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); an Ignored + width 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/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..4d1a8305 100644 --- a/src/glider/gui/styles/touch.qss +++ b/src/glider/gui/styles/touch.qss @@ -1,807 +1,1046 @@ /* - * 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. 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 + * 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:checked { + background-color: rgba(56, 189, 248, 0.13); /* ACCENT @ 13% */ + border-color: rgba(56, 189, 248, 0.50); + color: #38bdf8; /* ACCENT */ } 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; + background-color: #111820; /* SURFACE_1 */ + border-color: #1e2530; + color: #718096; /* TEXT_MUTED: still legible */ } -/* 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; +QPushButton[primary="true"] { + background-color: #38bdf8; /* ACCENT */ + border-color: #38bdf8; + color: #0a0e13; /* CANVAS: dark ink on cyan */ } -QLabel#runnerStatusStrip[level="error"] { - background-color: rgba(248, 113, 113, 0.20); - color: #f87171; + +QPushButton[primary="true"]:pressed { + background-color: rgba(56, 189, 248, 0.80); } -QLabel#runnerStatusStrip[level="warn"] { - background-color: rgba(251, 191, 36, 0.20); - color: #fbbf24; + +QPushButton[secondary="true"], +QPushButton[buttonRole="secondary"] { + background-color: transparent; + color: #94a3b8; /* TEXT_TERTIARY */ } -QLabel#runnerStatusStrip[level="info"] { - background-color: rgba(96, 165, 250, 0.18); - color: #93c5fd; + +QPushButton[secondary="true"]:pressed, +QPushButton[buttonRole="secondary"]:pressed { + background-color: #111820; /* SURFACE_1 */ } -/* Primary action button */ -QPushButton[primary="true"] { - background-color: #34d399; +/* 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 */ } -QPushButton[primary="true"]:pressed { - background-color: #22a57a; +QToolButton:pressed { + background-color: #111820; /* SURFACE_1 */ } -/* Danger button */ -QPushButton[danger="true"] { - background-color: rgba(248, 113, 113, 0.2); - color: #f87171; - border: 2px solid #f87171; +QToolButton:checked { + background-color: rgba(56, 189, 248, 0.13); /* ACCENT @ 13% */ + border-color: rgba(56, 189, 248, 0.50); + color: #38bdf8; /* ACCENT */ } -QPushButton[danger="true"]:pressed { - background-color: rgba(248, 113, 113, 0.35); +QToolButton:disabled { + color: #718096; /* TEXT_MUTED */ } -/* 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; } -/* === Spin Box === */ +QComboBox QAbstractItemView::item { + min-height: 44px; + padding: 0 12px; +} + +/* Spin boxes put a finger-sized − and + either side of the value, rather than + * 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 { - background-color: #0f1419; - border: 2px solid #1e2530; - border-radius: @RADIUS_MEDIUM@; - padding: 8px; - font-size: 16px; - min-height: 40px; + padding: 0 4px; + min-width: 110px; + 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 */ } -QComboBox:focus { +QCheckBox::indicator { + border-radius: @RADIUS_SMALL@; +} + +QRadioButton::indicator { + border-radius: 12px; /* geometric: a circle, 22 px + 1 px border */ +} + +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 */ } -/* === Progress Bar === */ +QSlider::handle:vertical { + width: 40px; + height: 40px; + margin: 0 -18px; + background-color: #e2e8f0; + border: 2px solid #38bdf8; + border-radius: 22px; /* geometric */ +} + +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; } -/* === Tab Widget === */ +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; +} + +/* === 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 (or ✕) 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 QToolButton#piNoticeClose { + background-color: transparent; border: none; + font-size: 16px; + padding: 0; } -QPushButton[runnerAction="start"]:pressed { - background-color: #22a57a; +QFrame#piNoticeBar[level="info"] { + background-color: rgba(56, 189, 248, 0.10); /* ACCENT @ 10% */ + border-bottom-color: rgba(56, 189, 248, 0.30); } -QPushButton[runnerAction="stop"] { - background-color: #718096; - color: #e2e8f0; - font-size: 20px; - font-weight: bold; - border-radius: @RADIUS_LARGE@; - border: none; +QFrame#piNoticeBar[level="info"] QLabel, +QFrame#piNoticeBar[level="info"] QToolButton#piNoticeClose { + color: #38bdf8; /* ACCENT */ } -QPushButton[runnerAction="stop"]:pressed { - background-color: #5a6878; +QFrame#piNoticeBar[level="warning"] { + background-color: rgba(251, 191, 36, 0.12); /* WARNING @ 12% */ + border-bottom-color: rgba(251, 191, 36, 0.35); } -QPushButton[runnerAction="start"]:disabled, -QPushButton[runnerAction="stop"]:disabled { - background-color: #2d3748; - color: #718096; +QFrame#piNoticeBar[level="warning"] QLabel, +QFrame#piNoticeBar[level="warning"] QToolButton#piNoticeClose { + color: #fbbf24; /* WARNING */ } -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; +QFrame#piNoticeBar[level="critical"] { + background-color: rgba(248, 113, 113, 0.18); /* ERROR @ 18% */ + border-bottom-color: rgba(248, 113, 113, 0.45); } -QPushButton[runnerAction="emergency"]:pressed { - background-color: rgba(248, 113, 113, 0.35); +QFrame#piNoticeBar[level="critical"] QLabel { + color: #f87171; /* ERROR */ + font-weight: 600; } -/* === Runner View === */ -QWidget[runnerHeader="true"] { - background-color: #0b0f14; - border-radius: @RADIUS_LARGE@; +QFrame#piNoticeBar[level="critical"] QToolButton#piNoticeClose { + color: #f87171; /* ERROR */ } -/* === 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; +/* ============================================================================ + * 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); } -QWidget[runnerRunBanner="true"] QLabel[timer="true"] { - color: #34d399; +QTabWidget#piDashboardTabs::pane { + border: none; + background-color: #0f1419; /* BASE */ } -/* === Readiness Strip (Dashboard) === */ -QWidget[readinessStrip="true"] { - background-color: transparent; +QTabWidget#piDashboardTabs QTabBar { + background-color: #0b0f14; /* CHROME */ } -/* 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@; +QTabWidget#piDashboardTabs QTabBar::tab { + background-color: #0b0f14; /* CHROME */ + color: #94a3b8; /* TEXT_TERTIARY */ border: none; - text-align: left; - padding-left: 16px; + border-bottom: 1px solid #1e2530; /* BORDER */ + padding: 0 4px; + height: 47px; + min-width: 0; + font-size: 14px; + font-weight: 500; } -QPushButton[readinessRow="blocked"]:pressed { - background-color: #e0a91a; +QTabWidget#piDashboardTabs QTabBar::tab:selected { + color: #38bdf8; /* ACCENT */ + border-bottom: 2px solid #38bdf8; + height: 46px; } -/* Satisfied gate: slim green confirmation — no longer needs a tap. */ -QPushButton[readinessRow="ok"] { - background-color: rgba(52, 211, 153, 0.12); - color: #34d399; +/* 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: bold; - min-height: 32px; - max-height: 32px; + font-weight: 600; + padding: 0 16px; + min-height: 42px; +} + +/* 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#graphLockButton:disabled { + color: #718096; /* TEXT_MUTED */ +} + +/* ============================================================================ + * 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; +} + +QLabel#piRunClock[live="true"] { + color: #34d399; /* SUCCESS */ +} + +/* Run fields: tap to edit in place. */ +QPushButton[fieldTag="true"] { + background-color: #111820; /* SURFACE_1 */ + border: 1px solid #1e2530; /* BORDER */ border-radius: @RADIUS_MEDIUM@; - border: 1px solid rgba(52, 211, 153, 0.3); - text-align: left; - padding-left: 16px; + 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 */ +} + +QPushButton[fieldTag="true"][locked="true"] { + background-color: transparent; + color: #94a3b8; /* TEXT_TERTIARY */ +} + +QLineEdit[fieldTagEditor="true"] { + border-color: #38bdf8; /* ACCENT */ } -QPushButton[readinessRow="ok"]:pressed { - background-color: rgba(52, 211, 153, 0.2); +/* 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; } -QWidget[runnerControls="true"] { - background-color: #0b0f14; +QLabel[statusState="READY"] { + background-color: rgba(52, 211, 153, 0.13); /* SUCCESS @ 13% */ + color: #34d399; +} + +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; +} + +/* 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/dashboard/panels/test_field_tags.py b/tests/unit/gui/dashboard/panels/test_field_tags.py index 4b8f89f9..0df4e1f8 100644 --- a/tests/unit/gui/dashboard/panels/test_field_tags.py +++ b/tests/unit/gui/dashboard/panels/test_field_tags.py @@ -99,3 +99,128 @@ 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) + + +_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] + # 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. "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"] + + +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/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/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 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/panels/test_node_library_tap.py b/tests/unit/gui/panels/test_node_library_tap.py new file mode 100644 index 00000000..b793f239 --- /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) + 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; 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) + assert sig.args[0] == "Delay" + + +def test_tap_is_refused_while_the_graph_is_locked(qtbot): + 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) + 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" 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..52804a36 --- /dev/null +++ b/tests/unit/gui/runner/test_notice_bar.py @@ -0,0 +1,116 @@ +import pytest +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QVBoxLayout, QWidget + +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 _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() + 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): + 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_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" 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..659838a0 --- /dev/null +++ b/tests/unit/gui/runner/test_pi_dashboard_window.py @@ -0,0 +1,187 @@ +"""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 + +import pytest +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 + + +@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) + 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) + (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( + 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() + # 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): + 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") + + +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() 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..7f34c505 --- /dev/null +++ b/tests/unit/gui/runner/test_pi_header.py @@ -0,0 +1,109 @@ +"""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)) + 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] + + +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 + + +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 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..c88c7a78 --- /dev/null +++ b/tests/unit/gui/runner/test_pi_run_window.py @@ -0,0 +1,184 @@ +"""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 + +import pytest +from PyQt6.QtCore import QPoint +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 + + +@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(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 = [] + 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" + + +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() 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_run_page.py b/tests/unit/gui/runner/test_run_page.py new file mode 100644 index 00000000..21b59097 --- /dev/null +++ b/tests/unit/gui/runner/test_run_page.py @@ -0,0 +1,159 @@ +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.hardware_manager.connected_board_description = lambda: "Arduino Uno" + 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"] + + +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 + + +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/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/runner/test_runner_shell_integration.py b/tests/unit/gui/runner/test_runner_shell_integration.py deleted file mode 100644 index 71cf0b15..00000000 --- a/tests/unit/gui/runner/test_runner_shell_integration.py +++ /dev/null @@ -1,80 +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 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. - -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) - - -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/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/shell/test_touch_chrome.py b/tests/unit/gui/shell/test_touch_chrome.py new file mode 100644 index 00000000..870a8830 --- /dev/null +++ b/tests/unit/gui/shell/test_touch_chrome.py @@ -0,0 +1,190 @@ +"""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 STRIP_HEIGHT, 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 + + +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 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): diff --git a/tests/unit/gui/test_device_card_link_status.py b/tests/unit/gui/test_device_card_link_status.py index 4b41d4c2..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 @@ -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"]) 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..08b48029 --- /dev/null +++ b/tests/unit/gui/test_experiment_page_compact.py @@ -0,0 +1,108 @@ +"""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 + # 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 + + +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 PyQt6.QtWidgets import QBoxLayout + + assert editor._shape_layout.direction() == QBoxLayout.Direction.LeftToRight 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 ( diff --git a/tests/unit/gui/test_touch_stylesheet.py b/tests/unit/gui/test_touch_stylesheet.py new file mode 100644 index 00000000..234b67d0 --- /dev/null +++ b/tests/unit/gui/test_touch_stylesheet.py @@ -0,0 +1,289 @@ +"""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 ( + QAbstractItemView, + QAbstractScrollArea, + QScrollArea, + QScroller, + QTreeView, +) + +from glider.gui.styles import colors, load_stylesheet + +_EXAMPLE = Path(__file__).resolve().parents[3] / "examples" / "box_pi_side.glider" + + +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) + # 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() + + +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 _scroll_areas(panel): + """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 _skipped(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 + 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, + **{key: page.built_sections()[key] for key in ("metadata", "mice", "vocabulary", "zones")}, + } + found = { + key: [QScroller.hasScroller(area.viewport()) for area in _scroll_areas(panel)] + 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): + 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] + # 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 + # 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()) + + +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() + 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")