plesty.lib.ui.qt

PySide6 backend of the PLESTY shell.

Importing this package requires the gui extra (uv add "plesty-lib[gui]"): PySide6 and pyqtgraph. Everything above it — Panel, Theme, MonitorPanel — imports without a GUI installed, so headless hosts and CI keep working.

Submodules

Classes

DeviceForm

The widget tree for one DevicePanel.

PlotRenderer

Draw target that renders a monitor's traces into a pyqtgraph widget.

Recorder

Capture a window while active; encode a video file on stop.

DockPanel

One sub-window of the shell, wrapping a Panel.

PanelTitleBar

Title bar of a sub-window: state dot, title, live status, controls.

Shell

Home window hosting every panel as a rearrangeable sub-window.

Functions

application(→ PySide6.QtWidgets.QApplication)

Return the running Qt application, creating it when there is none.

build_shell(→ plesty.lib.ui.qt.shell.Shell)

Create a shell hosting panels, without starting the event loop.

run(→ int)

Open a shell on panels and run it until the window is closed.

Package Contents

plesty.lib.ui.qt.application(name: str = 'PLESTY') → PySide6.QtWidgets.QApplication

Return the running Qt application, creating it when there is none.

Parameters:

name (str) – Application name, used by the window manager.

Returns:

The QApplication instance.

Return type:

PySide6.QtWidgets.QApplication

plesty.lib.ui.qt.build_shell(panels: Sequence[plesty.lib.ui.panel.Panel], *, title: str = 'PLESTY', theme: plesty.lib.ui.theme.Theme | None = None, interval_ms: int = 500, layout_file: str | pathlib.Path | None = None, remember_layout: bool = True) → plesty.lib.ui.qt.shell.Shell

Create a shell hosting panels, without starting the event loop.

Parameters:
  • panels (Sequence[plesty.lib.ui.panel.Panel]) – Panels to add, in the order they should be docked.

  • title (str) – Window title and layout key.

  • theme (Optional[plesty.lib.ui.theme.Theme]) – Palette; None loads the packaged PLESTY theme.

  • interval_ms (int) – Panel refresh interval in milliseconds.

  • layout_file (Optional[str | pathlib.Path]) – Where the arrangement is stored.

  • remember_layout (bool) – Restore and save the arrangement.

Returns:

The shell, ready for start().

Return type:

plesty.lib.ui.qt.shell.Shell

plesty.lib.ui.qt.run(panels: Sequence[plesty.lib.ui.panel.Panel], *, title: str = 'PLESTY', theme: plesty.lib.ui.theme.Theme | None = None, interval_ms: int = 500, layout_file: str | pathlib.Path | None = None, remember_layout: bool = True) → int

Open a shell on panels and run it until the window is closed.

Parameters:
  • panels (Sequence[plesty.lib.ui.panel.Panel]) – Panels to add, in the order they should be docked.

  • title (str) – Window title and layout key.

  • theme (Optional[plesty.lib.ui.theme.Theme]) – Palette; None loads the packaged PLESTY theme.

  • interval_ms (int) – Panel refresh interval in milliseconds.

  • layout_file (Optional[str | pathlib.Path]) – Where the arrangement is stored.

  • remember_layout (bool) – Restore and save the arrangement.

Returns:

The Qt exit code.

Return type:

int

class plesty.lib.ui.qt.DeviceForm(panel: plesty.lib.ui.device_panel.DevicePanel, parent: PySide6.QtWidgets.QWidget | None = None)

The widget tree for one DevicePanel.

The form owns no state worth reading back: the panel holds the values and tells the form what to show. It is rebuilt whole when the controls arrive, which happens once, after describe answers.

Build the empty form.

Parameters:
panel
widget
editors: dict[str, PySide6.QtWidgets.QWidget]
readouts: dict[str, _Readout]
labels: dict[str, PySide6.QtWidgets.QWidget]
_holds: dict[str, _Hold]
arguments: dict[str, dict[str, PySide6.QtWidgets.QWidget]]
_results: dict[str, _Readout]
_outputs: dict[str, tuple[plesty.lib.ui.device_panel.Control, Ellipsis]]
_editing: set[str]
groups: dict[str, Any]
banner
control_toggle
refresh_button
scroll
body
stack
plain
form
groups_holder
_groups_stack
ops_heading
ops_holder
ops
group_forms: dict[str, _FlowForm]
_columns = 1
set_controls(controls: list[plesty.lib.ui.device_panel.Control]) → None

