Skip to content

View

Base classes

Bases: ABC

Base view class.

Does not inherit PView, whose read-only name property would shadow the instance attribute set here. Instances satisfy the protocol by shape, like any other view.

Parameters:

Name Type Description Default
name str

Identity key of the view, positional-only.

required
kwargs Any

Additional keyword arguments for view subclasses.

{}
Source code in src/redsun/view/_base.py
class View(ABC):
    """Base view class.

    Does not inherit [`PView`][redsun.view.PView], whose read-only ``name``
    property would shadow the instance attribute set here. Instances satisfy
    the protocol by shape, like any other view.

    Parameters
    ----------
    name : str
        Identity key of the view, positional-only.
    kwargs : Any, optional
        Additional keyword arguments for view subclasses.
    """

    name: str

    @abstractmethod
    def __init__(
        self,
        name: str,
        /,
        **kwargs: Any,
    ) -> None:
        self.name = name
        super().__init__(**kwargs)

    @property
    @abstractmethod
    def view_position(self) -> ViewPosition:
        """Position of the view in the main window."""

view_position abstractmethod property

view_position: ViewPosition

Position of the view in the main window.

Bases: Protocol

Protocol of a view component.

name is a read-only property, so an instance attribute, a class attribute or a property satisfies it.

Notes

A view reaches the virtual container by implementing IsInjectable.

Checked with isinstance on the built instance, since attributes assigned in __init__ do not exist on the class.

Source code in src/redsun/view/_base.py
@runtime_checkable
class PView(Protocol):
    """Protocol of a view component.

    ``name`` is a read-only property, so an instance attribute, a class
    attribute or a property satisfies it.

    Notes
    -----
    A view reaches the virtual container by implementing
    [`IsInjectable`][redsun.virtual.IsInjectable].

    Checked with ``isinstance`` on the built instance, since attributes
    assigned in ``__init__`` do not exist on the class.
    """

    @property
    def name(self) -> str:
        """Identity key of the view."""
        ...

    @property
    @abstractmethod
    def view_position(self) -> ViewPosition:
        """Position of the view in the main window."""

name property

name: str

Identity key of the view.

view_position abstractmethod property

view_position: ViewPosition

Position of the view in the main window.

Bases: str, Enum

Where a view sits in the main window.

Warning

The values follow Qt's dock widget areas and may change.

Source code in src/redsun/view/__init__.py
@unique
class ViewPosition(str, Enum):
    """Where a view sits in the main window.

    !!! warning
        The values follow Qt's dock widget areas and may change.
    """

    CENTER = "center"
    LEFT = "left"
    RIGHT = "right"
    TOP = "top"
    BOTTOM = "bottom"

Qt widgets

Qt widgets for interfaces that run plans.

  • ActionButton: a QPushButton carrying an Action, its label following the toggle state.
  • PlanWidget: a frozen dataclass owning one plan's widgets (parameter form, run and pause buttons, action buttons).
  • create_plan_widget: builds a PlanWidget from a PlanSpec and connects the given callbacks.
  • PlanInfoDialog: a dialog rendering a plan's docstring as Markdown.
  • create_param_widget: re-exported from _widget_factory.

ActionButton

Bases: QPushButton

A QPushButton carrying an Action.

Its label follows the toggle state, using the action's toggle_states.

Parameters:

Name Type Description Default
action Action

The button's action.

required
parent QWidget | None

The parent widget.

None

Attributes:

Name Type Description
action Action

The button's action.

Source code in src/redsun/view/qt/utils.py
class ActionButton(QtW.QPushButton):
    """A ``QPushButton`` carrying an ``Action``.

    Its label follows the toggle state, using the action's ``toggle_states``.

    Parameters
    ----------
    action : Action
        The button's action.
    parent : QtWidgets.QWidget | None, optional
        The parent widget.

    Attributes
    ----------
    action : Action
        The button's action.
    """

    def __init__(self, action: Action, parent: QtW.QWidget | None = None) -> None:
        self.name_capital = action.name.capitalize()
        super().__init__(self.name_capital, parent)
        self.action = action

        if action.description:
            self.setToolTip(action.description)

        if action.togglable:
            self.setCheckable(True)
            self.toggled.connect(self._update_text)
            self._update_text(False)

    def _update_text(self, checked: bool) -> None:
        """Update the label to the toggle state."""
        state_text = (
            self.action.toggle_states[1] if checked else self.action.toggle_states[0]
        )
        self.setText(f"{self.name_capital} ({state_text})")

PlanInfoDialog

Bases: QDialog

Dialog showing information to the user.

Parameters:

Name Type Description Default
title str

The title of the dialog window.

required
text str

Text shown, rendered as Markdown.

required
parent QWidget | None

The parent widget.

