plesty.lib.ui.qt.form ===================== .. py:module:: plesty.lib.ui.qt.form .. autoapi-nested-parse:: Qt renderer for a device's controls: one widget per :class:`Control`. This module makes no decisions. Which control a parameter gets, what bounds it has and whether it may be written are settled in :func:`~plesty.lib.ui.device_panel.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 ---------- .. autoapisummary:: plesty.lib.ui.qt.form.MIN_COLUMN_PX plesty.lib.ui.qt.form.MAX_COLUMNS plesty.lib.ui.qt.form.UNBOUNDED plesty.lib.ui.qt.form.INT_UNBOUNDED plesty.lib.ui.qt.form.MAX_FIELD_PX plesty.lib.ui.qt.form.MAX_ARG_PX plesty.lib.ui.qt.form.READOUT_PAD_PX plesty.lib.ui.qt.form.COPY_OVER plesty.lib.ui.qt.form.COPY_TEXT plesty.lib.ui.qt.form.HOLD_MS plesty.lib.ui.qt.form.REFRESH_TEXT plesty.lib.ui.qt.form.LIVE_TEXT plesty.lib.ui.qt.form.UNSET_TEXT plesty.lib.ui.qt.form.DECIMALS plesty.lib.ui.qt.form.MIN_OP_COLUMN_PX plesty.lib.ui.qt.form.OPERATIONS_TEXT Classes ------- .. autoapisummary:: plesty.lib.ui.qt.form._Body plesty.lib.ui.qt.form._GroupBox plesty.lib.ui.qt.form._Hold plesty.lib.ui.qt.form._ReadLabel plesty.lib.ui.qt.form._Readout plesty.lib.ui.qt.form._WheelScroll plesty.lib.ui.qt.form._FlowForm plesty.lib.ui.qt.form.DeviceForm Functions --------- .. autoapisummary:: plesty.lib.ui.qt.form._by_group plesty.lib.ui.qt.form._argument_widget plesty.lib.ui.qt.form._build_argument_widget plesty.lib.ui.qt.form._is_unset plesty.lib.ui.qt.form._widget_value plesty.lib.ui.qt.form._set_widget_value Module Contents --------------- .. py:function:: _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. :param controls: The derived controls. :returns: ``(group, members)`` in the order the groups are to be shown. .. py:data:: MIN_COLUMN_PX :value: 320 .. py:data:: MAX_COLUMNS :value: 4 .. py:data:: UNBOUNDED :value: 1000000000000.0 .. py:data:: INT_UNBOUNDED :value: 2147483647 .. py:data:: MAX_FIELD_PX :value: 240 .. py:data:: MAX_ARG_PX :value: 150 .. py:data:: READOUT_PAD_PX :value: 6 .. py:data:: COPY_OVER :value: 32 .. py:data:: COPY_TEXT :value: 'Copy' .. py:data:: HOLD_MS :value: 500 .. py:data:: REFRESH_TEXT :value: 'Refresh' .. py:data:: LIVE_TEXT :value: 'Live' .. py:data:: UNSET_TEXT :value: 'device default' .. py:data:: DECIMALS :value: 6 .. py:data:: MIN_OP_COLUMN_PX :value: 460 .. py:data:: OPERATIONS_TEXT :value: 'Operations' .. py:class:: _Body(parent: Optional[PySide6.QtWidgets.QWidget], on_resize: Any) Bases: :py:obj:`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. :param parent: Parent widget. :param on_resize: Callback taking the body's new width. .. py:attribute:: _on_resize .. py:method:: resizeEvent(event: Any) -> None Report the new width, then resize as usual. .. py:class:: _GroupBox(title: str, parent: Optional[PySide6.QtWidgets.QWidget], on_resize: Any) Bases: :py:obj:`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. :param title: The group's name, shown on the box. :param parent: Parent widget. :param on_resize: Callback taking the box's new width. .. py:attribute:: _on_resize .. py:method:: resizeEvent(event: Any) -> None Report the new width, then resize as usual. .. py:class:: _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. :param parent: Widget the timer belongs to. :param on_click: Called on a press released within :data:`HOLD_MS`. :param on_hold: Called once the press outlasts it. .. py:attribute:: _on_click .. py:attribute:: _on_hold .. py:attribute:: _held :value: False .. py:attribute:: _timer .. py:method:: began() -> None A press started. .. py:method:: ended() -> None A press ended; a click acts, a hold already did. .. py:method:: _fired() -> None .. py:class:: _ReadLabel(text: str, parent: Optional[PySide6.QtWidgets.QWidget], on_click: Any, on_hold: Any) Bases: :py:obj:`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. :param text: The caption. :param parent: Parent widget. :param on_click: Called on a click. :param on_hold: Called when the press is held. .. py:attribute:: _hold .. py:method:: mousePressEvent(event: Any) -> None Start timing a press. .. py:method:: mouseReleaseEvent(event: Any) -> None Finish a press: a click reads once, a hold has already started. .. py:class:: _Readout(parent: Optional[PySide6.QtWidgets.QWidget] = None, cap: Optional[int] = MAX_FIELD_PX) Bases: :py:obj:`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. :param parent: Parent widget. :param cap: 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. .. py:attribute:: text :value: '' .. py:attribute:: _label .. py:attribute:: _strip .. py:attribute:: copy_button .. py:method:: setText(text: str) -> None Show *text*, offering to copy it when it is too long to read here. :param text: The reading. .. py:method:: showEvent(event: Any) -> None Take the height from the label once the stylesheet has reached it. .. py:method:: _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. .. py:method:: text_of() -> str What is being shown. .. py:method:: _copy() -> None .. py:class:: _WheelScroll Bases: :py:obj:`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. .. py:method:: wheelEvent(event: Any) -> None Scroll horizontally by whatever the wheel reported, either axis. .. py:class:: _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*. .. py:attribute:: grid .. py:attribute:: _entries :type: list[tuple[Optional[PySide6.QtWidgets.QWidget], PySide6.QtWidgets.QWidget, int]] :value: [] .. py:attribute:: _columns :value: 0 .. py:method:: 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. :param widget: The box. :param span: Column pairs it needs. .. py:method:: 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. :param head: What names the entry — a caption, or an operation's button. :param body: What it shows or takes — an editor, a result, or an operation's arguments beside its result. :param span: 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. .. py:method:: clear() -> None Forget every entry and empty the grid, deleting the widgets. .. py:method:: relayout(columns: int) -> bool Place every entry in *columns* columns; ``False`` when already there. :param columns: How many head-and-body columns to use. :returns: Whether anything moved. .. py:method:: _place(columns: int) -> None .. py:class:: DeviceForm(panel: plesty.lib.ui.device_panel.DevicePanel, parent: Optional[PySide6.QtWidgets.QWidget] = None) The widget tree for one :class:`~plesty.lib.ui.device_panel.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. :param panel: The panel this renders, and the object edits are reported to. :param parent: Parent widget. .. py:attribute:: panel .. py:attribute:: widget .. py:attribute:: editors :type: dict[str, PySide6.QtWidgets.QWidget] .. py:attribute:: readouts :type: dict[str, _Readout] .. py:attribute:: labels :type: dict[str, PySide6.QtWidgets.QWidget] .. py:attribute:: _holds :type: dict[str, _Hold] .. py:attribute:: arguments :type: dict[str, dict[str, PySide6.QtWidgets.QWidget]] .. py:attribute:: _results :type: dict[str, _Readout] .. py:attribute:: _outputs :type: dict[str, tuple[plesty.lib.ui.device_panel.Control, Ellipsis]] .. py:attribute:: _editing :type: set[str] .. py:attribute:: groups :type: dict[str, Any] .. py:attribute:: banner .. py:attribute:: control_toggle .. py:attribute:: refresh_button .. py:attribute:: scroll .. py:attribute:: body .. py:attribute:: stack .. py:attribute:: plain .. py:attribute:: form .. py:attribute:: groups_holder .. py:attribute:: _groups_stack .. py:attribute:: ops_heading .. py:attribute:: ops_holder .. py:attribute:: ops .. py:attribute:: group_forms :type: dict[str, _FlowForm] .. py:attribute:: _columns :value: 1 .. py:method:: 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. :param controls: What the device declared, already derived. .. py:method:: _width_changed(width: int) -> None Re-place the controls when the body's width asks for another column. .. py:method:: columns_for(width: int) -> int How many label-and-field columns *width* has room for. :param width: The body's width in pixels. :returns: At least one, at most :data:`MAX_COLUMNS`. .. py:method:: _relayout(width: Optional[int] = 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. :param width: The body's width; its current one when ``None``. :param force: Re-place even where the column count did not change. .. py:method:: _group_resized(group: str, width: int) -> None Lay the group *group* out for the width its box has now. .. py:method:: _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. .. py:method:: 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. :param key: The control's key. :param value: What the device reported. .. py:method:: set_writable(writable: bool) -> None Enable or disable every editor and button. :param writable: Whether the operator may drive the device. .. py:method:: 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. :param op: Operation name. :param value: Its result. .. py:method:: set_error(message: str) -> None Show *message* above the form. :param message: What failed; empty hides the banner. .. py:method:: _label(control: plesty.lib.ui.device_panel.Control) -> PySide6.QtWidgets.QLabel Return the caption for one control, which is also how it is read. .. py:method:: _toggle_live(key: str, arguments: Any = None) -> None Start or stop re-reading one control, and say which it is. .. py:method:: _show_label(key: str) -> None Mark a control that is being re-read, and say what a press does. .. py:method:: _readout(control: plesty.lib.ui.device_panel.Control) -> PySide6.QtWidgets.QWidget Return a display-only field for a parameter the device reports. .. py:method:: _editor(control: plesty.lib.ui.device_panel.Control) -> PySide6.QtWidgets.QWidget Return the editor a control asks for, wired to write when edited. .. py:method:: _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. :param key: The parameter that was edited. :param value: What the operator entered. .. py:method:: _spin(control: plesty.lib.ui.device_panel.Control) -> PySide6.QtWidgets.QWidget Return a spin box bounded by what the device declared, and no more. .. py:method:: _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. .. py:method:: _repeat(op: str) -> None Start or stop repeating an operation with the arguments it has now. .. py:method:: _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. :param op: Operation name. .. py:function:: _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. :param control: The derived argument control. :param parent: Parent widget. :returns: A widget holding the argument's value. .. py:function:: _build_argument_widget(control: plesty.lib.ui.device_panel.Control, parent: PySide6.QtWidgets.QWidget) -> PySide6.QtWidgets.QWidget Return the editor itself; :func:`_argument_widget` bounds it. .. py:function:: _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 :data:`UNSET_TEXT`, which Qt shows in place of the number. :param widget: The argument's editor. :returns: Whether the argument should be left out of the call. .. py:function:: _widget_value(widget: PySide6.QtWidgets.QWidget) -> Any Return what a widget currently holds. :param widget: One of the widget types this module builds. :returns: The value, in the type the widget represents. .. py:function:: _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. :param widget: The widget to update. :param value: The value to show.