Replace the form’s rows with controls.

A device that declares configuration groups gets one box per group, titled with it. Flat, the groups are invisible: a motor’s zero and a detector’s sit side by side under labels that do not say which is which, and the panel is as long as the sum of them. The box is also what lets a label stay the parameter’s own name, since the group is written once above it rather than into every row.

Parameters and operations alike are laid out in as many columns as the panel is wide enough for, and re-placed when it is resized.

Parameters:

controls (list[plesty.lib.ui.device_panel.Control]) – What the device declared, already derived.

Return type:

None

_width_changed(width: int) → None

Re-place the controls when the body’s width asks for another column.

Parameters:

width (int)

Return type:

None

columns_for(width: int) → int

How many label-and-field columns width has room for.

Parameters:

width (int) – The body’s width in pixels.

Returns:

At least one, at most MAX_COLUMNS.

Return type:

int

_relayout(width: int | None = None, force: bool = False) → None

Lay every flow out for the current width.

Only the device’s own section is laid out from this width. Each group box reports its own — it is narrower than the panel by the margins around it, and Qt has not resized it yet when the panel’s resize arrives, so asking it here lays it out one resize behind.

Parameters:
  • width (Optional[int]) – The body’s width; its current one when None.

  • force (bool) – Re-place even where the column count did not change.

Return type:

None

_group_resized(group: str, width: int) → None

Lay the group group out for the width its box has now.

Parameters:
  • group (str)

  • width (int)

Return type:

None

_group_form(group: str) → _FlowForm

Add a titled box for the configuration group group; return its flow.

Its labels sit against the box’s left edge, unlike the form outside it. Right-aligned they hang off the box’s own title, and the wider the box grows the further the parameter names drift from the group they belong to. Against the edge, the title and the parameters under it line up.

Parameters:

group (str)

Return type:

_FlowForm

set_value(key: str, value: Any) → None

Show value for control key.

A widget the operator is typing in is left alone — a reading that arrives mid-edit must not take the keyboard out of their hands.

Parameters:
  • key (str) – The control’s key.

  • value (Any) – What the device reported.

Return type:

None

set_writable(writable: bool) → None

Enable or disable every editor and button.

Parameters:

writable (bool) – Whether the operator may drive the device.

Return type:

None

set_result(op: str, value: Any) → None

Show what an operation returned, as a reading rather than a mapping.

The device declared what it returns, so the result is rendered from that: no braces, no key repeating the button that was just pressed, and a magnitude an instrument would recognise.

Parameters:
  • op (str) – Operation name.

  • value (Any) – Its result.

Return type:

None

set_error(message: str) → None

Show message above the form.

Parameters:

message (str) – What failed; empty hides the banner.

Return type:

None

_label(control: plesty.lib.ui.device_panel.Control) → PySide6.QtWidgets.QLabel

Return the caption for one control, which is also how it is read.

Parameters:

control (plesty.lib.ui.device_panel.Control)

Return type:

PySide6.QtWidgets.QLabel

_toggle_live(key: str, arguments: Any = None) → None

Start or stop re-reading one control, and say which it is.

Parameters:
  • key (str)

  • arguments (Any)

Return type:

None

_show_label(key: str) → None

Mark a control that is being re-read, and say what a press does.

Parameters:

key (str)

Return type:

None

_readout(control: plesty.lib.ui.device_panel.Control) → PySide6.QtWidgets.QWidget

Return a display-only field for a parameter the device reports.

Parameters:

control (plesty.lib.ui.device_panel.Control)

Return type:

PySide6.QtWidgets.QWidget

_editor(control: plesty.lib.ui.device_panel.Control) → PySide6.QtWidgets.QWidget

Return the editor a control asks for, wired to write when edited.

Parameters:

control (plesty.lib.ui.device_panel.Control)

Return type:

PySide6.QtWidgets.QWidget

_commit(key: str, value: Any) → None

Send an edited value to the device and let polling refresh it again.