None
Source code in src/redsun/view/qt/utils.py
class PlanInfoDialog(QtW.QDialog):
    """Dialog showing information to the user.

    Parameters
    ----------
    title : str
        The title of the dialog window.
    text : str
        Text shown, rendered as Markdown.
    parent : QtWidgets.QWidget | None, optional
        The parent widget.
    """

    def __init__(
        self,
        title: str,
        text: str,
        parent: QtW.QWidget | None = None,
    ) -> None:
        super().__init__(parent)

        self.setWindowTitle(title)
        self.resize(500, 300)

        layout = QtW.QVBoxLayout(self)

        self.text_edit = QtW.QTextEdit()
        self.text_edit.setReadOnly(True)
        self.text_edit.setMarkdown(text)
        layout.addWidget(self.text_edit)

        self.ok_button = QtW.QPushButton("OK")
        self.ok_button.setDefault(True)
        self.ok_button.clicked.connect(self.accept)

        button_layout = QtW.QHBoxLayout()
        button_layout.addStretch()
        button_layout.addWidget(self.ok_button)
        layout.addLayout(button_layout)

        self.setLayout(layout)

    @classmethod
    def show_dialog(
        cls, title: str, text: str, parent: QtW.QWidget | None = None
    ) -> int:
        """Create and show the dialog in one step.

        Parameters
        ----------
        title : str
            The title of the dialog window.
        text : str
            Text shown.
        parent : QtWidgets.QWidget | None, optional
            The parent widget.

        Returns
        -------
        int
            Dialog result code (``QDialog.Accepted`` or ``QDialog.Rejected``).
        """
        dialog = cls(title, text, parent)
        return dialog.exec()

show_dialog classmethod

show_dialog(
    title: str, text: str, parent: QWidget | None = None
) -> int

Create and show the dialog in one step.

Parameters:

Name Type Description Default
title str

The title of the dialog window.

required
text str

Text shown.

required
parent QWidget | None

The parent widget.

None

Returns:

Type Description
int

Dialog result code (QDialog.Accepted or QDialog.Rejected).

Source code in src/redsun/view/qt/utils.py
@classmethod
def show_dialog(
    cls, title: str, text: str, parent: QtW.QWidget | None = None
) -> int:
    """Create and show the dialog in one step.

    Parameters
    ----------
    title : str
        The title of the dialog window.
    text : str
        Text shown.
    parent : QtWidgets.QWidget | None, optional
        The parent widget.

    Returns
    -------
    int
        Dialog result code (``QDialog.Accepted`` or ``QDialog.Rejected``).
    """
    dialog = cls(title, text, parent)
    return dialog.exec()

create_plan_widget

create_plan_widget(
    spec: PlanSpec,
    run_callback: Callable[[], None] | None = None,
    toggle_callback: Callable[[bool], None] | None = None,
    pause_callback: Callable[[bool], None] | None = None,
    action_clicked_callback: Callable[[str], None]
    | None = None,
    action_toggled_callback: Callable[[bool, str], None]
    | None = None,
) -> PlanWidget

Build a complete PlanWidget for spec.

Parameters:

Name Type Description Default
spec PlanSpec

The plan's specification.

required
run_callback Callable[[], None] | None

Connected to run_button.clicked for non-togglable plans.

None
toggle_callback Callable[[bool], None] | None

Connected to run_button.toggled for togglable plans.

None
pause_callback Callable[[bool], None] | None

Connected to pause_button.toggled for pausable plans.

None
action_clicked_callback Callable[[str], None] | None

Called with action_name when a non-togglable action fires.

None
action_toggled_callback Callable[[bool, str], None] | None

Called with (checked, action_name) when a togglable action fires.

None

Returns:

Type Description
PlanWidget

The widget, ready for a QStackedWidget.

Source code in src/redsun/view/qt/utils.py
def create_plan_widget(
    spec: PlanSpec,
    run_callback: Callable[[], None] | None = None,
    toggle_callback: Callable[[bool], None] | None = None,
    pause_callback: Callable[[bool], None] | None = None,
    action_clicked_callback: Callable[[str], None] | None = None,
    action_toggled_callback: Callable[[bool, str], None] | None = None,
) -> PlanWidget:
    """Build a complete ``PlanWidget`` for *spec*.

    Parameters
    ----------
    spec : PlanSpec
        The plan's specification.
    run_callback : Callable[[], None] | None, optional
        Connected to ``run_button.clicked`` for non-togglable plans.
    toggle_callback : Callable[[bool], None] | None, optional
        Connected to ``run_button.toggled`` for togglable plans.
    pause_callback : Callable[[bool], None] | None, optional
        Connected to ``pause_button.toggled`` for pausable plans.
    action_clicked_callback : Callable[[str], None] | None, optional
        Called with ``action_name`` when a non-togglable action fires.
    action_toggled_callback : Callable[[bool, str], None] | None, optional
        Called with ``(checked, action_name)`` when a togglable action fires.

    Returns
    -------
    PlanWidget
        The widget, ready for a ``QStackedWidget``.
    """
    page = QtW.QWidget()
    page_layout = QtW.QVBoxLayout(page)
    page_layout.setContentsMargins(4, 4, 4, 4)
    page_layout.setSpacing(4)

    device_widgets, param_widgets = _build_param_widgets(spec)

    # a flat list is what keeps `Container.parameters` reachable
    all_widgets = device_widgets + param_widgets
    container = mgw.Container(widgets=all_widgets)

    devices_group = _build_devices_group(device_widgets)
    params_group = _build_params_group(param_widgets)

    params_widget = QtW.QWidget()
    params_layout = QtW.QVBoxLayout(params_widget)
    params_layout.setContentsMargins(0, 0, 0, 0)
    params_layout.setSpacing(4)
    if devices_group is not None:
        params_layout.addWidget(devices_group)
    if params_group is not None:
        params_layout.addWidget(params_group)
    page_layout.addWidget(params_widget)

    run_button, pause_button = _build_run_buttons(
        spec,
        page,
        page_layout,
        run_callback or (lambda: None),
        toggle_callback or (lambda checked: None),
        pause_callback or (lambda paused: None),
    )

    actions_group, action_buttons = _build_actions_group(
        spec,
        page_layout,
        action_clicked_callback or (lambda name: None),
        action_toggled_callback or (lambda checked, name: None),
    )

    return PlanWidget(
        spec=spec,
        group_box=page,
        run_button=run_button,
        pause_button=pause_button,
        container=container,
        device_widgets=device_widgets,
        params_widget=params_widget,
        actions_group=actions_group,
        action_buttons=action_buttons,
    )

