plesty.lib.ui.qt.form

Qt renderer for a device’s controls: one widget per Control.

This module makes no decisions. Which control a parameter gets, what bounds it has and whether it may be written are settled in controls_for(), from what the device declared; here they only become widgets. Keeping it that way is the point of the exercise — the mapping is testable without a display, and this file cannot quietly invent a limit.

Two rules govern the traffic in both directions:

  • Only a human’s edit writes. Editors are wired to editingFinished and activated — signals Qt emits for a person, not for code — so the panel’s own polling can refresh a widget without echoing a write back to the device.

  • An editor being typed into is never overwritten. The operator entering an exposure outranks the poll that arrives mid-keystroke. Focus was the first guard and was not enough: a spin box with keyboard tracking off holds the typed text uncommitted, so a poll that Qt did not consider it focused for wiped the entry — and Return then wrote the polled value back to the instrument. A key is marked on the first keystroke and released when the edit is committed.

Colours come from the theme through assets/shell.qss; none is written here.

Attributes

MIN_COLUMN_PX

MAX_COLUMNS

UNBOUNDED

INT_UNBOUNDED

MAX_FIELD_PX

MAX_ARG_PX

READOUT_PAD_PX

COPY_OVER

COPY_TEXT

HOLD_MS

REFRESH_TEXT

LIVE_TEXT

UNSET_TEXT

DECIMALS

MIN_OP_COLUMN_PX

OPERATIONS_TEXT

Classes

_Body

The scrolled body, which tells the form when its width changed.

_GroupBox

A configuration group's box, which lays its own contents out.

_Hold

Tells a click from a press held down, for one widget.

_ReadLabel

A parameter's caption, which is also how that parameter is read.

_Readout

A reading, in a box of its own size whatever the reading's length.

_WheelScroll

A scroll area whose wheel moves it sideways.

_FlowForm

Every control as a head and a body, in as many columns as the width allows.

DeviceForm

The widget tree for one DevicePanel.

Functions