Clearing the mark here and not on the write’s reply is deliberate: the panel re-reads the parameter as soon as the write returns, and that reading — whatever the instrument actually took, which may not be what was asked for — is the one that should land in the widget.

Parameters:
  • key (str) – The parameter that was edited.

  • value (Any) – What the operator entered.

Return type:

None

_spin(control: plesty.lib.ui.device_panel.Control) → PySide6.QtWidgets.QWidget

Return a spin box bounded by what the device declared, and no more.

Parameters:

control (plesty.lib.ui.device_panel.Control)

Return type:

PySide6.QtWidgets.QWidget

_action_row(control: plesty.lib.ui.device_panel.Control) → PySide6.QtWidgets.QWidget

Return one operation as a box of its own: button, arguments, reading.

Operations were laid out against the parameters’ columns, and they do not fit them: a button, a couple of arguments and a reading are not a caption and an editor. Aligned they were ragged; boxed, the border is what separates one operation from the next, and the reading takes the width the box has rather than a parameter editor’s cap.

Parameters:

control (plesty.lib.ui.device_panel.Control)

Return type:

PySide6.QtWidgets.QWidget

_repeat(op: str) → None

Start or stop repeating an operation with the arguments it has now.

Parameters:

op (str)

Return type:

None

_run(op: str) → None

Collect an operation’s arguments and ask the panel to run it.

An argument left unset is left out, so the device applies its own default. Sending the floor instead would be sending a value: a spin box always holds a number, and an untouched timeout went out as a zero-second budget the device duly timed out on.

Parameters:

op (str) – Operation name.

Return type:

None

class plesty.lib.ui.qt.PlotRenderer(theme: plesty.lib.ui.theme.Theme | None = None, parent: PySide6.QtWidgets.QWidget | None = None)

Bases: plesty.lib.monitor.base_monitor.Renderer

Draw target that renders a monitor’s traces into a pyqtgraph widget.

The renderer owns its widget (widget), so a panel builds one and hands the widget to the shell:

renderer = PlotRenderer(theme)
monitor.attach(renderer)      # builds the plot items, replays the last data
shell_panel_widget = renderer.widget

Create the plot widget.

Parameters:
  • theme (Optional[plesty.lib.ui.theme.Theme]) – Palette to resolve trace colours and chrome against; None loads the packaged PLESTY theme.

  • parent (Optional[PySide6.QtWidgets.QWidget]) – Parent widget.

theme
widget
plot
caption
_layout
_items: dict[str, Any]
_images: dict[str, pyqtgraph.ImageItem]
_colorbars: dict[str, pyqtgraph.ColorBarItem]
_texts: dict[str, tuple[str, str]]
_names: dict[str, str]
_named: list[str] = []
_colormap
_lut
setup(monitor: plesty.lib.monitor.base_monitor.Monitor) → None

Build one plot item per declared trace.

Parameters:

monitor (plesty.lib.monitor.base_monitor.Monitor) – The monitor that was attached.

Return type:

None

draw(monitor: plesty.lib.monitor.base_monitor.Monitor, data: dict[str, plesty.lib.monitor.base_monitor.TraceData]) → None

Feed new payloads into the plot items.

Parameters:
Return type:

None

clear(monitor: plesty.lib.monitor.base_monitor.Monitor) → None

Empty every plot item without rebuilding them.

Parameters:

monitor (plesty.lib.monitor.base_monitor.Monitor) – The monitor whose view is being reset.

Return type:

None

_style_plot() → None

Apply the theme to the plot background, axes, and grid.

Return type:

None

_style_colorbar(bar: pyqtgraph.ColorBarItem, label: str) → None

Apply the theme to a colour bar and name what it measures.

Parameters:
  • bar (pyqtgraph.ColorBarItem) – The bar beside an image trace.

  • label (str) – The trace’s label, which names the quantity the colours stand for.

Return type:

None

_place_legend(wanted: bool) → None

Put the legend in its own column right of the plot, or take it away.

A legend floating inside the plot covers the data it names, and a corner that is empty for one frame is full for the next. In the plot item’s layout it takes its width from the plot instead. The plot item adds every named curve to item.legend, so that is where it goes.

Parameters:

wanted (bool) – Whether the view has curves to name.

Return type:

None

_sync_legend() → None

Show a legend entry for every named curve that has data, in declaration order.