The Qt widgets of one plan.

Source code in src/redsun/view/qt/utils.py
@dataclass(frozen=True)
class PlanWidget:
    """The Qt widgets of one plan."""

    spec: PlanSpec
    """The plan's specification."""

    group_box: QtW.QWidget
    """The top-level page, for a QStackedWidget."""

    run_button: QtW.QPushButton
    """The button running or stopping the plan."""

    container: mgw.Container[mgw_bases.ValueWidget[Any]]
    """The ``magicgui`` Container of parameter widgets."""

    device_widgets: list[mgw_bases.ValueWidget[Any]]
    """Device parameter widgets (``DeviceSequenceEdit`` or ``ComboBox``).

    Exposed so callers can connect validation to each widget's ``changed``
    signal.
    """

    params_widget: QtW.QWidget
    """Widget holding devices_group and params_group; disabling it locks every
    parameter input but not the run, stop and pause buttons.
    """

    action_buttons: dict[str, ActionButton]
    """Action buttons by action name."""

    actions_group: QtW.QGroupBox | None = None
    """The group box of action buttons, or None if the plan has no actions."""

    pause_button: QtW.QPushButton | None = None
    """The pause/resume button, or None if the plan is not pausable."""

    def toggle(self, status: bool) -> None:
        """Update the widgets when a togglable plan starts or stops.

        Parameters
        ----------
        status : bool
            `True` when the plan is starting; `False` when stopping.
        """
        self.run_button.setText("Stop" if status else "Run")
        if self.pause_button:
            self.pause_button.setEnabled(status)
        if self.actions_group:
            self.actions_group.setEnabled(status)
        self.params_widget.setEnabled(not status)

    def pause(self, status: bool) -> None:
        """Update the widgets when a plan pauses or resumes.

        Parameters
        ----------
        status : bool
            `True` when pausing; `False` when resuming.
        """
        if self.pause_button:
            self.pause_button.setText("Resume" if status else "Pause")
            self.run_button.setEnabled(not status)

    def setEnabled(self, enabled: bool) -> None:
        """Enable or disable the whole plan widget.

        Parameters
        ----------
        enabled : bool
            ``True`` to enable; ``False`` to disable.
        """
        self.group_box.setEnabled(enabled)
        self.run_button.setEnabled(enabled)
        self.params_widget.setEnabled(enabled)

    def enable_actions(self, enabled: bool = True) -> None:
        """Enable or disable the actions group box.

        Parameters
        ----------
        enabled : bool, optional
            ``True`` to enable; ``False`` to disable.
        """
        if self.actions_group:
            self.actions_group.setEnabled(enabled)

    def get_action_button(self, action_name: str) -> ActionButton | None:
        """Return the `ActionButton` for `action_name`, or `None` if absent.

        Parameters
        ----------
        action_name : str
            The name of the action.
        """
        return self.action_buttons.get(action_name)

    def has_actions(self) -> bool:
        """Return `True` if the plan has an action button."""
        return bool(self.action_buttons)

    @property
    def parameters(self) -> dict[str, Any]:
        """Current parameter values by name.

        The presenter turns them into positional and keyword arguments with
        ``collect_arguments`` / ``resolve_arguments``.
        """
        return {w.name: w.value for w in self.container}

spec instance-attribute

spec: PlanSpec

The plan's specification.

group_box instance-attribute

group_box: QWidget

The top-level page, for a QStackedWidget.

run_button instance-attribute

run_button: QPushButton

The button running or stopping the plan.

container instance-attribute

container: Container[ValueWidget[Any]]

The magicgui Container of parameter widgets.

device_widgets instance-attribute

device_widgets: list[ValueWidget[Any]]

Device parameter widgets (DeviceSequenceEdit or ComboBox).

Exposed so callers can connect validation to each widget's changed signal.

params_widget instance-attribute

params_widget: QWidget

Widget holding devices_group and params_group; disabling it locks every parameter input but not the run, stop and pause buttons.

action_buttons instance-attribute

action_buttons: dict[str, ActionButton]

Action buttons by action name.

actions_group class-attribute instance-attribute

actions_group: QGroupBox | None = None

The group box of action buttons, or None if the plan has no actions.

pause_button class-attribute instance-attribute

pause_button: QPushButton | None = None

The pause/resume button, or None if the plan is not pausable.

parameters property

parameters: dict[str, Any]

Current parameter values by name.

The presenter turns them into positional and keyword arguments with collect_arguments / resolve_arguments.

toggle

toggle(status: bool) -> None

Update the widgets when a togglable plan starts or stops.

Source code in src/redsun/view/qt/utils.py
def toggle(self, status: bool) -> None:
    """Update the widgets when a togglable plan starts or stops.

    Parameters
    ----------
    status : bool
        `True` when the plan is starting; `False` when stopping.
    """
    self.run_button.setText("Stop" if status else "Run")
    if self.pause_button:
        self.pause_button.setEnabled(status)
    if self.actions_group:
        self.actions_group.setEnabled(status)
    self.params_widget.setEnabled(not status)

pause

pause(status: bool) -> None

Update the widgets when a plan pauses or resumes.