_by_group(→ list[tuple[str, ...)

Gather controls by configuration group, in declaration order.

_argument_widget(→ PySide6.QtWidgets.QWidget)

Return the small editor an operation argument asks for.

_build_argument_widget(→ PySide6.QtWidgets.QWidget)

Return the editor itself; _argument_widget() bounds it.

_is_unset(→ bool)

Whether widget is showing the unset state rather than a value.

_widget_value(→ Any)

Return what a widget currently holds.

_set_widget_value(→ None)

Show value in widget without emitting an edit.

Module Contents

plesty.lib.ui.qt.form._by_group(controls: list[plesty.lib.ui.device_panel.Control]) → list[tuple[str, list[plesty.lib.ui.device_panel.Control]]]

Gather controls by configuration group, in declaration order.

The default group stays first whatever order it was declared in — it is the device’s own parameters and its operations, and a panel that opened with a sub-assembly’s box above them would read as belonging to that sub-assembly.

Parameters:

controls (list[plesty.lib.ui.device_panel.Control]) – The derived controls.

Returns:

(group, members) in the order the groups are to be shown.

Return type:

list[tuple[str, list[plesty.lib.ui.device_panel.Control]]]

plesty.lib.ui.qt.form.MIN_COLUMN_PX = 320
plesty.lib.ui.qt.form.MAX_COLUMNS = 4
plesty.lib.ui.qt.form.UNBOUNDED = 1000000000000.0
plesty.lib.ui.qt.form.INT_UNBOUNDED = 2147483647
plesty.lib.ui.qt.form.MAX_FIELD_PX = 240
plesty.lib.ui.qt.form.MAX_ARG_PX = 150
plesty.lib.ui.qt.form.READOUT_PAD_PX = 6
plesty.lib.ui.qt.form.COPY_OVER = 32
plesty.lib.ui.qt.form.COPY_TEXT = 'Copy'
plesty.lib.ui.qt.form.HOLD_MS = 500
plesty.lib.ui.qt.form.REFRESH_TEXT = 'Refresh'
plesty.lib.ui.qt.form.LIVE_TEXT = 'Live'
plesty.lib.ui.qt.form.UNSET_TEXT = 'device default'
plesty.lib.ui.qt.form.DECIMALS = 6
plesty.lib.ui.qt.form.MIN_OP_COLUMN_PX = 460
plesty.lib.ui.qt.form.OPERATIONS_TEXT = 'Operations'
class plesty.lib.ui.qt.form._Body(parent: PySide6.QtWidgets.QWidget | None, on_resize: Any)

Bases: PySide6.QtWidgets.QWidget

The scrolled body, which tells the form when its width changed.

Hold on_resize, called with the new width on every resize.

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

  • on_resize (Any) – Callback taking the body’s new width.

_on_resize
resizeEvent(event: Any) → None

Report the new width, then resize as usual.

Parameters:

event (Any)

Return type:

None

class plesty.lib.ui.qt.form._GroupBox(title: str, parent: PySide6.QtWidgets.QWidget | None, on_resize: Any)

Bases: PySide6.QtWidgets.QGroupBox

A configuration group’s box, which lays its own contents out.

It reports its own width rather than taking the panel’s. A box is narrower than the panel by the margins around it, and Qt has not resized it yet when the panel’s own resize is delivered — asked then, it answers the width it had before, so its contents were laid out one resize behind and, on the first pass, for a box that was still zero wide.

Hold on_resize, called with the box’s new width.

Parameters:
  • title (str) – The group’s name, shown on the box.

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

  • on_resize (Any) – Callback taking the box’s new width.

_on_resize
resizeEvent(event: Any) → None

Report the new width, then resize as usual.

Parameters:

event (Any)

Return type:

None

class plesty.lib.ui.qt.form._Hold(parent: PySide6.QtWidgets.QWidget, on_click: Any, on_hold: Any)

Tells a click from a press held down, for one widget.

Both mean “ask the device this”, once or over and over, so they belong on the same target rather than on a control and a separate switch beside it. The release after a hold is swallowed: a hold has already acted, and reading once more on the way up would be a stray round trip.

Watch parent’s press and release.

Parameters:
  • parent (PySide6.QtWidgets.QWidget) – Widget the timer belongs to.

  • on_click (Any) – Called on a press released within HOLD_MS.

  • on_hold (Any) – Called once the press outlasts it.

_on_click
_on_hold
_held = False
_timer
began() → None

A press started.

Return type:

None

ended() → None

A press ended; a click acts, a hold already did.

Return type:

None

_fired() → None
Return type:

None

class plesty.lib.ui.qt.form._ReadLabel(text: str, parent: PySide6.QtWidgets.QWidget | None, on_click: Any, on_hold: Any)

Bases: PySide6.QtWidgets.QLabel

A parameter’s caption, which is also how that parameter is read.

Click reads it once, hold keeps reading it. The caption is the target because it is already on every row and costs no width — a read button per row is thirty buttons on a device with thirty parameters — and because the editor beside it must stay exactly what it is: somewhere to type.

Build the caption and make it answer a press.

Parameters:
  • text (str) – The caption.

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

  • on_click (Any) – Called on a click.

  • on_hold (Any) – Called when the press is held.

_hold
mousePressEvent(event: Any) → None

Start timing a press.

Parameters:

event (Any)

Return type:

None

mouseReleaseEvent(event: Any) → None

Finish a press: a click reads once, a hold has already started.

Parameters:

event (Any)

Return type:

None

class plesty.lib.ui.qt.form._Readout(parent: PySide6.QtWidgets.QWidget | None = None, cap: int | None = MAX_FIELD_PX)

Bases: PySide6.QtWidgets.QWidget

A reading, in a box of its own size whatever the reading’s length.

A sensor’s identity or a calibration block is one long line. Shown in a plain label it set the width of its column, and through a flowing grid that is the width of the panel: one reading decided the layout for every other.

So the text lives in a fixed-height strip that scrolls sideways under the wheel, and the strip is the same size whatever is in it. Text that does not fit gets a Copy button, since reading a serial number by scrolling it past a window is not reading it.

Build the empty strip.

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

  • cap (Optional[int]) – Widest the strip may grow. None lets it take what it is given, which is what an operation’s own box hands it: there the strip is the width of the box less the button and the arguments, and capping it there would waste the room the box exists to provide.

text = ''
_label
_strip
copy_button
setText(text: str) → None

Show text, offering to copy it when it is too long to read here.

Parameters:

text (str) – The reading.

Return type:

None

showEvent(event: Any) → None

Take the height from the label once the stylesheet has reached it.

Parameters:

event (Any)

Return type:

None

_fit() → None

Make the strip exactly one styled line tall.

Measured from the label rather than from the font: the sheet gives the reading its own padding and the strip a border, and a height that knows about neither clips the text it exists to show — by four pixels, which is enough to cut the descenders off a serial number.

Return type:

None

text_of() → str

What is being shown.

Return type:

str

_copy() → None
Return type:

None

class plesty.lib.ui.qt.form._WheelScroll

Bases: PySide6.QtWidgets.QScrollArea

A scroll area whose wheel moves it sideways.

The strip is one line high, so a wheel turned over it can only mean “show me the rest of this” — and the rest of it is to the right.

wheelEvent(event: Any) → None

Scroll horizontally by whatever the wheel reported, either axis.

Parameters:

event (Any)

Return type:

None

class plesty.lib.ui.qt.form._FlowForm(parent: PySide6.QtWidgets.QWidget)

Every control as a head and a body, in as many columns as the width allows.

A QFormLayout is one column whatever the window does, so a panel of thirty parameters is thirty rows of scrolling beside an empty half-window. This holds the same entries in a grid and re-places them when the column count changes — the widgets are moved, never rebuilt, so nothing an operator has typed and no value in flight is lost by a resize.

An operation is an entry like any other: its button is the head and its result is the body, so buttons line up with each other and with the parameter captions, and results line up with the editors. Built as one opaque widget instead, an operation aligned with nothing — a grid can align cells and not the insides of them, so every button was its own width and every reading beside one started somewhere different.

Lay out inside parent.

Parameters:

parent (PySide6.QtWidgets.QWidget)

grid
_entries: list[tuple[PySide6.QtWidgets.QWidget | None, PySide6.QtWidgets.QWidget, int]] = []
_columns = 0
add_block(widget: PySide6.QtWidgets.QWidget, span: int = 1) → None

Add one entry that is a single box rather than a head and a body.

An operation is drawn as its own outlined box holding its button, its arguments and its reading together. Inside a box, alignment with the operations beside it is neither possible nor wanted: the border is what says where one operation ends and the next begins, which a row of buttons of eight different widths never did.

Parameters:
  • widget (PySide6.QtWidgets.QWidget) – The box.

  • span (int) – Column pairs it needs.

Return type:

None

add_pair(head: PySide6.QtWidgets.QWidget, body: PySide6.QtWidgets.QWidget, span: int = 1) → None

Add one entry: a caption and its editor, or a button and its result.

Parameters:
  • head (PySide6.QtWidgets.QWidget) – What names the entry — a caption, or an operation’s button.

  • body (PySide6.QtWidgets.QWidget) – What it shows or takes — an editor, a result, or an operation’s arguments beside its result.

  • span (int) – Column pairs the body needs. An operation carrying arguments asks for two; squeezed into one it pushes every column out until the panel is wider than its window.

Return type:

None

clear() → None

Forget every entry and empty the grid, deleting the widgets.

Return type:

None

relayout(columns: int) → bool

Place every entry in columns columns; False when already there.

Parameters:

columns (int) – How many head-and-body columns to use.

Returns:

Whether anything moved.

Return type:

bool

_place(columns: int) → None
Parameters:

columns (int)

Return type:

None

class plesty.lib.ui.qt.form.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

plesty.lib.ui.qt.form._argument_widget(control: plesty.lib.ui.device_panel.Control, parent: PySide6.QtWidgets.QWidget) → PySide6.QtWidgets.QWidget

Return the small editor an operation argument asks for.

Capped narrower than a parameter’s editor: several sit on one row beside the button that uses them, and an uncapped row of them is what pushed the panel wider than its own window.

Parameters:
Returns:

A widget holding the argument’s value.

Return type:

PySide6.QtWidgets.QWidget

plesty.lib.ui.qt.form._build_argument_widget(control: plesty.lib.ui.device_panel.Control, parent: PySide6.QtWidgets.QWidget) → PySide6.QtWidgets.QWidget

Return the editor itself; _argument_widget() bounds it.

Parameters:
Return type:

PySide6.QtWidgets.QWidget

plesty.lib.ui.qt.form._is_unset(widget: PySide6.QtWidgets.QWidget) → bool

Whether widget is showing the unset state rather than a value.

Only a spin box built for an omittable argument has one: it is the floor, labelled UNSET_TEXT, which Qt shows in place of the number.

Parameters:

widget (PySide6.QtWidgets.QWidget) – The argument’s editor.

Returns:

Whether the argument should be left out of the call.

Return type:

bool

plesty.lib.ui.qt.form._widget_value(widget: PySide6.QtWidgets.QWidget) → Any

Return what a widget currently holds.

Parameters:

widget (PySide6.QtWidgets.QWidget) – One of the widget types this module builds.

Returns:

The value, in the type the widget represents.

Return type:

Any

plesty.lib.ui.qt.form._set_widget_value(widget: PySide6.QtWidgets.QWidget, value: Any) → None

Show value in widget without emitting an edit.

Signals are blocked around the change: every editor writes to the device when it changes, and a reading arriving from the device must not be sent straight back to it.

Parameters:
  • widget (PySide6.QtWidgets.QWidget) – The widget to update.

  • value (Any) – The value to show.

Return type:

None