A view declares its traces once, before any data, often more than it will fill — a slot per line it might find. An entry for a curve with nothing drawn would name a line that is not there.

Return type:

None

_drop_colorbars() → None

Remove every colour bar from the plot item’s layout.

Return type:

None

_build_item(spec: plesty.lib.monitor.base_monitor.TraceSpec, index: int) → None

Create the plot item declared by one trace spec.

Parameters:
Return type:

None

_draw_curve(key: str, payload: plesty.lib.monitor.base_monitor.TraceData) → None

Update one line or scatter trace.

Parameters:
Return type:

None

_draw_image(key: str, payload: plesty.lib.monitor.base_monitor.TraceData, scale: str = 'linear') → None

Update one image trace, scaling it onto its physical axes.

Parameters:
Return type:

None

_draw_text(key: str, spec: plesty.lib.monitor.base_monitor.TraceSpec, payload: plesty.lib.monitor.base_monitor.TraceData) → None

Record a scalar or text trace for the caption under the plot.

The label is written in the trace’s colour, so a text that belongs to a curve — a fitted line’s result — reads as that curve’s. An empty text takes the entry out of the caption.

Parameters:
Return type:

None

_caption_html() → str

Return the caption: scalar readings on one line, then each text on its own line.

Return type:

str

class plesty.lib.ui.qt.Recorder(widget: PySide6.QtWidgets.QWidget, path: str | pathlib.Path, *, fps: int = DEFAULT_FPS, every: int = 1)

Capture a window while active; encode a video file on stop.

shell.start_recording("run.mp4", fps=10)
...                                  # the shell captures on every tick
path = shell.stop_recording()        # → run.mp4, or run.gif without ffmpeg

Prepare a recording of widget.

Parameters:
  • widget (PySide6.QtWidgets.QWidget) – The window to capture — normally the shell itself.

  • path (str | pathlib.Path) – Destination file; the suffix is corrected to what the available encoder can actually write.

  • fps (int) – Frame rate of the encoded video.

  • every (int) – Capture one frame per every refreshes, for a run whose rows arrive faster than a video needs them.

Raises:

ValueError – If fps or every is not positive.

widget
path
fps = 10
every = 1
active = False
frames: list[numpy.ndarray] = []
_shape: tuple[int, int] | None = None
_seen = 0
start() → None

Begin capturing; a restart discards what was captured before.

Return type:

None

capture() → None

Capture one frame, honouring every; ignored when inactive.

Return type:

None

stop() → pathlib.Path | None

Stop capturing and encode what was captured.

Returns:

The written file, or None when nothing was captured — a recording that never saw a frame writes no file rather than an empty one.

Return type:

Optional[pathlib.Path]

_encode() → pathlib.Path

Write the captured frames, preferring MP4 and falling back to GIF.

Return type:

pathlib.Path

_encode_gif() → pathlib.Path

Write an animated GIF, the fallback that needs only Pillow.

Return type:

pathlib.Path

describe() → dict[str, Any]

Return what has been captured so far.

Returns:

Whether the recorder is active, the frame count, and the target.

Return type:

dict[str, Any]

class plesty.lib.ui.qt.DockPanel(panel: plesty.lib.ui.panel.Panel, shell: Shell)

Bases: PySide6.QtWidgets.QDockWidget

One sub-window of the shell, wrapping a Panel.

Movable, floatable, and (unless the panel says otherwise) closable — the three features that let an operator rearrange the window at will.

Wrap panel into a sub-window of shell.

Parameters:
panel
title_bar
toggle_floating() → None

Pull the panel out of the home window, or drop it back in.

Return type:

None

refresh() → None

Tick the panel and mirror its status into the title bar.

Return type:

None

class plesty.lib.ui.qt.PanelTitleBar(dock: DockPanel, title: str, closable: bool = True)

Bases: PySide6.QtWidgets.QWidget

Title bar of a sub-window: state dot, title, live status, controls.

Replaces Qt’s default dock title bar so a panel can show what it is doing without stealing plot area — the same window-chrome language as the plesty.net hero card. Mouse presses are left unhandled on purpose, so the dock widget beneath still starts a drag: this bar is the drag handle that pulls a panel out of the home window.

Build the bar for dock.