Source code in src/redsun/view/qt/utils.py
def pause(self, status: bool) -> None:
    """Update the widgets when a plan pauses or resumes.

    Parameters
    ----------
    status : bool
        `True` when pausing; `False` when resuming.
    """
    if self.pause_button:
        self.pause_button.setText("Resume" if status else "Pause")
        self.run_button.setEnabled(not status)

setEnabled

setEnabled(enabled: bool) -> None

Enable or disable the whole plan widget.

Source code in src/redsun/view/qt/utils.py
def setEnabled(self, enabled: bool) -> None:
    """Enable or disable the whole plan widget.

    Parameters
    ----------
    enabled : bool
        ``True`` to enable; ``False`` to disable.
    """
    self.group_box.setEnabled(enabled)
    self.run_button.setEnabled(enabled)
    self.params_widget.setEnabled(enabled)

enable_actions

enable_actions(enabled: bool = True) -> None

Enable or disable the actions group box.

Source code in src/redsun/view/qt/utils.py
def enable_actions(self, enabled: bool = True) -> None:
    """Enable or disable the actions group box.

    Parameters
    ----------
    enabled : bool, optional
        ``True`` to enable; ``False`` to disable.
    """
    if self.actions_group:
        self.actions_group.setEnabled(enabled)

get_action_button

get_action_button(action_name: str) -> ActionButton | None

Return the ActionButton for action_name, or None if absent.

Source code in src/redsun/view/qt/utils.py
def get_action_button(self, action_name: str) -> ActionButton | None:
    """Return the `ActionButton` for `action_name`, or `None` if absent.

    Parameters
    ----------
    action_name : str
        The name of the action.
    """
    return self.action_buttons.get(action_name)

has_actions

has_actions() -> bool

Return True if the plan has an action button.

Source code in src/redsun/view/qt/utils.py
def has_actions(self) -> bool:
    """Return `True` if the plan has an action button."""
    return bool(self.action_buttons)

Widgets for plan parameter forms.

create_param_widget maps a ParamDescription to a magicgui widget. It walks _WIDGET_FACTORY_MAP, an ordered list of (predicate, factory) pairs, and calls the first factory whose predicate matches.

Extending the system

For a new annotation shape, write a predicate and a factory and insert the pair at the right priority in _WIDGET_FACTORY_MAP.

Unresolvable annotations

create_plan_spec already checks that every required parameter maps to a widget: a plan failing that raises UnresolvableAnnotationError and is skipped. create_param_widget therefore raises RuntimeError if every entry fails, instead of falling back silently.

create_param_widget

create_param_widget(param: ParamDescription) -> mgw.Widget

Create a magicgui widget for param.

Parameters:

Name Type Description Default
param ParamDescription

The parameter specification.

required

Returns:

Type Description
Widget

The created widget.

Raises:

Type Description
RuntimeError

If every entry in _WIDGET_FACTORY_MAP fails.

Source code in src/redsun/view/qt/_widget_factory.py
def create_param_widget(param: ParamDescription) -> mgw.Widget:
    """Create a ``magicgui`` widget for *param*.

    Parameters
    ----------
    param : ParamDescription
        The parameter specification.

    Returns
    -------
    mgw.Widget
        The created widget.

    Raises
    ------
    RuntimeError
        If every entry in ``_WIDGET_FACTORY_MAP`` fails.
    """
    for predicate, factory in _WIDGET_FACTORY_MAP:
        widget = _try_factory_entry(predicate, factory, param)
        if widget is not None:
            return widget
    raise RuntimeError(
        f"No widget factory matched parameter {param.name!r} "
        f"(annotation: {param.annotation!r}). "
        f"This is a bug - create_plan_spec should have caught this."
    )

Tree view showing and editing device settings from their descriptors.

DescriptorTreeView, a QTreeWidget, shows bluesky describe() / read() dicts as a two-column property tree.

The design follows the ParameterTree widget of pyqtgraph (MIT licence, © 2012 University of North Carolina at Chapel Hill, Luke Campagnola).

DescriptorTreeView

Bases: QTreeWidget

Two-column property tree for browsing and editing device settings.

Rows are grouped by each descriptor's source field: one header per device, with the device name dropped from leaf labels.

Parameters:

Name Type Description Default
descriptors dict[str, Descriptor]

Descriptors by name-property key.

required
readings dict[str, Reading[Any]]

Initial readings for the same keys; only reading["value"] is read.

required
parent QWidget

Parent widget.

None
Signals

sig_property_changed : Signal[str, str, Any] Emitted when the user commits an edit. - str: object name - str: property name - Any: new value

