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:
uv add "plesty-lib[gui]" # PySide6 + pyqtgraph
The home window
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:
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): 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:
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 |
|---|---|
|
spin box, adaptive step |
|
its range — and where the device declares none, none |
|
drop-down |
|
check box |
|
a readout, not an editor |
|
an editor that is never polled |
|
the editor’s suffix |
|
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 |
|---|---|
|
|
|
|
|
|
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 |
|
A console beside the device server |
|
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:
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 |
|---|---|
|
curve, optionally with markers; named in the legend in its own column right of the plot |
|
heat map, scaled onto its physical axes via the payload’s |
|
on one caption line under the plot |
|
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:
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:
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 — what
MonitorPanelshows.