Parameters:
  • dock (DockPanel) – The sub-window this bar belongs to.

  • title (str) – Text shown on the left.

  • closable (bool) – Show the close button.

_dock
dot
label
status
_button(glyph: str, tip: str, handler: Any) → PySide6.QtWidgets.QToolButton

Create one title-bar button.

Parameters:
  • glyph (str)

  • tip (str)

  • handler (Any)

Return type:

PySide6.QtWidgets.QToolButton

set_status(text: str, tone: str = 'idle') → None

Update the status line and the state dot.

Parameters:
  • text (str) – Status text shown at the right of the bar.

  • tone (str) – State name understood by the theme ("running", "idle", "error", "warning").

Return type:

None

set_floating(floating: bool) → None

Mark the bar as belonging to a floating window.

Parameters:

floating (bool) – Whether the sub-window is currently floating.

Return type:

None

mouseDoubleClickEvent(event: Any) → None

Toggle floating on a double click, as a title bar conventionally does.

Parameters:

event (Any) – The Qt mouse event.

Return type:

None

class plesty.lib.ui.qt.Shell(title: str = 'PLESTY', *, theme: plesty.lib.ui.theme.Theme | None = None, interval_ms: int = 500, layout_file: str | pathlib.Path | None = None, remember_layout: bool = True, parent: PySide6.QtWidgets.QWidget | None = None)

Bases: PySide6.QtWidgets.QMainWindow

Home window hosting every panel as a rearrangeable sub-window.

Create the home window.

Parameters:
  • title (str) – Window title, also the key of the saved layout.

  • theme (Optional[plesty.lib.ui.theme.Theme]) – Palette and stylesheet; None loads the packaged PLESTY theme.

  • interval_ms (int) – Refresh interval — how often every panel is ticked. 500 ms keeps a live spectrum fluent without loading the machine that is also running the measurement.

  • layout_file (Optional[str | pathlib.Path]) – Where to store the arrangement; None uses ~/.plesty/ui/<title>.json.

  • remember_layout (bool) – Restore the stored arrangement on open and save it on close.

  • parent (Optional[PySide6.QtWidgets.QWidget]) – Parent widget.

theme
panels: dict[str, DockPanel]
recorder: plesty.lib.ui.qt.recorder.Recorder | None = None
last_recording: pathlib.Path | None = None
remember_layout = True
layout_file
_timer
_started = False
_paused = False
_pause_action: PySide6.QtGui.QAction | None = None
add(panel: plesty.lib.ui.panel.Panel, *, area: str | None = None, tabify_with: str | None = None, split_with: str | None = None) → DockPanel

Add a panel as a sub-window.

A panel joining an area that already holds one is split against the last panel there rather than dropped into the area as a whole. Only a split puts a real handle between two panels: docks that merely share an area — or worse, sit in different areas — cannot be resized against each other, which is how a panel ends up as an unadjustable strip.

Parameters:
  • panel (plesty.lib.ui.panel.Panel) – The panel to host.

  • area (Optional[str]) – Docking area override; defaults to panel.area.

  • tabify_with (Optional[str]) – Name of an already-added panel to tab this one with, instead of giving it its own space.

  • split_with (Optional[str]) – Name of the panel to split against; defaults to the last one added to the same area.

Returns:

The created sub-window.

Raises:

ValueError – If the panel name is already taken, or the panel named in tabify_with / split_with is unknown.

Return type:

DockPanel

_named(name: str, action: str) → DockPanel

Return an already-added sub-window by panel name.

Raises:

ValueError – If no panel of that name was added.

Parameters:
  • name (str)

  • action (str)

Return type:

DockPanel

_last_in_area(area: str) → DockPanel | None

Return the last docked sub-window of area, if it has one.

Parameters:

area (str)

Return type:

Optional[DockPanel]

apply_weights() → None

Size the panels of each area in proportion to their panel weights.

Qt distributes leftover space evenly, which leaves a status strip as tall as a spectrum. Weights say what each panel is worth: a plot 3, a readout 1.

Return type:

None

remove(name: str) → None

Remove a panel and release what it holds.

Parameters:

name (str) – Name of the panel to remove; unknown names are ignored.

Return type:

None

start() → None

Show the window, restore the layout, and start refreshing panels.