Source code in src/redsun/view/qt/treeview.py
class DescriptorTreeView(QtWidgets.QTreeWidget):
    """Two-column property tree for browsing and editing device settings.

    Rows are grouped by each descriptor's ``source`` field: one header per
    device, with the device name dropped from leaf labels.

    Parameters
    ----------
    descriptors : dict[str, Descriptor]
        Descriptors by ``name-property`` key.
    readings : dict[str, Reading[Any]]
        Initial readings for the same keys; only ``reading["value"]`` is read.
    parent : QtWidgets.QWidget, optional
        Parent widget.

    Signals
    -------
    sig_property_changed : Signal[str, str, Any]
        Emitted when the user commits an edit.
        - str: object name
        - str: property name
        - Any: new value
    """

    sig_property_changed: Signal = Signal(str, str, object)

    def __init__(
        self,
        descriptors: dict[str, Descriptor],
        readings: dict[str, Reading[Any]],
        parent: QtWidgets.QWidget | None = None,
    ) -> None:
        super().__init__(parent)

        self._descriptors = descriptors
        self._readings = {k: v["value"] for k, v in readings.items()}
        self._pending: dict[str, Any] = {}
        self._widgets: dict[str, QtWidgets.QWidget] = {}

        self.setColumnCount(2)
        self.setHeaderLabels(["Setting", "Value"])
        self.setHeaderHidden(True)
        _hdr = self.header()
        if _hdr is not None:
            _hdr.setSectionResizeMode(
                0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents
            )
            _hdr.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch)
        self.setRootIsDecorated(False)
        self.setIndentation(12)
        self.setAlternatingRowColors(True)
        self.setVerticalScrollMode(
            QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel
        )
        self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection)
        self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)

        self._build()

    def update_reading(self, key: str, reading: Reading[Any]) -> None:
        """Show a new reading for *key*.

        Parameters
        ----------
        key : str
            ``name-property`` key.
        reading : Reading[Any]
            New reading; only ``reading["value"]`` is read.
        """
        value = reading["value"]
        self._readings[key] = value
        widget = self._widgets.get(key)
        if widget is not None:
            desc = self._descriptors.get(key)
            if desc is not None:
                _update_widget_value(widget, value)

    def confirm_change(self, key: str, success: bool) -> None:
        """Confirm or revert a pending user edit.

        Parameters
        ----------
        key : str
            Key of the edited setting.
        success : bool
            ``True`` keeps the new value; ``False`` restores the previous one
            and refreshes the widget.
        """
        old = self._pending.pop(key, None)
        if old is None:
            return
        if not success:
            self._readings[key] = old
            widget = self._widgets.get(key)
            desc = self._descriptors.get(key)
            if widget is not None and desc is not None:
                _update_widget_value(widget, old)
            _log.info("Reverted '%s' to previous value.", key)

    def _on_changed(self, key: str, value: Any) -> None:
        """Handle a change from any editor widget."""
        self._pending[key] = self._readings.get(key)
        self._readings[key] = value
        owner, property = key.split("-", 1)
        self.sig_property_changed.emit(owner, property, value)

    def _add_leaf(
        self,
        group_item: QtWidgets.QTreeWidgetItem,
        full_key: str,
        prop: str,
        desc: Descriptor,
        readonly: bool,
    ) -> None:
        """Append a row (setting and value widget) to *group_item*."""
        child = QtWidgets.QTreeWidgetItem()
        units = desc.get("units", "") or ""
        label = f"{prop} ({units})" if units else prop
        child.setText(0, label)
        child.setTextAlignment(
            0,
            QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter,
        )
        tip_parts = [f"dtype: {desc.get('dtype', '?')}"]
        if "units" in desc:
            tip_parts.append(f"units: {desc['units']}")
        if readonly:
            tip_parts.append("(read-only)")
        tip = " | ".join(tip_parts)
        child.setToolTip(0, tip)
        child.setToolTip(1, tip)
        group_item.addChild(child)
        initial = self._readings.get(full_key)
        widget = _make_value_widget(
            full_key, desc, initial, self._on_changed, readonly, self
        )
        self.setItemWidget(child, 1, widget)
        self._widgets[full_key] = widget

    def _make_group_item(self, label: str) -> QtWidgets.QTreeWidgetItem:
        """Create and register a bold top-level group header."""
        item = QtWidgets.QTreeWidgetItem([label])
        item.setFirstColumnSpanned(True)
        font = item.font(0)
        font.setBold(True)
        item.setFont(0, font)
        item.setExpanded(True)
        self.addTopLevelItem(item)
        return item

    def _build(self) -> None:
        """Populate the tree."""
        self.clear()
        self._widgets.clear()
        self._build_from_sources()
        self.expandAll()
        self.resizeColumnToContents(0)

    def _build_from_sources(self) -> None:
        """Build the tree grouped by each descriptor's ``source`` prefix."""
        groups: dict[str, list[tuple[str, str, Descriptor, bool]]] = {}
        for full_key, desc in self._descriptors.items():
            prop = full_key.split("-", 1)[-1] if "-" in full_key else full_key
            source_raw = desc.get("source", "unknown")
            parts = source_raw.split("://", 1)
            source = parts[0]
            readonly = len(parts) > 1 and parts[1] == "readonly"
            groups.setdefault(source, []).append((full_key, prop, desc, readonly))

        for source, leaves in groups.items():
            group_item = self._make_group_item(source)
            for full_key, prop, desc, readonly in leaves:
                self._add_leaf(group_item, full_key, prop, desc, readonly)

update_reading

update_reading(key: str, reading: Reading[Any]) -> None

Show a new reading for key.

Parameters:

Name Type Description Default
key str

name-property key.

required
reading Reading[Any]

New reading; only reading["value"] is read.

required
Source code in src/redsun/view/qt/treeview.py
def update_reading(self, key: str, reading: Reading[Any]) -> None:
    """Show a new reading for *key*.

    Parameters
    ----------
    key : str
        ``name-property`` key.
    reading : Reading[Any]
        New reading; only ``reading["value"]`` is read.
    """
    value = reading["value"]
    self._readings[key] = value
    widget = self._widgets.get(key)
    if widget is not None:
        desc = self._descriptors.get(key)
        if desc is not None:
            _update_widget_value(widget, value)

confirm_change

confirm_change(key: str, success: bool) -> None

Confirm or revert a pending user edit.

Parameters:

