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

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:

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:

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