A stored arrangement wins over the panels’ weights — it is what the operator built last time.

Return type:

None

stop() → None

Stop refreshing panels.

Return type:

None

property paused: bool

Whether the panels are held where they are by pause().

Return type:

bool

pause() → None

Hold every panel where it is until resume().

Only the refresh stops: the window still resizes, and the Panels and Layout menus still work, so a held moment can be rearranged and read.

Return type:

None

resume() → None

Refresh the panels again after pause().

Unlike start() it neither shows the window nor restores the layout — the arrangement made while paused is kept.

Return type:

None

offer_pause() → PySide6.QtGui.QAction

Add a Replay menu whose Pause/Resume, also on the space bar, toggles the refresh.

For a replayed run: a live one cannot be held, only stop being watched, so a live viewer does not offer it. Calling it twice returns the action already added.

Returns:

The Pause/Resume action.

Return type:

PySide6.QtGui.QAction

set_status(text: str, state: str = 'idle') → None

Set the shell-wide status line.

Parameters:
  • text (str) – Message shown in the status bar.

  • state (str) – State name coloring the indicator ("running", "idle", "error", "warning").

Return type:

None

_tick() → None

Refresh every panel; a failing panel never stops the others.

Return type:

None

start_recording(path: str | pathlib.Path, *, fps: int = 10, every: int = 1) → plesty.lib.ui.qt.recorder.Recorder

Capture the window after every refresh until stop_recording().

Recording belongs to the window rather than to a view, so whatever is docked is captured — including a panel added or floated halfway through a run.

Parameters:
  • path (str | pathlib.Path) – Destination file; the suffix is corrected to what the available encoder can write (MP4, else animated GIF).

  • fps (int) – Frame rate of the encoded video.

  • every (int) – Capture one frame per every refreshes.

Returns:

The recorder, already active.

Return type:

plesty.lib.ui.qt.recorder.Recorder

stop_recording() → pathlib.Path | None

Encode what was captured and stop recording.

Returns:

The written file, or None if nothing was being recorded.

Return type:

Optional[pathlib.Path]

_toggle_recording() → None

Menu handler: start a recording beside the layout, or stop one.

Return type:

None

save_layout(path: str | pathlib.Path | None = None) → pathlib.Path

Store the current arrangement.

Parameters:

path (Optional[str | pathlib.Path]) – Destination file; None uses layout_file.

Returns:

The path written.

Return type:

pathlib.Path

restore_layout(path: str | pathlib.Path | None = None) → bool

Restore a stored arrangement, if one exists and still fits.

Panels that were not added this time are simply absent from the restored layout; new ones keep the placement they were added with.

Parameters:

path (Optional[str | pathlib.Path]) – Layout file; None uses layout_file.

Returns:

Whether a layout was restored.

Return type:

bool

_all_docks() → list[DockPanel | PySide6.QtWidgets.QDockWidget]

Return every sub-window this shell hosts, added by it or not.

panels holds the ones add() created, and for most shells that is all of them. A module is free to dock a widget of its own into the shell, though, and to the operator that is a panel like any other: “Hide all” that left it on screen would be answering a question nobody asked. The ones this shell added come first, so the order an operator sees is still the order they were added in.

Return type:

list[DockPanel | PySide6.QtWidgets.QDockWidget]

reset_layout() → None

Return every panel to the area it was added with, undocked from tabs.

Return type:

None

hide_all() → None

Take every panel off the screen, leaving the window empty.

The same thing as unchecking each panel in the Panels menu, in one action: each entry there is the panel’s own visibility, so hiding a panel here unchecks it and the menu keeps saying what is on screen. show_all() brings them all back.

This is the bulk gesture an operator actually reaches for. Floating three plots into three windows to see one of them is a rearrangement; hiding the other two is the question they were asking.

Return type:

None

show_all() → None

Put every panel back on screen, wherever it was.

Panels return to the arrangement they had, docked or floating; this says nothing about where they sit, only that they are visible. Use reset_layout() to put them back in their declared areas.

Return type:

None

_build_chrome() → None

Create the menus and the status bar.

Return type:

None

closeEvent(event: Any) → None

Save the arrangement and release the panels before closing.

Parameters:

event (Any) – The Qt close event.

Return type:

None