Name Type Description Default
key str

Key of the edited setting.

required
success bool

True keeps the new value; False restores the previous one and refreshes the widget.

required
Source code in src/redsun/view/qt/treeview.py
def confirm_change(self, key: str, success: bool) -> None:
    """Confirm or revert a pending user edit.

    Parameters
    ----------
    key : str
        Key of the edited setting.
    success : bool
        ``True`` keeps the new value; ``False`` restores the previous one
        and refreshes the widget.
    """
    old = self._pending.pop(key, None)
    if old is None:
        return
    if not success:
        self._readings[key] = old
        widget = self._widgets.get(key)
        desc = self._descriptors.get(key)
        if widget is not None and desc is not None:
            _update_widget_value(widget, old)
        _log.info("Reverted '%s' to previous value.", key)

Built-ins

Bases: QtView

Read-only console of the running session's log records.

Records logged before the view existed are shown too, read from the session buffer. The level selector sets the lowest level shown; since the view redraws from the buffer, lowering the level again brings records back.

Application and service records have their own tabs. The Services tab appears once a service logs, and its selector narrows it to one service. Clear log window and Save logs... act on the tab and service shown.

Records are coloured by level, with one palette for light and one for dark backgrounds, chosen from the console's background and redrawn when the palette changes.

New records are drawn in batches, so a burst does not stall the window, and each console keeps no more lines than the buffer holds for it.

With session log files open, Save logs... copies the files of the tab shown and Open log folder opens their folder in the file browser; without them the folder button is disabled.

Parameters:

Name Type Description Default
name str

Identity key of the view, positional-only.

required
kwargs Any

Additional keyword arguments (unused).

