# Shell Framework `plesty.lib.ui` is the GUI framework of the platform: **one home window that hosts any number of rearrangeable sub-windows**. It is deliberately generic — a live view (`MonitorPanel`) and a device's controls (`DevicePanel`) are the same contract in the same window, and a run status board would be a third. The framework is toolkit-free down to the last layer. `Panel`, `Theme`, and `MonitorPanel` import without a GUI installed; only the Qt backend needs the `gui` extra: ```bash uv add "plesty-lib[gui]" # PySide6 + pyqtgraph ``` ## The home window ```python from plesty.lib.ui import MonitorPanel, run run( [ MonitorPanel(spectrum, area="top", min_height=260), MonitorPanel(spectral_map, area="bottom", min_height=260), MonitorPanel(power_series, area="right", min_width=360), ], title="QD-FSS", interval_ms=500, ) ``` What the operator can then do, without any further code: - **Move** a panel to another edge, or drop it onto another panel to tab the two together. - **Resize** by dragging the handle between panels; panels nest in both directions, so any split is reachable. - **Drag a panel out** of the home window into a free-floating window — onto a second screen, for instance — and **drag it back in** later. The `⤢` button and a double click on the title bar do the same thing. - **Close** a panel and bring it back from the *Panels* menu. - **Keep the arrangement**: it is saved on exit and restored next time, per window title, under `~/.plesty/ui/`. *Layout → Reset to defaults* undoes an arrangement that went wrong. Every panel is ticked on the shell's clock (`interval_ms`), which is the only clock in the application: monitors drain their sources there, so nothing polls in the background. A panel that raises is caught, and the error is shown in that panel's title bar — one broken view never blinds the rest. ## Panels A `Panel` is anything worth a sub-window. The contract is small on purpose: ```python class RunStatusPanel(Panel): kind = "status" def build(self, parent): # called once; toolkit imports live here from PySide6.QtWidgets import QLabel self._label = QLabel("waiting…", parent) return self._label def tick(self): # called on the shell's clock self._label.setText(self.journal.status()["state"]) def status(self): # shown in the title bar return f"{self.journal.status()['completed']} steps" ``` Placement (`area`, `floating`, `closable`, `min_width`, `min_height`) is declared at construction and is only the *initial* arrangement — the operator owns it from there. `status()` is the line in the title bar and `tone()` the colour of its dot — `running`, `idle`, `warning`, `error`. The default reads a panel as working whenever it has something to say, which is right for a view that only draws; a panel that can fail *while still running* overrides it, so a device that stopped answering is red from across the room. `MonitorPanel` is the bridge to the older `Monitor` classes (a viewer's plots use `PlotPanel`; see [Viewing a Run](index.md)): it attaches a `PlotRenderer` to a monitor when it is built, and ticks it afterwards. Constructing and describing it needs no GUI, so a shell's composition is testable headless. ## Device panels A device already describes its controls completely, so `DevicePanel` builds them instead of asking anyone to write them again: ```python from plesty.lib.ui import DevicePanel, LocalHandle, run run([DevicePanel(LocalHandle(spectrometer), name="cct", include=("exposure", "hardware_average"))]) ``` Every widget comes from the schema. Nothing is hand-placed, and nothing is hand-bounded: | The device declares | The panel renders | |---|---| | `dtype` `float`/`int` | spin box, adaptive step | | `min_value` / `max_value` | its range — and where the device declares none, none | | `options` | drop-down | | `dtype` `bool` | check box | | `read_only` | a readout, not an editor | | `write_only` | an editor that is never polled | | `unit` | the editor's suffix | | `description` | the tooltip | | an operation | a button, with a small form when it takes arguments | That table is the whole point. A panel written by hand for one spectrometer bounded its exposure at `0.01–60000 ms` where the device declares no upper bound at all, and its hardware averaging at `1–1000` where the device declares no limit — numbers that exist nowhere in the instrument, constraining whoever used it. `controls_for()` derives them, is free of any toolkit, and is tested without a display, so a drift like that fails a test instead of reaching a bench. **A result is shown as a reading, not as what the wire carried.** An operation answers with a mapping; `plesty.lib.ui.readings` renders it from the outputs the device declared, so a panel shows what an instrument would: | The device returns | The panel shows | |---|---| | `{'power': 1.478103e-06}` | `1.478 uW` | | `{'energy': 0.0}` | `0 J` | | `{'sensor_info': {'name': 'S130C', 'serial_number': 'MOCK0001', …}}` | `name: S130C, serial number: MOCK0001, …` | A single output drops its key — the button already says what was measured. Metric prefixes are applied only to base units (`W`, `A`, `Hz`, …): `mm` and `nm` carry a prefix already, and scaling one would give `umm`. **What stays out.** A spectrum, a beam image, a stage map are not derivable from a schema — those are monitors, and a `MonitorPanel` docks beside this one. A long sequence in a result is summarised rather than dumped into a label, for the same reason. Operations declared `lifecycle` (homing, reset, replacing a dark reference) are left off unless named in `operations=`: they should not be one click away. **A panel starts read-only.** A monitor holds no device; this one necessarily does, and the instrument may be driven by a running experiment. Editors and buttons stay disabled behind the panel's *Take control* toggle (`set_writable()`). ### Where the device is `DevicePanel` never talks to a device directly — it drives a `DeviceHandle`, which is four calls: `describe()`, `query(key)`, `write(key, value)` and `call(op, **kwargs)`. That is what lets one panel serve three places: | | The handle | |---|---| | A module's own GUI | `LocalHandle(device)` | | A console beside the device server | `DeviceTCPIPClient` — it satisfies the protocol as written | | Another machine | the same four calls, relayed by the bench agent | `describe` carries the device's serialized `DeviceDocModel` — units, bounds, options, descriptions — beside the method list it always returned, so a caller that never imports plesty-lib reads what the device declared instead of inventing it. A server older than that payload answers with method names only, and the panel says so rather than showing an empty form. `LocalHandle` goes through the same serialized payload the wire uses. That is deliberate: a module's own GUI then exercises exactly what the console and a remote client will see, so a hole in the payload shows up at the bench and not only across a network. ### Why it never reads on the GUI thread Panels are ticked on the shell's clock, and a device read is a round trip to hardware. Every call therefore goes to a `DeviceWorker` — one thread per panel — and every result comes back through a queue the panel drains on its next tick, so the shell's own clock does the hand-over and no Qt signal is involved. One worker *per panel*, not per shell: a device server that has died answers nothing until its client times out, and a shared worker would let that one device stall every panel in the window. Requests are coalesced by tag, so a device slower than the refresh interval drops the polls it cannot keep up with instead of accumulating a backlog and showing readings from minutes ago. ## Theme Colours are data, not code. `Theme` reads `assets/palette.json` and renders `assets/shell.qss`, whose placeholders it substitutes at load time: ```python theme = Theme.load() theme.color("cyan") # "#22d3ee" — a design token, not a literal theme.series_color(2) # nth trace of a view theme.state_color("error") # "#fb7185" ``` The tokens mirror the plesty.net stylesheet and the canonical module colours, so the website, the generated reports, and the lab GUI stay one visual system. A rig that wants its own palette passes a JSON file to `Theme.load()`; nothing needs patching. Widget chrome belongs in the `.qss` asset, never in a Python string — the same rule the report and tutorial generators follow. ## Rendering `PlotRenderer` (pyqtgraph) draws the trace kinds a monitor may declare: | Kind | Drawn as | |---|---| | `line`, `scatter` | curve, optionally with markers; named in the legend in its own column right of the plot | | `image` | heat map, scaled onto its physical axes via the payload's `x`/`y`, with a colour bar | | `scalar` | on one caption line under the plot | | `text` | each on its own caption line, its label in the trace's colour; an empty text is left out | The colour range of a map ends at the brightest value that the neighbouring rows confirm, not at the brightest pixel: a map's rows are its sweep positions, so a line runs through many rows while a cosmic ray hits one, and the ray saturates instead of darkening every line. pyqtgraph is used because a spectrum arriving every few hundred milliseconds for hours has to redraw without the operator noticing. ## Recording a run A measurement that took two hours is shown to a colleague as a video of the run building up. Recording belongs to the window, not to a view, so whatever is docked is captured — including a panel added or floated halfway through: ```python shell.start_recording("runs/qd_fss_20260804.mp4", fps=10) ... path = shell.stop_recording() # also called when the window closes ``` The *Record* menu does the same thing without code. MP4 needs the `record` extra (`uv add "plesty-lib[record]"`); with Pillow alone the recorder writes an animated GIF rather than nothing. `every=N` captures one frame per N refreshes, for a run whose rows arrive faster than a video needs them. ## Testing The toolkit-free half of the framework is tested like any other module. The Qt half runs on Qt's `offscreen` platform, so it needs no display — and is skipped where the `gui` extra is not installed, which is why CI (and any headless lab host) still runs the full suite: ```bash QT_QPA_PLATFORM=offscreen uv run pytest tests/test_ui.py tests/test_device_panel.py ``` A device panel is tested the same way, and further: `open()` starts its worker without building a widget, so the whole device-facing half — the schema arriving, polling, a refused write, a device that stops answering — is driven with no display and no toolkit at all. ## See also - [Viewing a Run](index.md) — what `MonitorPanel` shows.