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 is only the first kind of panel; a device control form or a run status board is the same contract in the same window.

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.

MonitorPanel is the bridge to the monitor framework: 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.

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

image

heat map, scaled onto its physical axes via the payload’s x/y

scalar, text

caption line under the plot

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

See also