{}
Source code in src/redsun/view/qt/_log_view.py
class LogView(QtView):
    """Read-only console of the running session's log records.

    Records logged before the view existed are shown too, read from the session
    buffer. The level selector sets the lowest level shown; since the view
    redraws from the buffer, lowering the level again brings records back.

    Application and service records have their own tabs. The Services tab
    appears once a service logs, and its selector narrows it to one service.
    ``Clear log window`` and ``Save logs...`` act on the tab and service shown.

    Records are coloured by level, with one palette for light and one for dark
    backgrounds, chosen from the console's background and redrawn when the
    palette changes.

    New records are drawn in batches, so a burst does not stall the window, and
    each console keeps no more lines than the buffer holds for it.

    With session log files open, ``Save logs...`` copies the files of the tab
    shown and ``Open log folder`` opens their folder in the file browser;
    without them the folder button is disabled.

    Parameters
    ----------
    name : str
        Identity key of the view, positional-only.
    kwargs : Any, optional
        Additional keyword arguments (unused).
    """

    @property
    def view_position(self) -> ViewPosition:
        """Position in the main window."""
        return ViewPosition.BOTTOM

    def __init__(self, name: str, /, **kwargs: Any) -> None:
        super().__init__(name, **kwargs)

        self._formatter = GlobalFormatter(datefmt="%d-%m-%y|%H:%M:%S")
        self._level = logging.INFO

        buffer = log_buffer()

        self._console = self._make_console(buffer.capacity)
        self._service_console = self._make_console(buffer.service_capacity)
        self._service_combo = QtW.QComboBox(self)
        self._service_combo.addItem(_ALL_SERVICES, None)
        services_page = QtW.QWidget(self)
        services_layout = QtW.QVBoxLayout(services_page)
        services_layout.setContentsMargins(0, 0, 0, 0)
        services_layout.addWidget(self._service_combo)
        services_layout.addWidget(self._service_console)
        self._tabs = QtW.QTabWidget(self)
        self._tabs.addTab(self._console, "Application")
        self._tabs.addTab(services_page, "Services")
        self._tabs.setTabVisible(_SERVICES_TAB, False)
        # only the newest records can end up on screen, so a burst larger than
        # the buffer never queues more than a console would keep
        self._pending: deque[logging.LogRecord] = deque(maxlen=buffer.capacity)
        self._service_pending: deque[logging.LogRecord] = deque(
            maxlen=buffer.service_capacity
        )
        for service in buffer.services:
            self._add_service(service)
        self._service_combo.currentIndexChanged.connect(self._on_service_selected)

        self._level_combo = QtW.QComboBox(self)
        for label, level in _LEVELS:
            self._level_combo.addItem(label, level)
        self._level_combo.setCurrentIndex(self._level_combo.findData(self._level))
        self._level_combo.currentIndexChanged.connect(self._on_level_selected)

        self._save_button = QtW.QPushButton("Save logs...", self)
        self._save_button.clicked.connect(self._on_save_clicked)
        self._clear_button = QtW.QPushButton("Clear log window", self)
        self._clear_button.clicked.connect(self.clear)
        self._folder_button = QtW.QPushButton("Open log folder", self)
        self._folder_button.clicked.connect(self._on_folder_clicked)
        handler = session_log()
        self._folder_button.setEnabled(handler is not None)
        if handler is not None:
            self._folder_button.setToolTip(str(Path(handler.baseFilename).parent))

        root = QtW.QGridLayout(self)
        root.addWidget(self._tabs, 0, 0, 1, 4)
        root.addWidget(QtW.QLabel("Level:", self), 1, 0)
        root.addWidget(self._level_combo, 1, 1, 1, 3)
        root.addWidget(self._save_button, 2, 1)
        root.addWidget(self._clear_button, 2, 2)
        root.addWidget(self._folder_button, 2, 3)
        # the label column keeps its own width; the three that carry the
        # buttons share the rest evenly, so the combo box spans exactly them
        for column in (1, 2, 3):
            root.setColumnStretch(column, 1)
        self.setLayout(root)

        self._batch_timer = QtCore.QTimer(self)
        self._batch_timer.setInterval(_BATCH_INTERVAL_MS)
        self._batch_timer.timeout.connect(self._draw_batch)

        self._render()
        # psygnal holds the bound method weakly, so a destroyed view drops out
        # of the buffer on its own: a view is never asked to shut down
        buffer.sig_record.connect(self._on_record, thread="main")

    def _make_console(self, capacity: int) -> QtW.QPlainTextEdit:
        console = QtW.QPlainTextEdit(self)
        console.setReadOnly(True)
        console.setMaximumBlockCount(capacity)
        font = QtGui.QFont("nosuchfont")
        font.setStyleHint(QtGui.QFont.StyleHint.Monospace)
        console.setFont(font)
        return console

    def changeEvent(self, event: QtCore.QEvent | None) -> None:
        """Redraw in the colours of the palette the console now carries."""
        if event is not None:
            super().changeEvent(event)
            if event.type() == QtCore.QEvent.Type.PaletteChange:
                self._render()

    def closeEvent(self, event: QtGui.QCloseEvent | None) -> None:
        """Stop following the buffer once the console is closed."""
        log_buffer().sig_record.disconnect(self._on_record, missing_ok=True)
        self._batch_timer.stop()
        self._pending.clear()
        self._service_pending.clear()
        if event is not None:
            super().closeEvent(event)

    @property
    def level(self) -> int:
        """The lowest level currently displayed."""
        return self._level

    @property
    def service(self) -> str | None:
        """The service the Services tab shows, ``None`` for every service."""
        data = self._service_combo.currentData()
        return None if data is None else str(data)

    def set_level(self, level: int) -> None:
        """Show only records at or above *level*, redrawing from the buffer."""
        self._level = level
        index = self._level_combo.findData(level)
        if index != -1 and index != self._level_combo.currentIndex():
            # the selection change comes back through _on_level_selected,
            # which renders once the combo agrees with the level
            self._level_combo.setCurrentIndex(index)
            return
        self._render()

    def _on_level_selected(self, index: int) -> None:
        self.set_level(int(self._level_combo.itemData(index)))

    def _on_service_selected(self, index: int) -> None:
        self._render()

    def clear(self) -> None:
        """Empty the console of the tab shown.

        The buffer is untouched, so ``Save logs...`` still writes everything and
        changing the level brings records back.
        """
        if self._tabs.currentIndex() == _SERVICES_TAB:
            self._service_pending.clear()
            self._service_console.clear()
        else:
            self._pending.clear()
            self._console.clear()

    def save(self, path: str) -> None:
        """Write the records of the tab shown to *path*, whatever the displayed level.

        The Application tab writes application records; the Services tab the
        selected service's, or every service's in turn. They come from the
        session's log files if open, so records the buffer dropped are
        included, and from the buffer otherwise.
        """
        buffer = log_buffer()
        if self._tabs.currentIndex() != _SERVICES_TAB:
            sources: list[tuple[str | None, Iterable[logging.LogRecord]]] = [
                (None, buffer.records)
            ]
        else:
            names = buffer.services if self.service is None else (self.service,)
            sources = [(name, buffer.service_records(name)) for name in names]
        with open(path, "w", encoding="utf-8") as fh:
            for source, records in sources:
                handler = session_log(source)
                if handler is None:
                    fh.writelines(
                        f"{self._formatter.format(record)}\n" for record in records
                    )
                    continue
                handler.flush()
                fh.writelines(
                    file.read_text(encoding="utf-8") for file in handler.files
                )

    def _add_service(self, service: str) -> None:
        """Offer *service* in the selector, and show the Services tab."""
        self._service_combo.addItem(service, service)
        self._tabs.setTabVisible(_SERVICES_TAB, True)
        capacity = log_buffer().service_capacity * (self._service_combo.count() - 1)
        self._service_console.setMaximumBlockCount(capacity)
        self._service_pending = deque(self._service_pending, maxlen=capacity)

    def _on_record(self, record: logging.LogRecord) -> None:
        service = service_of(record)
        if service is not None and self._service_combo.findData(service) == -1:
            self._add_service(service)
        if record.levelno < self._level:
            return
        if service is None:
            self._pending.append(record)
        elif self.service in (None, service):
            self._service_pending.append(record)
        else:
            return
        if not self._batch_timer.isActive():
            self._batch_timer.start()

    def _draw_batch(self) -> None:
        for pending, console in (
            (self._pending, self._console),
            (self._service_pending, self._service_console),
        ):
            count = min(_BATCH_SIZE, len(pending))
            self._write(console, [pending.popleft() for _ in range(count)])
        if not (self._pending or self._service_pending):
            self._batch_timer.stop()

    def _render(self) -> None:
        buffer = log_buffer()
        self._batch_timer.stop()
        self._pending.clear()
        self._service_pending.clear()
        self._console.clear()
        self._service_console.clear()
        self._write(
            self._console, [r for r in buffer.records if r.levelno >= self._level]
        )
        self._write(
            self._service_console,
            [
                r
                for r in buffer.service_records(self.service)
                if r.levelno >= self._level
            ],
        )

    @property
    def colors(self) -> dict[int, str]:
        """The level colours in use, chosen from the console's background."""
        base = self._console.palette().color(QtGui.QPalette.ColorRole.Base)
        return _ON_LIGHT if base.lightness() >= _MID_LIGHTNESS else _ON_DARK

    def _write(
        self, console: QtW.QPlainTextEdit, records: Iterable[logging.LogRecord]
    ) -> None:
        # pyqt6 annotates both as optional and pyside6 does not
        document: QtGui.QTextDocument | None = console.document()
        bar: QtW.QScrollBar | None = console.verticalScrollBar()
        if document is None or bar is None:
            return
        # follow the newest line only when the reader is already at the bottom
        following = bar.value() == bar.maximum()
        colors = self.colors
        formats: dict[int, QtGui.QTextCharFormat] = {}
        cursor = QtGui.QTextCursor(document)
        cursor.movePosition(QtGui.QTextCursor.MoveOperation.End)
        cursor.beginEditBlock()
        for record in records:
            if record.levelno not in formats:
                char_format = QtGui.QTextCharFormat()
                color = colors.get(record.levelno, colors[logging.INFO])
                char_format.setForeground(QtGui.QBrush(QtGui.QColor(color)))
                formats[record.levelno] = char_format
            if not document.isEmpty():
                cursor.insertBlock()
            cursor.insertText(self._formatter.format(record), formats[record.levelno])
        cursor.endEditBlock()
        if following:
            bar.setValue(bar.maximum())

    def _on_save_clicked(self) -> None:
        chosen, _ = QtW.QFileDialog.getSaveFileName(
            self, "Save session logs", "redsun.log", "Log files (*.log);;All files (*)"
        )
        if not chosen:
            return
        try:
            self.save(chosen)
        except OSError as e:
            QtW.QMessageBox.warning(self, "Could not save logs", str(e))

    def _on_folder_clicked(self) -> None:
        handler = session_log()
        if handler is None:
            return
        folder = Path(handler.baseFilename).parent
        if not QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(str(folder))):
            QtW.QMessageBox.warning(self, "Could not open the log folder", str(folder))

view_position property

view_position: ViewPosition

Position in the main window.

level property

level: int

The lowest level currently displayed.

service property

service: str | None

The service the Services tab shows, None for every service.

colors property

colors: dict[int, str]

The level colours in use, chosen from the console's background.

changeEvent

changeEvent(event: QEvent | None) -> None

Redraw in the colours of the palette the console now carries.

Source code in src/redsun/view/qt/_log_view.py
def changeEvent(self, event: QtCore.QEvent | None) -> None:
    """Redraw in the colours of the palette the console now carries."""
    if event is not None:
        super().changeEvent(event)
        if event.type() == QtCore.QEvent.Type.PaletteChange:
            self._render()

closeEvent

closeEvent(event: QCloseEvent | None) -> None

Stop following the buffer once the console is closed.

Source code in src/redsun/view/qt/_log_view.py
def closeEvent(self, event: QtGui.QCloseEvent | None) -> None:
    """Stop following the buffer once the console is closed."""
    log_buffer().sig_record.disconnect(self._on_record, missing_ok=True)
    self._batch_timer.stop()
    self._pending.clear()
    self._service_pending.clear()
    if event is not None:
        super().closeEvent(event)

set_level

set_level(level: int) -> None

Show only records at or above level, redrawing from the buffer.

Source code in src/redsun/view/qt/_log_view.py
def set_level(self, level: int) -> None:
    """Show only records at or above *level*, redrawing from the buffer."""
    self._level = level
    index = self._level_combo.findData(level)
    if index != -1 and index != self._level_combo.currentIndex():
        # the selection change comes back through _on_level_selected,
        # which renders once the combo agrees with the level
        self._level_combo.setCurrentIndex(index)
        return
    self._render()

clear

clear() -> None

Empty the console of the tab shown.

The buffer is untouched, so Save logs... still writes everything and changing the level brings records back.

Source code in src/redsun/view/qt/_log_view.py
def clear(self) -> None:
    """Empty the console of the tab shown.

    The buffer is untouched, so ``Save logs...`` still writes everything and
    changing the level brings records back.
    """
    if self._tabs.currentIndex() == _SERVICES_TAB:
        self._service_pending.clear()
        self._service_console.clear()
    else:
        self._pending.clear()
        self._console.clear()

save

save(path: str) -> None

Write the records of the tab shown to path, whatever the displayed level.

The Application tab writes application records; the Services tab the selected service's, or every service's in turn. They come from the session's log files if open, so records the buffer dropped are included, and from the buffer otherwise.

Source code in src/redsun/view/qt/_log_view.py
def save(self, path: str) -> None:
    """Write the records of the tab shown to *path*, whatever the displayed level.

    The Application tab writes application records; the Services tab the
    selected service's, or every service's in turn. They come from the
    session's log files if open, so records the buffer dropped are
    included, and from the buffer otherwise.
    """
    buffer = log_buffer()
    if self._tabs.currentIndex() != _SERVICES_TAB:
        sources: list[tuple[str | None, Iterable[logging.LogRecord]]] = [
            (None, buffer.records)
        ]
    else:
        names = buffer.services if self.service is None else (self.service,)
        sources = [(name, buffer.service_records(name)) for name in names]
    with open(path, "w", encoding="utf-8") as fh:
        for source, records in sources:
            handler = session_log(source)
            if handler is None:
                fh.writelines(
                    f"{self._formatter.format(record)}\n" for record in records
                )
                continue
            handler.flush()
            fh.writelines(
                file.read_text(encoding="utf-8") for file in handler.files
            )