Skip to content

API reference

The reference below is generated directly from the current Python sources. Names beginning with an underscore are implementation details.

Smart table

ms_table

smart_table ─────────── Advanced table component for PySide6 + SQLModel.

Exports

SmartTableView → Main widget. TableConfig → Declarative configuration. ColumnDef → Column definition. FilterSpec → Filter specification. FilterOperator → Comparison operators. SortSpec → Sort specification. AlignHint → Column alignment. QueryBuilder → Direct access to the builder (tests / advanced use). SmartTableModel → Qt model (advanced use / subclassing). PaginationControl → Standalone pagination control.

Modules:

Name Description
action_header

action_header.py

cell_delegate

cell_delegate.py

column_panel

column_panel.py

example_usage

example_usage.py

filter_panel

filter_panel.py

metric_filter_bar

Additive numeric filter bar, rendered as chips.

pagination_control

pagination_control.py

query_builder

query_builder.py

session_provider
smart_table_view

smart_table_view.py

sort_panel
table_config

table_config.py

table_model

table_model.py

test_external_filters

External filter-overlay contract for QueryBuilder.

test_toolbar

Self-check for the agnostic ms_table toolbar (zones + overflow + collapsible).

Classes:

Name Description
ActionHeader

Horizontal header with per-column action buttons.

ColumnConfigDialog

Lets the user:

FilterPanel

Filter bar with:

MetricFilterBar

Chips over numeric fields, plus a + that opens the condition dialog.

SortPanel

Compact bar showing the active sorts as pills, with a combo to add new

PaginationControl

Compact, modern pagination.

QueryBuilder

Builds and runs paginated queries from a TableConfig.

SessionProvider
SmartTableView

Smart table with pagination, filtering, sorting and column configuration.

ColumnDef

Define a visible column in the table.

FilterSpec

Specification of an active filter.

SortSpec

Sort specification.

TableConfig

Full configuration of a SmartTableView.

ToolbarAction

An action injected into the toolbar. Deliberately dumb: the component

SmartTableModel

Qt model for SmartTableView.

Functions:

Name Description
choices_from_class

Extract the string values of a constants class (not an Enum).

ActionHeader

ActionHeader(config: TableConfig, parent: QWidget | None = None)

Bases: QHeaderView

Horizontal header with per-column action buttons.

Signal

actionClicked(logical_index, action, modifiers) action: "sort" | "search" | "filter" modifiers: Qt.KeyboardModifiers (Shift → additive sort)

Source code in src/ms_components/ms_table/action_header.py
66
67
68
69
70
71
72
73
74
75
76
77
def __init__(self, config: TableConfig, parent: QWidget | None = None) -> None:
    super().__init__(Qt.Orientation.Horizontal, parent)
    self._config = config
    self.setSectionsClickable(True)
    self.setHighlightSections(False)
    self.setMouseTracking(True)
    self.setMinimumHeight(38)

    self._hovered: tuple[int, str] | None = None
    self._active_searches: set[int] = set()
    self._active_filters:  set[int] = set()
    self._sort_directions: dict[int, Qt.SortOrder] = {}

ColumnConfigDialog

ColumnConfigDialog(config: TableConfig, parent=None)

Bases: QDialog

Lets the user
  1. Enable / disable columns (checkbox)
  2. Reorder them with drag & drop

On accept it emits the reordered ColumnDef list with updated visibility.

Source code in src/ms_components/ms_table/column_panel.py
40
41
42
43
44
45
def __init__(self, config: TableConfig, parent=None):
    super().__init__(parent)
    self.setWindowTitle("Configure columns")
    self.setMinimumSize(320, 400)
    self._config = config
    self._setup_ui()

FilterPanel

FilterPanel(config: TableConfig, parent=None, *, show_search: bool = True)

Bases: QWidget

Filter bar with
  • Quick search
  • Chips for the active filters
  • A "+ Filter" button that opens FilterDialog
Signals

search_changed(str): Global search text. filter_added(FilterSpec): New advanced filter. filter_removed(str): Filter removed (field). filters_cleared(): All filters cleared.

Methods:

Name Description
set_filters

Show chips for a list of filters (e.g. when restoring state).

Source code in src/ms_components/ms_table/filter_panel.py
189
190
191
192
193
194
195
def __init__(self, config: TableConfig, parent=None, *, show_search: bool = True):
    super().__init__(parent)
    self._config  = config
    self._chips:  dict[str, FilterChip] = {}
    self._show_search = show_search
    self._search_box: QLineEdit | None = None
    self._setup_ui()

set_filters

set_filters(specs: list[FilterSpec]) -> None

Show chips for a list of filters (e.g. when restoring state).

Source code in src/ms_components/ms_table/filter_panel.py
306
307
308
309
310
def set_filters(self, specs: list[FilterSpec]) -> None:
    """Show chips for a list of filters (e.g. when restoring state)."""
    self._clear_all(emit_signal=False)
    for spec in specs:
        self._add_chip(spec)

MetricFilterBar

MetricFilterBar(fields: Sequence[tuple[str, str]], parent: QWidget | None = None, *, decimals: int = 3, default_value: float = 0.0, empty_text: str = 'No filters — everything is shown.')

Bases: QWidget

Chips over numeric fields, plus a + that opens the condition dialog.

fields are (key, label) pairs: the key is what shows up in conditions().

Methods:

Name Description
add_condition

Add (or replace) a condition: the same field+operator is an edit, not another chip.

set_conditions

Whole state at once (restoring a saved view): a single signal.

Source code in src/ms_components/ms_table/metric_filter_bar.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def __init__(
    self,
    fields: Sequence[tuple[str, str]],
    parent: QWidget | None = None,
    *,
    decimals: int = 3,
    default_value: float = 0.0,
    empty_text: str = "No filters — everything is shown.",
):
    super().__init__(parent)
    self.setStyleSheet(CHIP_STYLE)
    self._fields = tuple(fields)
    self._labels = {key: label for key, label in fields}
    self._decimals = decimals
    self._default_value = default_value
    self._conditions: dict[tuple[str, str], float] = {}
    self._chips: dict[tuple[str, str], _Chip] = {}

    row = QHBoxLayout(self)
    row.setContentsMargins(0, 0, 0, 0)
    row.setSpacing(4)
    self.add_button = QToolButton(self)
    self.add_button.setText("+")
    self.add_button.setToolTip("Add a filter")
    self.add_button.clicked.connect(self.open_dialog)
    row.addWidget(self.add_button)
    self._empty = QLabel(empty_text, self)
    self._empty.setEnabled(False)
    row.addWidget(self._empty)
    row.addStretch(1)
    self._row = row

add_condition

add_condition(field: str, op: str, value: float, *, notify: bool = True) -> None

Add (or replace) a condition: the same field+operator is an edit, not another chip.

Source code in src/ms_components/ms_table/metric_filter_bar.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def add_condition(self, field: str, op: str, value: float, *, notify: bool = True) -> None:
    """Add (or replace) a condition: the same field+operator is an edit, not another chip."""
    if field not in self._labels or op not in dict(OPERATORS):
        return
    self._drop((field, op))
    self._conditions[(field, op)] = float(value)
    symbol = dict(OPERATORS)[op]
    chip = _Chip(field, op, f"{self._labels[field]} {symbol} {float(value):g}", self)
    chip.removed.connect(self._on_removed)
    self._chips[(field, op)] = chip
    self._row.insertWidget(self._row.count() - 1, chip)  # before the stretch
    self._empty.setVisible(False)
    if notify:
        self.changed.emit()

set_conditions

set_conditions(conditions: Iterable[Condition]) -> None

Whole state at once (restoring a saved view): a single signal.

Source code in src/ms_components/ms_table/metric_filter_bar.py
181
182
183
184
185
186
187
def set_conditions(self, conditions: Iterable[Condition]) -> None:
    """Whole state at once (restoring a saved view): a single signal."""
    for key in list(self._chips):
        self._drop(key)
    for field, op, value in conditions:
        self.add_condition(field, op, value, notify=False)
    self.changed.emit()

SortPanel

SortPanel(config: TableConfig, parent=None)

Bases: QWidget

Compact bar showing the active sorts as pills, with a combo to add new ones.

Signals

sort_changed(list[SortSpec])

Source code in src/ms_components/ms_table/sort_panel.py
64
65
66
67
68
69
70
def __init__(self, config: TableConfig, parent=None):
    super().__init__(parent)
    self._config = config
    self._sorts: list[SortSpec] = list(config.default_sort)
    self._chips: dict[str, SortChip] = {}
    self._setup_ui()
    self._rebuild_chips()

PaginationControl

PaginationControl(total_items: int = 0, items_per_page: int = 20, items_per_page_options: list[int] | None = None, parent=None)

Bases: QWidget

Compact, modern pagination.

Signals

page_changed(int) items_per_page_changed(int)

Source code in src/ms_components/ms_table/pagination_control.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def __init__(
    self,
    total_items: int = 0,
    items_per_page: int = 20,
    items_per_page_options: list[int] | None = None,
    parent=None,
):
    super().__init__(parent)
    self.total_items     = total_items
    self.items_per_page  = items_per_page
    self.current_page    = 1
    self.total_pages     = 1

    if items_per_page_options is None:
        self.items_per_page_options = [10, 15, 20, 25, 50, 100, 200]
    else:
        self.items_per_page_options = items_per_page_options

    self._setup_ui()
    self.update_pagination()

QueryBuilder

QueryBuilder(db: SessionProvider, config: TableConfig)

Builds and runs paginated queries from a TableConfig.

Usage

builder = QueryBuilder(db, config) builder.set_filters([FilterSpec("name", FilterOperator.ILIKE, "%ann%")]) builder.set_sort([SortSpec("id", descending=True)]) builder.set_page(1) rows, total = builder.fetch()

Methods:

Name Description
set_external_clause

Overlay a raw SQLAlchemy clause under key (None removes it).

set_search

Enable an ILIKE search ORed across the given fields.

remove_search

Disable the quick search and show everything again.

all_ids

PKs of every row matching the current filters/search (no pagination).

count

Row total under the current filters/search, without fetching any row.

cursor_of

Keyset cursor (sort_value, pk) for an already-loaded row.

fetch_after

Next page by keyset: WHERE (sort, pk) > cursor, with no OFFSET.

fetch_window_after

The page after the cursor, without COUNT(*).

fetch_window_at

Direct load for a scrollbar jump to a page that has not been walked yet.

fetch

Run the query and return (rows_as_dicts, total_count).

to_query_spec

The "filtered" selection as QuerySpec kwargs, ready to hand to a job.

Source code in src/ms_components/ms_table/query_builder.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def __init__(self, db: SessionProvider, config: TableConfig):
    self._db            = db
    self._config        = config
    self._filters: list[FilterSpec] = list(config.default_filters)
    # Filter overlays imposed by an external host (e.g. a tool narrowing a
    # shared table). They compose with the user's filters using AND without
    # overwriting them, and are cleared by key when the host goes away.
    self._external: dict[str, list[FilterSpec]] = {}
    # Same as _external but holding already-built SQLAlchemy expressions (see
    # set_external_clause): whatever FilterSpec cannot describe.
    self._external_clauses: dict[str, Any] = {}
    self._sorts:   list[SortSpec]   = list(config.default_sort)
    self._page:    int              = 1
    self._page_size: int            = config.page_size
    self._search_text:   str        = ""
    self._search_fields: list[str]  = []

set_external_clause

set_external_clause(key: str, clause) -> None

Overlay a raw SQLAlchemy clause under key (None removes it).

Escape hatch for what FilterSpec cannot express - subqueries (~exists(...)), OR across fields, functions. It composes with AND just like set_external_filters, and because it is an already-built expression the builder does not interpret it: making sure it references the right model is the caller's responsibility.

Source code in src/ms_components/ms_table/query_builder.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def set_external_clause(self, key: str, clause) -> None:
    """Overlay a raw SQLAlchemy clause under `key` (None removes it).

    Escape hatch for what FilterSpec cannot express - subqueries
    (`~exists(...)`), OR across fields, functions. It composes with AND just
    like `set_external_filters`, and because it is an already-built expression
    the builder does not interpret it: making sure it references the right
    model is the caller's responsibility.
    """
    if clause is None:
        self._external_clauses.pop(key, None)
    else:
        self._external_clauses[key] = clause
    self._page = 1
set_search(text: str, fields: list[str]) -> None

Enable an ILIKE search ORed across the given fields.

Source code in src/ms_components/ms_table/query_builder.py
113
114
115
116
117
def set_search(self, text: str, fields: list[str]) -> None:
    """Enable an ILIKE search ORed across the given fields."""
    self._search_text   = text
    self._search_fields = fields
    self._page = 1
remove_search() -> None

Disable the quick search and show everything again.

Source code in src/ms_components/ms_table/query_builder.py
119
120
121
122
123
def remove_search(self) -> None:
    """Disable the quick search and show everything again."""
    self._search_text   = ""
    self._search_fields = []
    self._page = 1

all_ids

all_ids() -> list[int]

PKs of every row matching the current filters/search (no pagination).

Lets a caller resolve "everything the table currently shows" into an id set without materializing rows. Honors filters + quick search, ignores paging.

Source code in src/ms_components/ms_table/query_builder.py
139
140
141
142
143
144
145
146
147
148
149
150
def all_ids(self) -> list[int]:
    """PKs of every row matching the current filters/search (no pagination).

    Lets a caller resolve "everything the table currently shows" into an id set
    without materializing rows. Honors filters + quick search, ignores paging.
    """
    pk = self._get_pk_attr()
    if pk is None:
        return []
    stmt = self._build_base_statement().with_only_columns(pk)
    with self._db.get_session() as session:
        return [int(value) for value in session.exec(stmt).all() if value is not None]

count

count() -> int

Row total under the current filters/search, without fetching any row.

For hosts that want to keep the counter live (e.g. while a job inserts records) without reloading the visible rows.

Source code in src/ms_components/ms_table/query_builder.py
152
153
154
155
156
157
158
159
def count(self) -> int:
    """Row total under the current filters/search, without fetching any row.

    For hosts that want to keep the counter live (e.g. while a job inserts
    records) without reloading the visible rows.
    """
    with self._db.get_session() as session:
        return int(session.exec(self._build_count_statement()).one())  # type: ignore[arg-type]

cursor_of

cursor_of(row: dict) -> tuple[Any, Any] | None

Keyset cursor (sort_value, pk) for an already-loaded row.

Source code in src/ms_components/ms_table/query_builder.py
161
162
163
164
165
166
167
168
169
170
171
172
173
def cursor_of(self, row: dict) -> tuple[Any, Any] | None:
    """Keyset cursor `(sort_value, pk)` for an already-loaded row."""
    raw = row.get("__raw__")
    pk = self._get_pk_attr()
    if raw is None or pk is None:
        return None
    pk_value = getattr(raw, pk.key, None)
    if not self._sorts:
        return (None, pk_value)
    sort = self._sorts[0]
    col_def = self._config.column_by_field(sort.field)
    key = col_def.display_key if col_def is not None else sort.field
    return ((row.get("__cursor_values__") or {}).get(key, row.get(key)), pk_value)

fetch_after

fetch_after(cursor: tuple[Any, Any] | None) -> tuple[list[dict], int]

Next page by keyset: WHERE (sort, pk) > cursor, with no OFFSET.

OFFSET makes the engine walk and discard the previous N rows, so page 500 costs 500 times the first one. The cursor does not: it is always an index seek. The cursor is composite because the visible order is rarely unique (two identical scores).

Source code in src/ms_components/ms_table/query_builder.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def fetch_after(self, cursor: tuple[Any, Any] | None) -> tuple[list[dict], int]:
    """Next page by keyset: `WHERE (sort, pk) > cursor`, with no OFFSET.

    OFFSET makes the engine walk and discard the previous N rows, so page 500
    costs 500 times the first one. The cursor does not: it is always an index
    seek. The cursor is composite because the visible order is rarely unique
    (two identical scores).
    """
    stmt = self._build_base_statement()
    clause = self._keyset_clause(cursor)
    if clause is not None:
        stmt = stmt.where(clause)
    with self._db.get_session() as session:
        total: int = session.exec(self._build_count_statement()).one()  # type: ignore[arg-type]
        rows = [self._row_to_dict(r) for r in session.exec(stmt.limit(self._page_size)).all()]
    return rows, total

fetch_window_after

fetch_window_after(cursor: tuple[Any, Any] | None) -> tuple[list[dict], bool]

The page after the cursor, without COUNT(*).

Infinite scroll only needs to know whether another page exists. Asking for one extra row answers that without re-counting a potentially huge table on every move.

Source code in src/ms_components/ms_table/query_builder.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def fetch_window_after(self, cursor: tuple[Any, Any] | None) -> tuple[list[dict], bool]:
    """The page after the cursor, without ``COUNT(*)``.

    Infinite scroll only needs to know whether another page exists. Asking for
    one extra row answers that without re-counting a potentially huge table on
    every move.
    """
    stmt = self._build_base_statement()
    clause = self._keyset_clause(cursor)
    if clause is not None:
        stmt = stmt.where(clause)
    with self._db.get_session() as session:
        raw = session.exec(stmt.limit(self._page_size + 1)).all()
    has_more = len(raw) > self._page_size
    return [self._row_to_dict(row) for row in raw[:self._page_size]], has_more

fetch_window_at

fetch_window_at(page_index: int) -> tuple[list[dict], bool]

Direct load for a scrollbar jump to a page that has not been walked yet.

The normal path is still keyset via :meth:fetch_window_after. OFFSET only shows up when the user drags the scrollbar to a position with no known cursor.

Source code in src/ms_components/ms_table/query_builder.py
208
209
210
211
212
213
214
215
216
217
218
219
220
def fetch_window_at(self, page_index: int) -> tuple[list[dict], bool]:
    """Direct load for a scrollbar jump to a page that has not been walked yet.

    The normal path is still keyset via :meth:`fetch_window_after`. OFFSET only
    shows up when the user drags the scrollbar to a position with no known
    cursor.
    """
    offset = max(0, int(page_index)) * self._page_size
    stmt = self._build_base_statement().offset(offset).limit(self._page_size + 1)
    with self._db.get_session() as session:
        raw = session.exec(stmt).all()
    has_more = len(raw) > self._page_size
    return [self._row_to_dict(row) for row in raw[:self._page_size]], has_more

fetch

fetch() -> tuple[list[dict], int]

Run the query and return (rows_as_dicts, total_count).

Source code in src/ms_components/ms_table/query_builder.py
245
246
247
248
249
250
251
252
253
254
255
256
257
def fetch(self) -> tuple[list[dict], int]:
    """Run the query and return (rows_as_dicts, total_count)."""
    stmt      = self._build_base_statement()
    count_stmt = self._build_count_statement()
    with self._db.get_session() as session:
        total: int = session.exec(count_stmt).one()  # type: ignore[arg-type]

        offset = (self._page - 1) * self._page_size
        stmt   = stmt.offset(offset).limit(self._page_size)

        raw_rows = session.exec(stmt).all()
        rows     = [self._row_to_dict(r) for r in raw_rows]
    return rows, total

to_query_spec

to_query_spec() -> dict

The "filtered" selection as QuerySpec kwargs, ready to hand to a job.

Returns a dict rather than a QuerySpec so the table does not take ms_flow as a dependency; the host does QuerySpec(**builder.to_query_spec()). This is what avoids materialising a list of ids: the job receives the query, not its result.

Raises when any part of the current state is not expressible (expression columns, LIKE/ILIKE filters over joins): staying quiet and sending a wider query would be worse.

Source code in src/ms_components/ms_table/query_builder.py
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
def to_query_spec(self) -> dict:
    """The "filtered" selection as QuerySpec kwargs, ready to hand to a job.

    Returns a dict rather than a QuerySpec so the table does not take ms_flow as
    a dependency; the host does `QuerySpec(**builder.to_query_spec())`. This is
    what avoids materialising a list of ids: the job receives the query, not its
    result.

    Raises when any part of the current state is not expressible (expression
    columns, LIKE/ILIKE filters over joins): staying quiet and sending a wider
    query would be worse.
    """
    model = self._config.model_class
    table = getattr(getattr(model, "__table__", None), "name", "") or model.__name__.lower()

    filters: dict[str, Any] = {}
    for f in self._all_filters():
        col_def = self._config.column_by_field(f.field)
        if col_def is not None and col_def.expr is not None:
            raise ValueError(f"Column '{f.field}' filters on a SQL expression; QuerySpec cannot carry it.")
        if "." in f.field:
            raise ValueError(f"Filter on joined field '{f.field}' has no QuerySpec equivalent.")
        if f.op in (FilterOperator.LIKE, FilterOperator.ILIKE):
            filters[f"{f.field}__contains"] = str(f.value).strip("%")
            continue
        suffix = self._SPEC_OPS.get(f.op)
        if suffix is None:
            raise ValueError(f"Filter operator {f.op} has no QuerySpec equivalent.")
        filters[f"{f.field}{suffix}"] = f.value

    if self._external_clauses:
        raise ValueError("Raw SQLAlchemy clauses set by the host cannot be exported to QuerySpec.")
    if self._search_text and self._search_fields:
        # Quick search is an OR across columns; QuerySpec filters are ANDed.
        raise ValueError("Quick search (OR across columns) has no QuerySpec equivalent.")

    order = tuple(
        f"-{s.field}" if s.descending else s.field
        for s in self._sorts
        if "." not in s.field
    )
    return {"table": table, "filters": filters, "order": order}

SessionProvider

Bases: Protocol

Methods:

Name Description
get_session

Return a fresh SQLModel session for a query cycle.

get_session

get_session() -> Session

Return a fresh SQLModel session for a query cycle.

Source code in src/ms_components/ms_table/session_provider.py
10
11
def get_session(self) -> Session:
    """Return a fresh SQLModel session for a query cycle."""

SmartTableView

SmartTableView(db: SessionProvider, config: TableConfig, parent=None)

Bases: QWidget

Smart table with pagination, filtering, sorting and column configuration.

Signals

row_clicked(object): SQLModel object for the clicked row. row_double_clicked(object): SQLModel object for the double-clicked row. selection_changed(list): List of selected objects. edit_committed(object, str, object): (raw_obj, field, new_value) when an inline edit is committed. data_refreshed(int): Total item count after each load.

Methods:

Name Description
refresh_counts

Refresh only the record counter (COUNT), without reloading rows.

ensure_viewport_filled

One tick of a job's start-up: reload rows only once there are enough to

patch_loaded_rows

Patch visible cached rows by a raw-object/key value; no model reset or reselection.

open_table_menu

Pop up the full menu for external hosts (a ribbon, for instance).

set_external_filters

Set (or replace) an external filter overlay under key and reload.

set_external_clause

Set (or, with None, drop) a raw SQLAlchemy clause under key and reload.

set_empty_state

Change the empty state at runtime: None restores what the config declares.

set_toolbar_button_style

Icons / text / both for the toolbar actions.

set_toolbar_actions

Put a host's commands on this table's toolbar while it needs them.

set_config_actions_visible

Show/hide the actions the config declares (toolbar_left/center/right).

view_state

Current user-facing table prefs: visible column fields + active sort.

apply_view_state

Apply a previously saved view_state(). Unknown fields are ignored;

refresh

Reload data from the DB with the current filter/sort/page state.

load_next_page

Load the next page and drop old pages that fell outside the window.

set_db

Replace the session provider (useful on project switch or reconnect).

repaint_rows

Repaint the visible rows without re-querying (host state changed, not the data).

get_selected_objects

Return the list of selected SQLModel objects.

get_selected_object

Return the first selected object, or None.

all_filtered_ids

PKs of every row matching the current filters/search (across all pages).

set_filter

Apply a filter programmatically.

set_filters

Replace the whole scope and reload exactly once.

select_first_row

Select the first materialised row; never the sentinel row.

Attributes:

Name Type Description
record_total int

Row total under the current filters (for the host's statusbar).

view

The inner QTableView — for hosts that need to scope focus or shortcuts to it.

Source code in src/ms_components/ms_table/smart_table_view.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def __init__(
    self,
    db: SessionProvider,
    config: TableConfig,
    parent=None
):
    super().__init__(parent)
    self._db      = db
    self._config  = config
    self._builder = QueryBuilder(db, config)
    self._model   = SmartTableModel(config)
    self._loading_more = False
    self._infinite_autofill_scheduled = False
    self._infinite_pages: dict[int, list[dict]] = {}
    self._infinite_page_cursors: dict[int, tuple | None] = {0: None}
    self._infinite_has_more = False
    self._infinite_frontier_page = 0
    self._infinite_total_hint = 0
    self._infinite_total_exact = True
    # How far the scroll has reached, in rows. Not `loaded_count`: the window drops
    # pages behind the cursor, so that one shrinks while this keeps growing.
    self._infinite_loaded_end = 0
    self._active_popup: QWidget | None = None

    self._setup_ui()
    self._connect_signals()
    self._update_control_summaries()
    self._sync_header()
    self.refresh()                  # Initial load

record_total property

record_total: int

Row total under the current filters (for the host's statusbar).

view property

view

The inner QTableView — for hosts that need to scope focus or shortcuts to it.

refresh_counts

refresh_counts() -> bool

Refresh only the record counter (COUNT), without reloading rows.

Meant for running jobs: the user watches the total grow and infinite scroll knows there are more pages, but the visible rows do not shift under the cursor. Returns False when the table is empty (nothing to preserve: the host should do a plain refresh()).

Source code in src/ms_components/ms_table/smart_table_view.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def refresh_counts(self) -> bool:
    """Refresh only the record counter (COUNT), without reloading rows.

    Meant for running jobs: the user watches the total grow and infinite scroll
    knows there are more pages, but the visible rows do not shift under the
    cursor. Returns False when the table is empty (nothing to preserve: the
    host should do a plain refresh()).
    """
    if self._model.loaded_count == 0:
        return False
    total = self._builder.count()
    if self._is_infinite_mode():
        self._infinite_total_hint = total
        self._infinite_total_exact = True
        self._infinite_has_more = self._infinite_loaded_end < total
        tail = self._infinite_pages.get(self._infinite_frontier_page)
        if tail is not None and len(tail) < self._builder.page_size and self._infinite_has_more:
            # The last page came back short and rows were added since: forget it, so the
            # autofill set_total() triggers fetches it again once it is in view. Kept, its
            # rows past the old end would stay blank forever.
            del self._infinite_pages[self._infinite_frontier_page]
            # The next page's cursor is the short page's last row: wrong now, so it is
            # found by offset instead.
            self._infinite_page_cursors.pop(self._infinite_frontier_page + 1, None)
    self._model.set_total(total)
    return True

ensure_viewport_filled

ensure_viewport_filled(*, force: bool = False) -> bool

One tick of a job's start-up: reload rows only once there are enough to fill the visible viewport (or once the host decides it has waited long enough, force=True).

Returns True when the viewport is full (or there is nothing else to fetch): the host then switches to "counters only" mode. False = still waiting, call again.

Source code in src/ms_components/ms_table/smart_table_view.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def ensure_viewport_filled(self, *, force: bool = False) -> bool:
    """One tick of a job's start-up: reload rows only once there are enough to
    fill the visible viewport (or once the host decides it has waited long
    enough, force=True).

    Returns True when the viewport is full (or there is nothing else to fetch):
    the host then switches to "counters only" mode. False = still waiting, call
    again.
    """
    loaded = self._model.loaded_count
    capacity = self._viewport_row_capacity()
    if loaded >= capacity:
        return True
    total = self._builder.count()
    if not force and total < capacity:
        return False  # not enough molecules yet to fill the view
    if total <= loaded:
        return force
    self.refresh()
    return force or self._model.loaded_count >= capacity

patch_loaded_rows

patch_loaded_rows(key: str, patches: dict[Any, dict[str, Any]]) -> bool

Patch visible cached rows by a raw-object/key value; no model reset or reselection.

Source code in src/ms_components/ms_table/smart_table_view.py
234
235
236
237
238
239
240
241
242
243
244
def patch_loaded_rows(self, key: str, patches: dict[Any, dict[str, Any]]) -> bool:
    """Patch visible cached rows by a raw-object/key value; no model reset or reselection."""
    changed = False
    for row in self._model.loaded_rows:
        data = self._model.get_row_data(row) or {}
        raw = data.get("__raw__")
        identity = data.get(key, getattr(raw, key, None))
        values = patches.get(identity)
        if values is not None:
            changed = self._model.patch_loaded_row(row, values) or changed
    return changed

open_table_menu

open_table_menu(anchor: QWidget | None = None) -> None

Pop up the full menu for external hosts (a ribbon, for instance).

The embedded toolbar exposes these options as actions of its own. anchor lets another host open the same collection under one of its own controls.

Source code in src/ms_components/ms_table/smart_table_view.py
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
def open_table_menu(self, anchor: QWidget | None = None) -> None:
    """Pop up the full menu for external hosts (a ribbon, for instance).

    The embedded toolbar exposes these options as actions of its own.
    ``anchor`` lets another host open the same collection under one of its own
    controls.
    """
    menu = QMenu(self)
    columns_action = menu.addAction("Columns", self._open_column_config)
    columns_action.setIcon(self._toolbar_icon("columns.svg"))
    menu.addMenu(self._create_export_menu(menu))
    if self._toolbar is not None:
        menu.addMenu(self._create_toolbar_style_menu(menu))

    if anchor is not None:
        menu.exec(anchor.mapToGlobal(anchor.rect().bottomLeft()))
    else:
        from PySide6.QtGui import QCursor
        menu.exec(QCursor.pos())

set_external_filters

set_external_filters(key: str, filters: list[FilterSpec]) -> None

Set (or replace) an external filter overlay under key and reload.

It composes with the user's interactive filters using AND, without touching them; passing an empty list is the same as clear_external_filters(key).

Source code in src/ms_components/ms_table/smart_table_view.py
600
601
602
603
604
605
606
607
def set_external_filters(self, key: str, filters: list[FilterSpec]) -> None:
    """Set (or replace) an external filter overlay under `key` and reload.

    It composes with the user's interactive filters using AND, without touching
    them; passing an empty list is the same as `clear_external_filters(key)`.
    """
    self._builder.set_external_filters(key, filters)
    self.refresh()

set_external_clause

set_external_clause(key: str, clause) -> None

Set (or, with None, drop) a raw SQLAlchemy clause under key and reload.

For conditions FilterSpec cannot express - typically a ~exists(...) against another table.

Source code in src/ms_components/ms_table/smart_table_view.py
613
614
615
616
617
618
619
620
def set_external_clause(self, key: str, clause) -> None:
    """Set (or, with None, drop) a raw SQLAlchemy clause under `key` and reload.

    For conditions FilterSpec cannot express - typically a `~exists(...)`
    against another table.
    """
    self._builder.set_external_clause(key, clause)
    self.refresh()

set_empty_state

set_empty_state(message: str | None = None, *, show_action: bool | None = None)

Change the empty state at runtime: None restores what the config declares.

A host that filters the table from outside knows something the table does not: "there is no data" and "the filter lets nothing through" are different states, and the action that fills the table (import, create) does not fix the second one.

Source code in src/ms_components/ms_table/smart_table_view.py
676
677
678
679
680
681
682
683
684
685
686
687
def set_empty_state(self, message: str | None = None, *, show_action: bool | None = None):
    """Change the empty state at runtime: `None` restores what the config declares.

    A host that filters the table from outside knows something the table does
    not: "there is no data" and "the filter lets nothing through" are different
    states, and the action that fills the table (import, create) does not fix
    the second one.
    """
    self._empty_label.setText(message or self._config.empty_message)
    button = self._empty_widget.findChild(QPushButton, "EmptyActionButton")
    if button is not None:
        button.setVisible(True if show_action is None else bool(show_action))

set_toolbar_button_style

set_toolbar_button_style(style: ToolButtonStyle) -> None

Icons / text / both for the toolbar actions.

Source code in src/ms_components/ms_table/smart_table_view.py
844
845
846
847
def set_toolbar_button_style(self, style: Qt.ToolButtonStyle) -> None:
    """Icons / text / both for the toolbar actions."""
    if self._toolbar is not None:
        self._toolbar.setToolButtonStyle(style)

set_toolbar_actions

set_toolbar_actions(key: str, actions: list[ToolbarAction]) -> None

Put a host's commands on this table's toolbar while it needs them.

Same shape as set_external_filters: keyed by the host (a tool id), replacing whatever that key had, and an empty list is clear_toolbar_actions(key). A tool that narrows a table can now also act on it, and gives both back on the way out.

Source code in src/ms_components/ms_table/smart_table_view.py
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
def set_toolbar_actions(self, key: str, actions: list[ToolbarAction]) -> None:
    """Put a host's commands on this table's toolbar while it needs them.

    Same shape as `set_external_filters`: keyed by the host (a tool id), replacing
    whatever that key had, and an empty list is `clear_toolbar_actions(key)`. A tool that
    narrows a table can now also act on it, and gives both back on the way out.
    """
    self.clear_toolbar_actions(key)
    if not actions:
        return
    if self._toolbar is None:
        # Config asked for no toolbar; a runtime action still has to be reachable.
        self.layout().insertWidget(0, self._build_toolbar())
    added = [self._make_action(a) for a in actions]
    for act in added:
        act.setVisible(not self._actions_collapsed)
        self._toolbar.insertAction(self._zone_anchor, act)
    self._keyed_actions[key] = added

set_config_actions_visible

set_config_actions_visible(visible: bool) -> None

Show/hide the actions the config declares (toolbar_left/center/right).

The built-in controls (Columns, Export, Reload, Settings) stay: they describe the table. The config's own actions describe the catalog — a table lent to a tool should not still be offering "Import…".

Source code in src/ms_components/ms_table/smart_table_view.py
1010
1011
1012
1013
1014
1015
1016
1017
1018
def set_config_actions_visible(self, visible: bool) -> None:
    """Show/hide the actions the config declares (toolbar_left/center/right).

    The built-in controls (Columns, Export, Reload, Settings) stay: they describe the
    table. The config's own actions describe the *catalog* — a table lent to a tool
    should not still be offering "Import…".
    """
    self._config_actions_visible = bool(visible)
    self._apply_collapsed_state()

view_state

view_state() -> dict

Current user-facing table prefs: visible column fields + active sort. Suitable for JSON/TOML persistence by the host app.

Source code in src/ms_components/ms_table/smart_table_view.py
1039
1040
1041
1042
1043
1044
1045
def view_state(self) -> dict:
    """Current user-facing table prefs: visible column fields + active sort.
    Suitable for JSON/TOML persistence by the host app."""
    return {
        "columns": [c.field for c in self._config.columns if c.visible],
        "sort": [{"field": s.field, "descending": bool(s.descending)} for s in self._builder.active_sorts],
    }

apply_view_state

apply_view_state(state: dict | None) -> None

Apply a previously saved view_state(). Unknown fields are ignored; an empty/missing 'columns' leaves the config-defined visibility intact.

Source code in src/ms_components/ms_table/smart_table_view.py
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
def apply_view_state(self, state: dict | None) -> None:
    """Apply a previously saved view_state(). Unknown fields are ignored;
    an empty/missing 'columns' leaves the config-defined visibility intact."""
    if not state:
        return
    columns = state.get("columns")
    if columns:
        wanted = set(columns)
        known = {c.field for c in self._config.columns}
        # Only apply if it references real columns; keep at least one visible.
        if wanted & known:
            for c in self._config.columns:
                c.visible = c.field in wanted
    sort = state.get("sort")
    if sort:
        self._builder.set_sort(
            [SortSpec(s["field"], descending=bool(s.get("descending"))) for s in sort if s.get("field")]
        )
    self._install_delegates()
    self._sync_header()
    self.refresh()

refresh

refresh(reset_page: bool = True) -> None

Reload data from the DB with the current filter/sort/page state.

Source code in src/ms_components/ms_table/smart_table_view.py
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
def refresh(self, reset_page: bool = True) -> None:
    """Reload data from the DB with the current filter/sort/page state."""
    if self._is_infinite_mode():
        self._builder.set_page(1)
        rows, _has_more = self._builder.fetch_window_after(None)
        self._infinite_pages = {0: rows}
        self._infinite_page_cursors = {0: None}
        if rows:
            self._infinite_page_cursors[1] = self._builder.cursor_of(rows[-1])
        # ponytail: one COUNT per refresh (not per scrolled page). "100+ records" is not
        # an answer to "how many are there"; move this off-thread if it ever profiles hot.
        total = self._builder.count()
        self._infinite_frontier_page = 0
        self._infinite_loaded_end = len(rows)
        self._infinite_has_more = bool(rows) and len(rows) < total
        self._infinite_total_hint = total
        self._infinite_total_exact = True
        self._model.load_data(
            rows,
            total,
            1,
            window_start=0,
            total_is_exact=True,
        )
        return
    if reset_page:
        self._builder.set_page(1)

    rows, total = self._builder.fetch()
    self._model.load_data(rows, total, self._builder.current_page)

load_next_page

load_next_page() -> None

Load the next page and drop old pages that fell outside the window.

Source code in src/ms_components/ms_table/smart_table_view.py
1228
1229
1230
1231
1232
1233
1234
def load_next_page(self) -> None:
    """Load the next page and drop old pages that fell outside the window."""
    if not self._is_infinite_mode() or self._loading_more:
        return
    if not self._infinite_has_more:
        return
    self._load_infinite_page(self._infinite_frontier_page + 1)

set_db

set_db(db: SessionProvider) -> None

Replace the session provider (useful on project switch or reconnect).

Source code in src/ms_components/ms_table/smart_table_view.py
1301
1302
1303
1304
1305
1306
def set_db(self, db: SessionProvider) -> None:
    """Replace the session provider (useful on project switch or reconnect)."""
    self._db = db
    self._builder = QueryBuilder(db, self._config)
    self._update_control_summaries()
    self.refresh()

repaint_rows

repaint_rows() -> None

Repaint the visible rows without re-querying (host state changed, not the data).

Source code in src/ms_components/ms_table/smart_table_view.py
1313
1314
1315
def repaint_rows(self) -> None:
    """Repaint the visible rows without re-querying (host state changed, not the data)."""
    self._table.viewport().update()

get_selected_objects

get_selected_objects() -> list[Any]

Return the list of selected SQLModel objects.

Source code in src/ms_components/ms_table/smart_table_view.py
1317
1318
1319
1320
1321
1322
1323
1324
1325
def get_selected_objects(self) -> list[Any]:
    """Return the list of selected SQLModel objects."""
    indexes = self._table.selectionModel().selectedRows()
    objs = []
    for idx in indexes:
        raw = self._model.data(idx, RAW_OBJECT_ROLE)
        if raw is not None:
            objs.append(raw)
    return objs

get_selected_object

get_selected_object() -> Optional[Any]

Return the first selected object, or None.

Source code in src/ms_components/ms_table/smart_table_view.py
1327
1328
1329
1330
def get_selected_object(self) -> Optional[Any]:
    """Return the first selected object, or None."""
    objs = self.get_selected_objects()
    return objs[0] if objs else None

all_filtered_ids

all_filtered_ids() -> list[int]

PKs of every row matching the current filters/search (across all pages).

Source code in src/ms_components/ms_table/smart_table_view.py
1332
1333
1334
def all_filtered_ids(self) -> list[int]:
    """PKs of every row matching the current filters/search (across all pages)."""
    return self._builder.all_ids()

set_filter

set_filter(spec: FilterSpec) -> None

Apply a filter programmatically.

Source code in src/ms_components/ms_table/smart_table_view.py
1336
1337
1338
1339
1340
1341
def set_filter(self, spec: FilterSpec) -> None:
    """Apply a filter programmatically."""
    self._builder.add_filter(spec)
    self._sync_header()
    self._update_control_summaries()
    self.refresh()

set_filters

set_filters(filters: list[FilterSpec]) -> None

Replace the whole scope and reload exactly once.

This is the right operation for external controls that change receptor, ligand, protocol and metrics as a single state transition.

Source code in src/ms_components/ms_table/smart_table_view.py
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
def set_filters(self, filters: list[FilterSpec]) -> None:
    """Replace the whole scope and reload exactly once.

    This is the right operation for external controls that change receptor,
    ligand, protocol and metrics as a single state transition.
    """
    self._builder.set_filters(list(filters))
    self._sync_header()
    self._update_control_summaries()
    self.refresh()

select_first_row

select_first_row() -> bool

Select the first materialised row; never the sentinel row.

Source code in src/ms_components/ms_table/smart_table_view.py
1354
1355
1356
1357
1358
1359
def select_first_row(self) -> bool:
    """Select the first materialised row; never the sentinel row."""
    if self._model.loaded_count == 0:
        return False
    self._table.selectRow(self._model.window_start)
    return True

ColumnDef dataclass

ColumnDef(field: str, label: Optional[str] = None, join: Any = None, width: Optional[int] = None, min_width: int = 50, max_width: Optional[int] = None, resizable: bool = True, sortable: bool = True, searchable: bool = True, filterable: bool = True, kind: ColumnKind = TEXT, choices: tuple[str, ...] = (), editable: bool = False, visible: bool = True, align: AlignHint = LEFT, formatter: Optional[Callable] = None, delegate: Optional[str] = None, tooltip: Any = None, db_column: Optional[str] = None, expr: Any = None, progress_max: Optional[str] = None, progress_tone: Optional[Callable] = None, paint_factory: Optional[Callable] = None, widget_factory: Optional[Callable] = None, editor_factory: Optional[Callable] = None, editor_signal: Optional[str] = None, editor_getter: Optional[Callable] = None, cell_height: Optional[int] = None)

Define a visible column in the table.

Parameters:

Name Type Description Default
field str

Path to the field. Supports dot-notation for joins: "customer.name" → resolves Order.customer.name

required
label Optional[str]

Visible header. When None, the capitalised field is used.

None
join Any

SQLModel class to join in order to resolve this field. May be a list for chained joins.

None
width Optional[int]

Initial width in px (None = auto).

None
min_width int

Minimum width in px.

50
max_width Optional[int]

Maximum width in px.

None
resizable bool

Whether the user may resize it.

True
sortable bool

Whether the table may be sorted by this column.

True
filterable bool

Whether the column may be filtered.

True
editable bool

Whether the cell is editable inline.

False
visible bool

Visible by default.

True
align AlignHint

Content alignment.

LEFT
formatter Optional[Callable]

Function (raw_value) → str used for display.

None
delegate Optional[str]

Name of a custom Qt delegate (for editors).

None
tooltip Any

Static tooltip, or callable(row_data) → str.

None
db_column Optional[str]

Override for the real DB column name (when it differs from field).

None
expr Any

SQLAlchemy expression producing the column value, for anything that is not a model attribute (JSON_EXTRACT, CASE, func.*). When set, field is only the key/label.

None
progress_max Optional[str]

PROGRESS columns only: the row key holding the maximum. Set, the value is a count and the bar reads "value/max (pct%)", as QProgressBar's "%v/%m (%p%)"; unset, the value is a 0..1 fraction.

None
progress_tone Optional[Callable]

PROGRESS columns only: callable(row_data) → a theme accent ("green", "red"), the bar's tone property. Unset, "green".

None

Attributes:

Name Type Description
display_key str

Unique key used internally (dots replaced).

display_key property

display_key: str

Unique key used internally (dots replaced).

FilterSpec dataclass

FilterSpec(field: str, op: FilterOperator = ILIKE, value: Any = None, label: Optional[str] = None)

Specification of an active filter.

Parameters:

Name Type Description Default
field str

Field name (same as ColumnDef.field).

required
op FilterOperator

Comparison operator.

ILIKE
value Any

Value to compare against (None for IS NULL / NOT NULL).

None
label Optional[str]

Friendly label shown in the UI chips.

None

SortSpec dataclass

SortSpec(field: str, descending: bool = False)

Sort specification.

Parameters:

Name Type Description Default
field str

Field to sort by.

required
descending bool

True = DESC, False = ASC.

False

TableConfig dataclass

TableConfig(model_class: Type[SQLModel], columns: list[ColumnDef] = list(), default_sort: list[SortSpec] = list(), default_filters: list[FilterSpec] = list(), page_size: int = DEFAULT_PAGE_SIZE, page_size_options: list[int] = (lambda: list(DEFAULT_PAGE_SIZES))(), load_mode: TableLoadMode | str = PAGINATED, infinite_scroll_margin: int = 120, infinite_cache_pages: int = 2, max_export_rows: int = DEFAULT_MAX_EXPORT_ROWS, selectable: bool = True, multi_select: bool = True, show_row_numbers: bool = False, show_vertical_header: bool = False, show_checkboxes: bool = False, alternating_rows: bool = True, allow_column_reorder: bool = True, allow_column_resize: bool = True, allow_row_resize: bool = False, context_menu_actions: dict[str, Callable] = dict(), row_height: Optional[int] = None, empty_message: str = 'No data to show', compact_controls: bool = True, show_filters: bool = True, show_search: bool = True, max_loaded_rows: int = 5000, embedded_controls: bool = True, show_record_count: bool = True, toolbar_left: list['ToolbarAction'] = list(), toolbar_center: list['ToolbarAction'] = list(), toolbar_right: list['ToolbarAction'] = list(), toolbar_collapsible: bool = False, empty_action: Optional['ToolbarAction'] = None, row_background: Optional[Callable] = None)

Full configuration of a SmartTableView.

Parameters:

Name Type Description Default
model_class Type[SQLModel]

Main SQLModel class (base table).

required
columns list[ColumnDef]

List of ColumnDef. Their order is the column order.

list()
default_sort list[SortSpec]

Initial sort.

list()
default_filters list[FilterSpec]

Filters active on startup.

list()
page_size int

Initial items per page.

DEFAULT_PAGE_SIZE
page_size_options list[int]

Options offered by the pagination dropdown.

(lambda: list(DEFAULT_PAGE_SIZES))()
load_mode TableLoadMode | str

"paginated" uses PaginationControl; "infinite" loads the next page as the end comes into view.

PAGINATED
infinite_scroll_margin int

Distance in px from the end at which more rows are requested.

120
max_export_rows int

Row cap for exports.

DEFAULT_MAX_EXPORT_ROWS
selectable bool

Allow row selection.

True
multi_select bool

Allow multi-selection.

True
show_row_numbers bool

Show the row-number column.

False
show_vertical_header bool

Show Qt's native vertical header.

False
show_checkboxes bool

Show the selection checkbox column.

False
alternating_rows bool

Alternate row colours.

True
allow_column_reorder bool

The user may reorder columns by dragging.

True
allow_column_resize bool

The user may resize columns.

True
allow_row_resize bool

The user may resize row heights.

False
context_menu_actions dict[str, Callable]

Extra context-menu actions. dict[label, callable(selected_rows)]

dict()
row_height Optional[int]

Row height in px (None = auto).

None
empty_message str

Text shown when there are no results.

'No data to show'
compact_controls bool

Use a compact toolbar with collapsible panels for filtering and sorting.

True
show_filters bool

False = "simple" table: no column offers a filter.

True
show_search bool

False = "simple" table: no column offers a search.

True
infinite_cache_pages int

Contiguous pages kept in INFINITE mode. Old pages are replaced, so memory does not grow with scroll.

2
max_loaded_rows int

Compatibility: extra absolute ceiling for the window.

5000

ToolbarAction dataclass

ToolbarAction(label: str = '', icon: Any = None, on_click: Optional[Callable[[list], None]] = None, tooltip: str = '')

An action injected into the toolbar. Deliberately dumb: the component neither interprets on_click nor the objects it passes along - it just forwards them. All the domain semantics live in the host's closure, which is what keeps the table agnostic.

on_click: callable(selected_objects) - same signature as context_menu_actions.
icon:     QIcon (opaque to this layer) or None.

SmartTableModel

SmartTableModel(config: TableConfig, parent=None)

Bases: QAbstractTableModel

Qt model for SmartTableView.

Signals

data_loaded(int, int): (total_items, current_page) after a refresh. edit_requested(object, str, object): (raw_obj, field_name, new_value) when the user edits a cell.

Methods:

Name Description
load_data

Load one page of data. Called by SmartTableView.

set_total

Update only the total (counters/pagination), leaving loaded rows alone.

patch_loaded_row

Change displayed values in one loaded row without resetting selection or scroll.

Source code in src/ms_components/ms_table/table_model.py
49
50
51
52
53
54
55
56
57
def __init__(self, config: TableConfig, parent=None):
    super().__init__(parent)
    self._config:  TableConfig   = config
    self._rows:    list[dict]    = []
    self._total:   int           = 0
    self._total_is_exact: bool   = True
    self._window_start: int      = 0
    self._page:    int           = 1
    self._visible_cols: list[ColumnDef] = config.visible_columns()

load_data

load_data(rows: list[dict], total: int, page: int, *, window_start: int = 0, total_is_exact: bool = True) -> None

Load one page of data. Called by SmartTableView.

Source code in src/ms_components/ms_table/table_model.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def load_data(
    self,
    rows: list[dict],
    total: int,
    page: int,
    *,
    window_start: int = 0,
    total_is_exact: bool = True,
) -> None:
    """Load one page of data. Called by SmartTableView."""
    self.beginResetModel()
    self._rows  = rows
    self._total = total
    self._total_is_exact = bool(total_is_exact)
    self._window_start = max(0, int(window_start))
    self._page  = page
    self._visible_cols = self._config.visible_columns()
    self.endResetModel()
    self.data_loaded.emit(total, page)

set_total

set_total(total: int) -> None

Update only the total (counters/pagination), leaving loaded rows alone.

Source code in src/ms_components/ms_table/table_model.py
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def set_total(self, total: int) -> None:
    """Update only the total (counters/pagination), leaving loaded rows alone."""
    new_total = max(0, int(total))
    old_total = self.rowCount()
    if self._config.load_mode == TableLoadMode.INFINITE and new_total > old_total:
        self.beginInsertRows(QModelIndex(), old_total, new_total - 1)
        self._total = new_total
        self.endInsertRows()
    elif self._config.load_mode == TableLoadMode.INFINITE and new_total < old_total:
        self.beginRemoveRows(QModelIndex(), new_total, old_total - 1)
        self._total = new_total
        self.endRemoveRows()
    else:
        self._total = new_total
    self._total_is_exact = True
    self.data_loaded.emit(self._total, self._page)

patch_loaded_row

patch_loaded_row(row: int, values: dict[str, Any]) -> bool

Change displayed values in one loaded row without resetting selection or scroll.

Source code in src/ms_components/ms_table/table_model.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def patch_loaded_row(self, row: int, values: dict[str, Any]) -> bool:
    """Change displayed values in one loaded row without resetting selection or scroll."""
    row_data = self.get_row_data(row)
    if row_data is None:
        return False
    changed_columns: list[int] = []
    offset = 1 if self._config.show_row_numbers else 0
    for column, col_def in enumerate(self._visible_cols, start=offset):
        key = col_def.display_key
        if key in values and row_data.get(key) != values[key]:
            row_data[key] = values[key]
            changed_columns.append(column)
    if not changed_columns:
        return False
    self.dataChanged.emit(
        self.index(row, min(changed_columns)),
        self.index(row, max(changed_columns)),
        [Qt.DisplayRole],
    )
    return True

choices_from_class

choices_from_class(cls: type) -> tuple[str, ...]

Extract the string values of a constants class (not an Enum).

Useful for patterns such as

class MoleculeType: SMALL_MOLECULE = "small_molecule" PROTEIN = "protein"

Source code in src/ms_components/ms_table/table_config.py
29
30
31
32
33
34
35
36
37
38
39
40
def choices_from_class(cls: type) -> tuple[str, ...]:
    """Extract the string values of a constants class (not an Enum).

    Useful for patterns such as:
        class MoleculeType:
            SMALL_MOLECULE = "small_molecule"
            PROTEIN        = "protein"
    """
    return tuple(
        v for k, v in vars(cls).items()
        if not k.startswith("_") and isinstance(v, str)
    )

Monitor

ms_monitor

The monitor of MolSuite jobs. Its names are loaded when first asked for: config is read by processes that never open a window, and they must not pay for Qt.

Modules:

Name Description
bridge
cards
config

Default configuration for the monitor component.

detail_panel
job_cards

Card-based job list for the monitor.

models
palette

Monitor colors, resolved live from the QPalette and the active theme.

polling

Hosts and executors

ms_executors

The machines that run jobs and who runs them on them.

KeptPage is a Settings page: the entries kept under one name each (hosts or executors) and the form of the one chosen. ExecutorsPanel is the quick window that starts and stops the executors. Both go through a ms_flow.Workspace (save_host, remove_host, save_executor, remove_executor, executors, activate, deactivate), which is who refuses what is not to be done now, and says why.

Classes:

Name Description
KeptPage

The entries kept in table (hosts or executors) and the form of the chosen one.

ExecutorsPanel

Who runs jobs now: a row for each executor kept, started and stopped from here.

KeptPage

KeptPage(workspace, table: str, parent: QWidget | None = None)

Bases: QWidget

The entries kept in table (hosts or executors) and the form of the chosen one.

"Save" and "Remove" act at once, through the workspace: an entry is whole or it is not kept, so the "Save changes" of the dialog around this page is not for it.

Source code in src/ms_components/ms_executors.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
def __init__(self, workspace, table: str, parent: QWidget | None = None):
    super().__init__(parent)
    self._workspace, self._table, self._one = workspace, table, table[:-1]
    settings = type(workspace.settings_manager.settings)
    self._model = get_args(settings.model_fields[table].annotation)[1]
    self._editors: dict[str, QWidget] = {}
    self._body: QWidget | None = None

    root = QHBoxLayout(self)
    left = QVBoxLayout()
    self.names = QListWidget(self)
    self.names.setMaximumWidth(190)
    self.names.currentTextChanged.connect(lambda name: name and self._show(name))
    left.addWidget(self.names, 1)
    buttons = QHBoxLayout()
    self.new_button = QPushButton("New", self)
    self.new_button.clicked.connect(lambda: self._show(None))
    self.remove_button = QPushButton("Remove", self)
    self.remove_button.clicked.connect(self._remove)
    buttons.addWidget(self.new_button)
    buttons.addWidget(self.remove_button)
    left.addLayout(buttons)
    root.addLayout(left)

    self._right = QVBoxLayout()
    self.said = QLabel(self)
    self.said.setWordWrap(True)
    self.save_button = QPushButton("Save", self)
    self.save_button.clicked.connect(self._save)
    foot = QHBoxLayout()
    foot.addWidget(self.said, 1)
    foot.addWidget(self.save_button)
    self._right.addStretch(1)
    self._right.addLayout(foot)
    root.addLayout(self._right, 1)
    self.reload()

ExecutorsPanel

ExecutorsPanel(workspace, parent: QWidget | None = None)

Bases: QDialog

Who runs jobs now: a row for each executor kept, started and stopped from here.

Starting one waits for its machines, so it is done in a thread, and what is done on a machine that is being prepared is shown line by line. The round mark is the executor a job goes to when nobody is named.

Source code in src/ms_components/ms_executors.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
def __init__(self, workspace, parent: QWidget | None = None):
    super().__init__(parent)
    self.setWindowTitle("Executors")
    self.setModal(False)
    self.resize(640, 260)
    self._workspace = workspace
    self._busy: set[str] = set()  # the ones a thread of this panel is starting or stopping
    self._shown: Any = None
    self._body: QWidget | None = None
    self.buttons: dict[str, QPushButton] = {}
    self.states: dict[str, QLabel] = {}
    self.defaults: dict[str, QRadioButton] = {}

    self._layout = QVBoxLayout(self)
    self.said = QPlainTextEdit(self)
    self.said.setReadOnly(True)
    self.said.setVisible(False)
    self._layout.addWidget(self.said, 1)
    self._layout.addStretch(0)  # takes the room left only while nothing is said
    foot = QHBoxLayout()
    foot.addStretch(1)
    self.settings_button = QPushButton("Hosts and executors…", self)
    self.settings_button.setToolTip("They are made and changed in Settings.")
    self.settings_button.clicked.connect(self.settings_requested.emit)
    foot.addWidget(self.settings_button)
    self._layout.addLayout(foot)

    self.line.connect(self._say)
    self.ended.connect(self._ended)
    self._timer = QTimer(self)
    self._timer.setInterval(1000)
    self._timer.timeout.connect(self.refresh)
    self.refresh()

Projects

ms_projects

Modules:

Name Description
browser
scratch

Reflex/Vercel-style dark theme demo for PySide6

Settings

ms_settings

Modules:

Name Description
panel

Classes:

Name Description
AppSettingsPanel

Tree + page typed editor for one or more layered MolSuite configurations.

AppSettingsPanel

AppSettingsPanel(*, runtime=None, configurations=None, app_name: str | None = None, icon_provider=None, parent: QWidget | None = None)

Bases: QWidget

Tree + page typed editor for one or more layered MolSuite configurations.

Layout follows the IDE convention: search box and a three-level tree on the left, breadcrumb and the selected page on the right.

Methods:

Name Description
set_close_action

Show a Close button in the footer wired to callback (used by the host

add_page

Host a self-managing page (no config rows) as one more top-level tree node.

Source code in src/ms_components/ms_settings/panel.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def __init__(
    self,
    *,
    runtime=None,
    configurations=None,
    app_name: str | None = None,
    icon_provider=None,
    parent: QWidget | None = None,
):
    super().__init__(parent)
    self.runtime = runtime
    self.app_name = str(app_name or getattr(runtime, "app_id", "Application")).strip()
    # Host app injects its themed icon(name)->QIcon loader so the dialog's icons match
    # the rest of the app; falls back to Qt standard icons when not provided.
    self._icon_provider = icon_provider
    self._configurations: dict[str, Any] = {}
    self._rows: dict[tuple[str, str], _ParameterRow] = {}
    self._nodes: dict[str, _Node] = {}
    self._items: dict[str, QTreeWidgetItem] = {}
    self._pages: dict[str, QWidget] = {}
    self._search_hits: dict[str, list[str]] = {}
    self._highlighted: list[QLabel] = []
    self._dirty_rows: set[tuple[str, str]] = set()
    self._setup_ui()

    sources = configurations
    if sources is None and runtime is not None and hasattr(runtime, "configuration_sources"):
        sources = runtime.configuration_sources()
    for configuration in tuple(sources or ()):
        self.add_config(configuration)
    self._update_empty_state()

set_close_action

set_close_action(callback) -> None

Show a Close button in the footer wired to callback (used by the host dialog so Reload / Save / Close share one row).

Source code in src/ms_components/ms_settings/panel.py
329
330
331
332
333
def set_close_action(self, callback) -> None:
    """Show a Close button in the footer wired to ``callback`` (used by the host
    dialog so Reload / Save / Close share one row)."""
    self.close_button.setVisible(True)
    self.close_button.clicked.connect(callback)

add_page

add_page(widget: QWidget, title: str) -> None

Host a self-managing page (no config rows) as one more top-level tree node.

The page saves on its own -- "Save changes" and "Reload" ignore it.

Source code in src/ms_components/ms_settings/panel.py
771
772
773
774
775
776
777
778
def add_page(self, widget: QWidget, title: str) -> None:
    """Host a self-managing page (no config rows) as one more top-level tree node.

    The page saves on its own -- "Save changes" and "Reload" ignore it."""
    node = self._node(f"page:{title}", str(title), None)
    node.widget = widget
    self._build_branch(node.key)
    self._update_empty_state()

PyMOL

ms_pymol

Modules:

Name Description
plugins

pymol_plugins.py

widget

Classes:

Name Description
PymolControlBar

Compact, reusable controls for an embedded PyMOL scene.

PymolControlBar

PymolControlBar(cmd, parent: QWidget | None = None)

Bases: QFrame

Compact, reusable controls for an embedded PyMOL scene.

Methods:

Name Description
add_menu_action

Add an application menu that participates in native toolbar overflow.

add_action

Add one application action to the embedded toolbar.

refresh_context_label

Keep the compact context label useful without turning it into a second status bar.

Source code in src/ms_components/ms_pymol/widget.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
def __init__(self, cmd, parent: QWidget | None = None):
    super().__init__(parent)
    self._cmd = cmd
    self._context = PymolSceneContext()
    self._grid_label_objects: set[str] = set()
    self._comparison_layout = "grid"
    self._presets: dict[str, PymolPresetSpec] = {}
    self.setObjectName("PymolControlBar")

    self._toolbar_action_icons: dict[QAction, str] = {}
    self._selection_mode_actions: dict[int, QAction] = {}
    self._overflow_btn: QToolButton | None = None

    # Every control is a QAction. This is required for an embedded QToolBar to
    # populate its extension menu when it is not owned by a QMainWindow.
    outer = QHBoxLayout(self)
    outer.setContentsMargins(6, 1, 6, 1)
    outer.setSpacing(0)
    bar = self.toolbar = QToolBar(self)
    bar.setObjectName("PymolToolbar")
    bar.setMovable(False)
    bar.setFloatable(False)
    bar.setContentsMargins(0, 0, 0, 0)
    bar.setIconSize(QSize(16, 16))
    bar.setToolButtonStyle(Qt.ToolButtonIconOnly)
    outer.addWidget(bar, 1)

    selection_menu = QMenu("Selection", bar)
    selection_group = QActionGroup(selection_menu)
    selection_group.setExclusive(True)
    for label, value in SELECTION_MODES:
        action = selection_menu.addAction(label)
        action.setCheckable(True)
        action.setData(value)
        selection_group.addAction(action)
        action.triggered.connect(
            lambda checked=False, mode=value: self._set_selection_mode(mode)
        )
        self._selection_mode_actions[value] = action
    selection_menu.addSeparator()
    selection_menu.addAction("Clear selection", self.clear_selection)
    self.selection_mode_action = self._add_toolbar_action(
        "Selection", "selection.svg",
        "Mouse selection granularity used when clicking the 3D scene.",
        menu=selection_menu,
    )

    actions_menu = QMenu("Actions", bar)
    for label, icon_name, callback in (
        ("Zoom", "zoom.svg", self.zoom_active),
        ("Orient", "orient.svg", self.orient_active),
        ("Center", "center.svg", self.center_active),
    ):
        action = actions_menu.addAction(self._toolbar_icon(icon_name), label)
        action.triggered.connect(callback)
    self.actions_action = self._add_toolbar_action(
        "Actions", "actions.svg",
        "Zoom, orient, or center the current PyMOL selection or scene target.",
        menu=actions_menu,
    )

    representation_menu = QMenu("Display", bar)
    for label, value in REPRESENTATIONS:
        action = representation_menu.addAction(label)
        action.setData(value)
        action.triggered.connect(
            lambda checked=False, representation=value:
            self.apply_representation(representation)
        )
    self.representation_action = self._add_toolbar_action(
        "Display", "display.svg",
        "Apply a representation to the current selection or scene target.",
        menu=representation_menu,
    )
    self.comparison_menu = representation_menu.addMenu("Comparison")
    self.comparison_menu.menuAction().setVisible(False)
    comparison_group = QActionGroup(self.comparison_menu)
    comparison_group.setExclusive(True)
    self._comparison_layout_actions: dict[str, QAction] = {}
    for label, value in (("Grid", "grid"), ("Overlay", "overlay")):
        action = self.comparison_menu.addAction(label)
        action.setCheckable(True)
        action.setChecked(value == self._comparison_layout)
        comparison_group.addAction(action)
        action.triggered.connect(lambda checked=False, layout=value: self.set_comparison_layout(layout))
        self._comparison_layout_actions[value] = action

    hide_menu = QMenu("Hide", bar)
    for label, value in (("Everything", "everything"), *REPRESENTATIONS):
        action = hide_menu.addAction(label)
        action.setData(value)
        action.triggered.connect(
            lambda checked=False, representation=value:
            self.hide_representation(representation)
        )
    self.hide_action = self._add_toolbar_action(
        "Hide", "hide.svg",
        "Hide a representation from the current selection or scene target.",
        menu=hide_menu,
    )

    self.preset_menu = QMenu("Presets", bar)
    self.preset_action = self._add_toolbar_action(
        "Presets", "presets.svg",
        "Apply a preset compatible with the current molecular scene.",
        menu=self.preset_menu,
    )

    self.context_label = QLabel("Generic", self)
    self.context_label.setObjectName("PymolSceneContext")
    self.context_label.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
    self.context_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
    self.context_label.setToolTip("Current molecular scene context.")
    outer.addWidget(self.context_label)

    self._register_builtin_presets()
    self._sync_selection_mode()
    self.refresh_context_label()
    self._refresh_preset_menu()
    self._configure_overflow_button()
    if self._cmd is None:
        self.setEnabled(False)

add_menu_action

add_menu_action(text: str, menu: QMenu, *, icon_name: str = 'settings.svg', tooltip: str = '') -> QAction

Add an application menu that participates in native toolbar overflow.

Source code in src/ms_components/ms_pymol/widget.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
def add_menu_action(
    self,
    text: str,
    menu: QMenu,
    *,
    icon_name: str = "settings.svg",
    tooltip: str = "",
) -> QAction:
    """Add an application menu that participates in native toolbar overflow."""
    action = self._add_toolbar_action(
        text, icon_name, tooltip or text, menu=menu
    )
    self._configure_overflow_button()
    return action

add_action

add_action(text: str, *, icon_name: str, tooltip: str = '', callback: Callable[[], None] | None = None, checkable: bool = False) -> QAction

Add one application action to the embedded toolbar.

Source code in src/ms_components/ms_pymol/widget.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
def add_action(
    self,
    text: str,
    *,
    icon_name: str,
    tooltip: str = "",
    callback: Callable[[], None] | None = None,
    checkable: bool = False,
) -> QAction:
    """Add one application action to the embedded toolbar."""
    action = self._add_toolbar_action(
        text,
        icon_name,
        tooltip or text,
        callback,
        checkable=checkable,
    )
    self._configure_overflow_button()
    return action

refresh_context_label

refresh_context_label() -> None

Keep the compact context label useful without turning it into a second status bar.

Source code in src/ms_components/ms_pymol/widget.py
643
644
645
646
647
648
649
650
651
652
653
654
def refresh_context_label(self) -> None:
    """Keep the compact context label useful without turning it into a second status bar."""
    kind = str(self._context.kind or "generic").replace("_", " ").title()
    target = str(self._context.target or "all").strip() or "all"
    summary, details = self._active_selection_summary()
    if not summary and target != "all":
        summary = target
    self.context_label.setText(f"{kind} · {summary}" if summary else kind)
    context_lines = [f"Scene: {kind}", f"Target: {target}"]
    for role, selection in dict(self._context.selections or {}).items():
        context_lines.append(f"{str(role).title()}: {selection}")
    self.context_label.setToolTip("\n".join([*context_lines, *details]))

Comparison scenes provide grid and overlay layouts without reloading structures or changing the camera. Assign each structure/pose pair the same positive PyMOL grid_slot, then pass its display name:

dock.set_scene_context(
    "comparison",
    grid_labels={1: "Complex A", 3: "Complex B"},
)
dock.set_comparison_layout("overlay")

grid_labels is also accepted by PymolControlBar and PymolSceneContext. Without explicit labels, molecular object titles (or object names when no title is available) are joined per positive grid_slot within the context's target. Applications must pass display names if those names are not present in PyMOL.

The Display > Comparison > Grid / Overlay menu appears only in comparison scenes. The chosen layout persists when changing poses or applying presets. Overlay displays the existing coordinates together; alignment remains the caller's responsibility.

Each label is a pseudoatom named panel_text<slot>, initially sized 14. Grid labels appear at the top-left of their panel; overlay labels form a stacked legend that follows viewport resizing. Receptor cartoons, carbon atoms and label text share the slot's COMPARISON_COLORS color. Heteroatom colors are preserved. The label outline uses PyMOL's background-adaptive front color for readability on light and dark backgrounds. The palette has ten colors and repeats beyond ten slots. Existing labels are updated in place, so individual settings remain editable, for example cmd.set("label_size", 18, "panel_text3"). Presets restore label visibility after changing molecular representations.

To replace one pose without rebuilding the scene, pass its bytes directly:

dock.replace_comparison_object(
    "pose_a", pdb_bytes, "pdb",
    reference="receptor_a", label="Complex A [P2]",
)

The new object inherits the reference's transformation and slot, is shown as sticks, and receives the same comparison color. Camera and auto-zoom settings are preserved. The previous object remains available until the replacement loads successfully. This API neither fetches data nor calls the hosting application. Hosts remain responsible for reading their data and discarding stale requests.

Setting any non-comparison scene context after a comparison context removes the managed labels and sets grid_mode to zero. Applications should update the scene context when leaving their off-target view, even without loading a new structure. Unrelated grid scenes and user-created annotations are not removed. "offtarget" remains accepted as an alias for "comparison".

Stepper

ms_stepper

qstepper.py

Reusable stepper component for PySide6, modelled on Quasar's (https://quasar.dev/vue-components/stepper).

It provides
  • A header with indicators (number / prefix / icon / check / error) and connectors. HORIZONTAL or VERTICAL orientation (inline content).
  • Per-step states: inactive, active, done and error.
  • alternative-labels (horizontal): title/caption centred under the indicator. inline-label (vertical): title next to the indicator.
  • prefix: short text inside the indicator instead of the number.
  • Per-state icons: done / active / error, global and per step.
  • Per-step colours (active / done / inactive / error) over the base theme.
  • Validation hooks: each step may carry a validator that blocks advancing and marks the step as failed.
  • Programmatic navigation (next / previous / go_to) and header clicks (configurable per step).
  • Optional navigation bar (Back / Continue / Finish).
  • QSS-style theming: painted colours exposed as qproperty-*, and chrome (buttons, connectors, background) styleable by objectName. Example themes: light, dark, outlined and flat.
  • Signals: current_changed, step_changed, finished, validation_failed.

Classes:

Name Description
StepperTheme

Colours and metrics of the painted parts (indicators, connectors).

QStep

One stepper step. A QWidget whose layout you fill with the content.

QStepper

Signals:

StepperTheme

StepperTheme()

Colours and metrics of the painted parts (indicators, connectors).

The colours come from the live QPalette (and the theme's semantic accents), so they follow the active theme without anyone pinning them by hand.

Source code in src/ms_components/ms_stepper.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def __init__(self) -> None:
    pal = QApplication.palette() if QApplication.instance() else QPalette()
    Role = QPalette.ColorRole
    self.primary = pal.color(Role.Highlight)
    self.done = pal.color(Role.Highlight)
    self.error = color("red")
    self.warning = color("orange")
    self.inactive = pal.color(Role.Mid)
    self.text_active = pal.color(Role.Highlight)
    self.text = pal.color(Role.Text)
    self.text_inactive = pal.color(Role.PlaceholderText)
    self.caption = pal.color(Role.PlaceholderText)
    self.connector = pal.color(Role.Mid)
    self.separator = pal.color(Role.Mid)
    self.indicator_text = pal.color(Role.HighlightedText)
    self.diameter = 28

QStep

QStep(title: str = '', caption: str = '', icon: Optional[IconLike] = None, *, prefix: str = '', name: Optional[str] = None, disabled: bool = False, header_nav: bool = True, validator: Optional[Validator] = None, done_icon: Optional[IconLike] = None, active_icon: Optional[IconLike] = None, error_icon: Optional[IconLike] = None, active_color: Optional[ColorLike] = None, done_color: Optional[ColorLike] = None, inactive_color: Optional[ColorLike] = None, error_color: Optional[ColorLike] = None, parent: Optional[QWidget] = None)

Bases: QWidget

One stepper step. A QWidget whose layout you fill with the content.

Main parameters

title, caption Header texts. icon QIcon or glyph for the indicator (active / inactive). prefix Short text inside the indicator instead of the number. name Optional identifier (for go_to by name). disabled Not navigable. header_nav Allow navigating to this step by clicking the header. validator Callable[[], bool]; returning False blocks advancing. done_icon/active_icon/error_icon Per-state icons (QIcon or glyph). They override the stepper's global ones. active_color/done_color/inactive_color/error_color Per-step colours that override the theme.

Methods:

Name Description
validate

True when the step is valid (or has no validator).

Source code in src/ms_components/ms_stepper.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def __init__(
    self,
    title: str = "",
    caption: str = "",
    icon: Optional[IconLike] = None,
    *,
    prefix: str = "",
    name: Optional[str] = None,
    disabled: bool = False,
    header_nav: bool = True,
    validator: Optional[Validator] = None,
    done_icon: Optional[IconLike] = None,
    active_icon: Optional[IconLike] = None,
    error_icon: Optional[IconLike] = None,
    active_color: Optional[ColorLike] = None,
    done_color: Optional[ColorLike] = None,
    inactive_color: Optional[ColorLike] = None,
    error_color: Optional[ColorLike] = None,
    parent: Optional[QWidget] = None,
) -> None:
    super().__init__(parent)
    self.title = title
    self.caption = caption
    self.icon = icon
    self.prefix = prefix
    self.name = name
    self.header_nav = header_nav
    self._disabled = disabled
    self._done = False
    self._error = False
    self._warning = False
    self._validator = validator

    self.done_icon = done_icon
    self.active_icon = active_icon
    self.error_icon = error_icon
    self.warning_icon = None
    self.active_color = _qc(active_color)
    self.done_color = _qc(done_color)
    self.inactive_color = _qc(inactive_color)
    self.error_color = _qc(error_color)
    self.warning_color = None

    self._body = QVBoxLayout(self)
    self._body.setContentsMargins(0, 8, 0, 0)
    self._body.setSpacing(8)

validate

validate() -> bool

True when the step is valid (or has no validator).

Source code in src/ms_components/ms_stepper.py
288
289
290
291
292
293
294
295
def validate(self) -> bool:
    """True when the step is valid (or has no validator)."""
    if self._validator is None:
        return True
    try:
        return bool(self._validator())
    except Exception:
        return False

QStepper

QStepper(orientation: Orientation = HORIZONTAL, theme: Optional[StepperTheme] = None, *, linear: bool = True, alternative_labels: bool = False, inline_label: bool = False, show_navigation: bool = True, auto_error_on_invalid: bool = True, parent: Optional[QWidget] = None)

Bases: QWidget

Signals

current_changed(int) index of the current step step_changed(QStep) object of the current step finished() "Finish" was pressed (last step valid) validation_failed(QStep) a step blocked advancing during validation

Methods:

Name Description
set_qss

Append user QSS on top of the stepper's default chrome.

set_current_index

Navigate without validating (programmatic use).

next

Validate the current step and, if it passes, mark it done and advance.

Source code in src/ms_components/ms_stepper.py
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
def __init__(
    self,
    orientation: Orientation = Orientation.HORIZONTAL,
    theme: Optional[StepperTheme] = None,
    *,
    linear: bool = True,
    alternative_labels: bool = False,
    inline_label: bool = False,
    show_navigation: bool = True,
    auto_error_on_invalid: bool = True,
    parent: Optional[QWidget] = None,
) -> None:
    super().__init__(parent)
    self.setObjectName("qStepper")
    self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)

    self.theme = theme or StepperTheme()
    self.orientation = orientation
    self._linear = linear
    self.alternative_labels = alternative_labels
    self.inline_label = inline_label
    self._show_nav = show_navigation
    self._auto_error_on_invalid = auto_error_on_invalid

    # Global per-state icons (None => drawn fallback: check / "!").
    self.done_icon: Optional[IconLike] = None
    self.active_icon: Optional[IconLike] = None
    self.error_icon: Optional[IconLike] = None

    # TODO(responsive): implement Quasar's `contracted` mode. In horizontal
    # orientation, below a width threshold, hide titles/captions (or show
    # only the active step's label) and collapse the header to indicators
    # only. This would hook into resizeEvent to toggle a compact header
    # state. Not implemented yet.
    self.contracted = False  # placeholder, not yet implemented

    self._steps: List[QStep] = []
    self._current = -1
    self._max_reached = 0
    self._user_qss = ""

    self.back_text = "Back"
    self.next_text = "Continue"
    self.finish_text = "Finish"

    self._root = QVBoxLayout(self)
    self._root.setContentsMargins(0, 5, 0, 0)
    self._root.setSpacing(0)

    self._build_navigation()
    self._apply_chrome()
    self._rebuild()
    # Set last: before this point changeEvent must not touch anything.
    self._auto_theme = theme is None

set_qss

set_qss(qss: str) -> None

Append user QSS on top of the stepper's default chrome.

Source code in src/ms_components/ms_stepper.py
573
574
575
576
def set_qss(self, qss: str) -> None:
    """Append user QSS on top of the stepper's default chrome."""
    self._user_qss = qss or ""
    self._apply_chrome()

set_current_index

set_current_index(index: int) -> None

Navigate without validating (programmatic use).

Source code in src/ms_components/ms_stepper.py
656
657
658
659
660
661
662
663
664
665
666
667
668
def set_current_index(self, index: int) -> None:
    """Navigate without validating (programmatic use)."""
    if not (0 <= index < len(self._steps)):
        return
    if self._steps[index].is_disabled():
        return
    self._current = index
    self._max_reached = max(self._max_reached, index)
    self._sync_content()
    self._refresh_headers()
    self._refresh_nav()
    self.current_changed.emit(index)
    self.step_changed.emit(self._steps[index])

next

next() -> None

Validate the current step and, if it passes, mark it done and advance.

Source code in src/ms_components/ms_stepper.py
681
682
683
684
685
686
687
688
689
690
691
692
def next(self) -> None:
    """Validate the current step and, if it passes, mark it done and advance."""
    if not self._leave_current_ok():
        return
    cur = self.current_step()
    if cur and not cur.has_error():
        cur.set_done(True)
    j = self._current + 1
    while j < len(self._steps) and self._steps[j].is_disabled():
        j += 1
    if j < len(self._steps):
        self.set_current_index(j)

Guided tutorials

ms_tutorial

Informative and interactive tours for any Qt Widgets application.

Modules:

Name Description
bindings

The small Python boundary between tutorial content and a live Qt application.

controller

Attach a tour to existing widgets/signals without changing their slots.

models

Application-independent tutorial content, authored in Python or TOML.

overlay

A window-local spotlight: informative steps block input, interactive steps open a hole.

Classes:

Name Description
TutorialController

One active tour, with Python bindings and plain-data content.

Functions:

Name Description
load_tutorial

Read data only: target/event names are resolved by the application, never evaluated.

TutorialController

TutorialController(*, targets: Mapping[str, TutorialTarget], events: Mapping[str, TutorialEvent] | None = None, parent: QObject | None = None)

Bases: QObject

One active tour, with Python bindings and plain-data content.

Targets may belong to different windows or dialogs. prepare only reveals the relevant UI; the component never submits jobs or invokes business actions.

Methods:

Name Description
start

Validate bindings before replacing the active tour. Must run in the UI thread.

stop

Cancel and disconnect only the tutorial's own signal subscription.

Attributes:

Name Type Description
overlay TutorialOverlay | None

The current window's overlay; replaced when the tour moves between windows.

Source code in src/ms_components/ms_tutorial/controller.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def __init__(self, *, targets: Mapping[str, TutorialTarget],
             events: Mapping[str, TutorialEvent] | None = None,
             parent: QObject | None = None):
    super().__init__(parent)
    self.targets = dict(targets)
    self.events = dict(events or {})
    self._tutorial: Tutorial | None = None
    self._index = -1
    self._completed: set[str] = set()
    self._overlay: TutorialOverlay | None = None
    self._target: QWidget | None = None
    self._relay: _EventRelay | None = None
    self._previous_focus = None
    self._tracker = QTimer(self)
    self._tracker.setInterval(80)
    self._tracker.timeout.connect(self._refresh)

overlay property

overlay: TutorialOverlay | None

The current window's overlay; replaced when the tour moves between windows.

start

start(tutorial: Tutorial)

Validate bindings before replacing the active tour. Must run in the UI thread.

Source code in src/ms_components/ms_tutorial/controller.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def start(self, tutorial: Tutorial):
    """Validate bindings before replacing the active tour. Must run in the UI thread."""
    for step in tutorial.steps:
        if not isinstance(self.targets.get(step.target), TutorialTarget):
            raise ValueError(f"Unregistered tutorial target: {step.target}")
        if step.advance_on is not None and not isinstance(self.events.get(step.advance_on), TutorialEvent):
            raise ValueError(f"Unregistered tutorial event: {step.advance_on}")
    app = QApplication.instance()
    if app is None:
        raise RuntimeError("TutorialController requires a QApplication")
    self.stop()
    focus = app.focusWidget()
    self._previous_focus = weakref.ref(focus) if focus is not None else None
    self._tutorial = tutorial
    self._completed.clear()
    app.installEventFilter(self)
    self._tracker.start()
    self.started.emit(tutorial.id)
    if self._tutorial is tutorial:
        self._show_step(0)

stop

stop()

Cancel and disconnect only the tutorial's own signal subscription.

Source code in src/ms_components/ms_tutorial/controller.py
110
111
112
113
114
115
@Slot()
def stop(self):
    """Cancel and disconnect only the tutorial's own signal subscription."""
    if self.is_active:
        tutorial_id = self._cleanup()
        self.cancelled.emit(tutorial_id)

load_tutorial

load_tutorial(path: str | Path) -> Tutorial

Read data only: target/event names are resolved by the application, never evaluated.

Source code in src/ms_components/ms_tutorial/models.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def load_tutorial(path: str | Path) -> Tutorial:
    """Read data only: target/event names are resolved by the application, never evaluated."""
    with Path(path).open("rb") as source:
        data = tomllib.load(source)
    allowed = {"id", "title", "steps"}
    if set(data) - allowed:
        raise ValueError(f"Unknown tutorial keys: {', '.join(sorted(set(data) - allowed))}")
    if not isinstance(data.get("steps"), list):
        raise ValueError("Tutorial must contain [[steps]] tables")
    try:
        return Tutorial(id=data["id"], title=data["title"],
                        steps=tuple(TutorialStep(**step) for step in data["steps"]))
    except (KeyError, TypeError) as exc:
        raise ValueError(f"Invalid tutorial definition: {exc}") from exc

Tool panel

tool_panel

Tool panel: the frame every configure-then-run tool shares.

┌ top bar ──── scope and context; created on first use ─────┐
│ body ─────── sections, free widgets, Advanced (scrolls)   │
└ action bar ─ summary or progress · actions; first use ────┘

UI only: it knows nothing about jobs or runtimes. The host maps its job events onto ActionBar.start / set_progress / finish. The status bar and the job monitor keep the full detail, so the progress here is deliberately a quiet 3 px line.

Inside a QStepper, put one panel per step (step.add_widget(panel) with the step's body_layout margins at 0): each step keeps its own bar state while hidden, so there is nothing to swap when the current step changes.

Dividers and rules are painted with the palette, not styled: the panel follows the theme without any rule in base.qss.

Classes:

Name Description
ContentBar

Full-width strip with a divider on one edge. The padding lives inside, so the

ToolSection

One flat section: a header (title + rule) over a form body.

ActionBar

Bottom strip: a summary while idle, a quiet progress line while running, and the

ToolPanel

Frame for a configure-then-run tool; see the module docstring.

Functions:

Name Description
format_count

12480 -> '12 480' with a narrow no-break space, so a count never wraps mid-number.

hint_label

Secondary text (method notes, section descriptions): caption size, placeholder colour.

ContentBar

ContentBar(divider: Edge, parent: QWidget | None = None)

Bases: QFrame

Full-width strip with a divider on one edge. The padding lives inside, so the background and the divider reach the panel edges.

Source code in src/ms_components/tool_panel.py
 96
 97
 98
 99
100
101
def __init__(self, divider: Qt.Edge, parent: QWidget | None = None) -> None:
    super().__init__(parent)
    self._divider = divider
    self.row = QHBoxLayout(self)
    self.row.setContentsMargins(12, 8, 12, 8)
    self.row.setSpacing(6)

ToolSection

ToolSection(title: str, *, description: str = '', checkable: bool = False, checked: bool = True, collapse_when_unchecked: bool = False, collapsible: bool = False, expanded: bool = False, parent: QWidget | None = None)

Bases: QWidget

One flat section: a header (title + rule) over a form body.

checkable The title is a checkbox. Unchecked disables the body (checkable QGroupBox semantics), or hides it with collapse_when_unchecked for large optional bodies (flexible residues). collapsible The title is a disclosure arrow, for options few people need (Advanced).

isChecked / setChecked / toggled mirror QGroupBox, so code written against a checkable group box keeps working.

Methods:

Name Description
add_row

QFormLayout.addRow overloads: (label, field), (widget) spanning, (layout).

Source code in src/ms_components/tool_panel.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def __init__(
    self,
    title: str,
    *,
    description: str = "",
    checkable: bool = False,
    checked: bool = True,
    collapse_when_unchecked: bool = False,
    collapsible: bool = False,
    expanded: bool = False,
    parent: QWidget | None = None,
) -> None:
    if checkable and collapsible:
        raise ValueError("a section is either checkable or collapsible, not both")
    super().__init__(parent)
    self._title = title
    self._collapsible = collapsible
    self._collapse_when_unchecked = collapse_when_unchecked
    self._expanded = expanded or not collapsible
    self._checkbox: QCheckBox | None = None
    self._arrow: QToolButton | None = None

    outer = QVBoxLayout(self)
    outer.setContentsMargins(0, 0, 0, 0)
    outer.setSpacing(6)
    header = QHBoxLayout()
    header.setSpacing(8)
    if checkable:
        self._checkbox = QCheckBox(title, self)
        self._checkbox.setChecked(checked)
        self._checkbox.toggled.connect(self._on_toggled)
        title_widget: QWidget = self._checkbox
    elif collapsible:
        self._arrow = QToolButton(self)
        self._arrow.setText(title)
        self._arrow.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
        # base.qss drops the tool-button frame for this name: a heading, not a button.
        self._arrow.setObjectName(SECTION_TOGGLE_NAME)
        self._arrow.clicked.connect(lambda: self.setExpanded(not self._expanded))
        title_widget = self._arrow
    else:
        title_widget = QLabel(title, self)
    self._title_widget = title_widget
    title_widget.setFont(theme.font("strong"))
    header.addWidget(title_widget)
    header.addWidget(_Rule(parent=self), 1)
    outer.addLayout(header)

    self.description = hint_label(description, self)
    self.description.setContentsMargins(_INDENT, 0, 0, 0)
    outer.addWidget(self.description)

    self.body = QWidget(self)
    self.form = QFormLayout(self.body)
    self.form.setContentsMargins(_INDENT, 0, 0, 0)
    # Fields keep their natural width: a pH box stretched to 900 px stops reading as a form.
    self.form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
    # Left-aligned: labels share the left edge of the description and the spanning rows.
    self.form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
    outer.addWidget(self.body)
    self._sync()

add_row

add_row(*args) -> None

QFormLayout.addRow overloads: (label, field), (widget) spanning, (layout).

Source code in src/ms_components/tool_panel.py
202
203
204
def add_row(self, *args) -> None:
    """``QFormLayout.addRow`` overloads: (label, field), (widget) spanning, (layout)."""
    self.form.addRow(*args)

ActionBar

ActionBar(parent: QWidget | None = None)

Bases: ContentBar

Bottom strip: a summary while idle, a quiet progress line while running, and the tool's actions on the right.

Run-type actions give way to Cancel while running. The actions area keeps its idle width meanwhile, so the text and the progress line on the left never jump.

Methods:

Name Description
add_action

Any button (plain, split, menu). hide_while_running=False for actions that

set_summary

Idle text: what a run would cover (scope counts, estimate).

start

Running with unknown progress: submitted, waiting for prerequisites.

set_progress

eta_seconds=None estimates it from the rate observed across calls.

finish

Back to idle showing the outcome (done, failed, cancelled) until the next summary.

Source code in src/ms_components/tool_panel.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
def __init__(self, parent: QWidget | None = None) -> None:
    super().__init__(Qt.Edge.TopEdge, parent)
    info = QVBoxLayout()
    info.setContentsMargins(0, 0, 0, 0)
    info.setSpacing(4)
    self.headline = QLabel(self)
    self.headline.setTextFormat(Qt.TextFormat.PlainText)
    self.progress = QProgressBar(self)
    self.progress.setTextVisible(False)
    self.progress.setFixedHeight(3)
    self.progress.setObjectName(PROGRESS_NAME)
    self.progress.hide()
    self._rule = _Rule(3, self)  # holds the progress line's slot while idle
    self.detail = hint_label("", self)
    self.detail.setWordWrap(False)
    self.detail.setTextFormat(Qt.TextFormat.RichText)
    self.detail.setTextInteractionFlags(
        Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.LinksAccessibleByKeyboard
    )
    self.detail.linkActivated.connect(lambda _link: self.details_requested.emit())
    for widget in (self.headline, self.progress, self._rule, self.detail):
        info.addWidget(widget)
    self.row.addLayout(info, 2)
    self.row.addStretch(1)

    self._actions_box = QWidget(self)
    self._actions = QHBoxLayout(self._actions_box)
    self._actions.setContentsMargins(0, 0, 0, 0)
    self._actions.setSpacing(6)
    self._actions.addStretch(1)
    self.cancel_button = QPushButton("Cancel", self._actions_box)
    self.cancel_button.hide()
    self.cancel_button.clicked.connect(self._on_cancel)
    self._actions.addWidget(self.cancel_button)
    self.row.addWidget(self._actions_box)

    self._run_actions: list[QWidget] = []
    self._restore: list[QWidget] = []
    self._running = False
    self._rate_origin: tuple[float, int] | None = None  # (time, done) the ETA rate counts from

add_action

add_action(widget: QWidget, *, hide_while_running: bool = True) -> QWidget

Any button (plain, split, menu). hide_while_running=False for actions that stay useful during a run, such as Open Results.

Source code in src/ms_components/tool_panel.py
302
303
304
305
306
307
308
def add_action(self, widget: QWidget, *, hide_while_running: bool = True) -> QWidget:
    """Any button (plain, split, menu). ``hide_while_running=False`` for actions that
    stay useful during a run, such as Open Results."""
    self._actions.insertWidget(self._actions.indexOf(self.cancel_button), widget)
    if hide_while_running:
        self._run_actions.append(widget)
    return widget

set_summary

set_summary(headline: str, detail: str = '') -> None

Idle text: what a run would cover (scope counts, estimate).

Source code in src/ms_components/tool_panel.py
310
311
312
313
def set_summary(self, headline: str, detail: str = "") -> None:
    """Idle text: what a run would cover (scope counts, estimate)."""
    self._set_running(False)
    self._show(headline, detail)

start

start(headline: str = 'Running…', detail: str = '') -> None

Running with unknown progress: submitted, waiting for prerequisites.

Source code in src/ms_components/tool_panel.py
315
316
317
318
319
320
def start(self, headline: str = "Running…", detail: str = "") -> None:
    """Running with unknown progress: submitted, waiting for prerequisites."""
    self._set_running(True)
    self._rate_origin = None  # time spent waiting says nothing about the processing rate
    self.progress.setRange(0, 0)
    self._show(headline, detail)

set_progress

set_progress(done: int, total: int | None = None, *, stage: str = 'Running', unit: str = 'items', eta_seconds: float | None = None, failures: int = 0, detail: str = '') -> None

eta_seconds=None estimates it from the rate observed across calls. total=None keeps the line indeterminate and drops the ETA: a streamed total is only a floor, so a percentage would be invented. detail follows the ETA.

Source code in src/ms_components/tool_panel.py
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def set_progress(
    self,
    done: int,
    total: int | None = None,
    *,
    stage: str = "Running",
    unit: str = "items",
    eta_seconds: float | None = None,
    failures: int = 0,
    detail: str = "",
) -> None:
    """``eta_seconds=None`` estimates it from the rate observed across calls.
    ``total=None`` keeps the line indeterminate and drops the ETA: a streamed total is
    only a floor, so a percentage would be invented. ``detail`` follows the ETA."""
    self._set_running(True)
    parts = []
    if total:
        self.progress.setRange(0, int(total))
        self.progress.setValue(min(done, int(total)))
        headline = f"{stage} — {format_count(done)} / {format_count(total)} {unit}"
        if eta_seconds is None:
            eta_seconds = self._estimate_eta(done, int(total))
        parts.append("Estimating time…" if eta_seconds is None else format_eta(eta_seconds))
    else:
        self.progress.setRange(0, 0)
        headline = f"{stage} — {format_count(done)} {unit}"
    if detail:
        parts.append(detail)
    self._show(headline, " · ".join(parts), failures)

finish

finish(headline: str, detail: str = '', *, failures: int = 0) -> None

Back to idle showing the outcome (done, failed, cancelled) until the next summary.

Source code in src/ms_components/tool_panel.py
364
365
366
367
def finish(self, headline: str, detail: str = "", *, failures: int = 0) -> None:
    """Back to idle showing the outcome (done, failed, cancelled) until the next summary."""
    self._set_running(False)
    self._show(headline, detail, failures)

ToolPanel

ToolPanel(parent: QWidget | None = None, *, scrollable: bool = True)

Bases: QWidget

Frame for a configure-then-run tool; see the module docstring.

scrollable=False for tools whose body is mostly a table that scrolls itself.

Methods:

Name Description
add_section

Options as in ToolSection; inserted after the previous ones, before Advanced.

add_widget

A free widget in the body; stretch > 0 lets it take the spare height (tables).

Attributes:

Name Type Description
advanced ToolSection

Collapsed section kept last: seed, threads, batch size, executor.

Source code in src/ms_components/tool_panel.py
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
def __init__(self, parent: QWidget | None = None, *, scrollable: bool = True) -> None:
    super().__init__(parent)
    self._outer = QVBoxLayout(self)
    self._outer.setContentsMargins(0, 0, 0, 0)
    self._outer.setSpacing(0)
    self._top_bar: ContentBar | None = None
    self._action_bar: ActionBar | None = None
    self._advanced: ToolSection | None = None

    self.content = QWidget(self)
    self.body_layout = QVBoxLayout(self.content)
    self.body_layout.setContentsMargins(12, 12, 12, 12)
    self.body_layout.setSpacing(14)
    self.body_layout.addStretch(1)  # sections keep their height instead of spreading out
    self._has_tail = True
    if scrollable:
        scroll = QScrollArea(self)
        scroll.setFrameShape(QFrame.Shape.NoFrame)
        scroll.setWidgetResizable(True)
        scroll.setWidget(self.content)
        self._outer.addWidget(scroll, 1)
    else:
        self._outer.addWidget(self.content, 1)

advanced property

advanced: ToolSection

Collapsed section kept last: seed, threads, batch size, executor.

add_section

add_section(title: str, **options) -> ToolSection

Options as in ToolSection; inserted after the previous ones, before Advanced.

Source code in src/ms_components/tool_panel.py
456
457
458
459
460
def add_section(self, title: str, **options) -> ToolSection:
    """Options as in ``ToolSection``; inserted after the previous ones, before Advanced."""
    section = ToolSection(title, parent=self.content, **options)
    self._insert(section, 0)
    return section

add_widget

add_widget(widget: QWidget, stretch: int = 0) -> QWidget

A free widget in the body; stretch > 0 lets it take the spare height (tables).

Source code in src/ms_components/tool_panel.py
462
463
464
465
466
467
468
def add_widget(self, widget: QWidget, stretch: int = 0) -> QWidget:
    """A free widget in the body; ``stretch > 0`` lets it take the spare height (tables)."""
    if stretch and self._has_tail:
        self.body_layout.takeAt(self.body_layout.count() - 1)
        self._has_tail = False
    self._insert(widget, stretch)
    return widget

format_count

format_count(value: int) -> str

12480 -> '12 480' with a narrow no-break space, so a count never wraps mid-number.

Source code in src/ms_components/tool_panel.py
51
52
53
def format_count(value: int) -> str:
    """12480 -> '12 480' with a narrow no-break space, so a count never wraps mid-number."""
    return f"{int(value):,}".replace(",", _NARROW_NBSP)

hint_label

hint_label(text: str, parent: QWidget | None = None) -> QLabel

Secondary text (method notes, section descriptions): caption size, placeholder colour.

Source code in src/ms_components/tool_panel.py
68
69
70
71
72
73
74
def hint_label(text: str, parent: QWidget | None = None) -> QLabel:
    """Secondary text (method notes, section descriptions): caption size, placeholder colour."""
    label = QLabel(text, parent)
    label.setWordWrap(True)
    label.setFont(theme.font("caption"))
    label.setForegroundRole(QPalette.ColorRole.PlaceholderText)
    return label

Step dialog

step_dialog

Runs a list of ms_env Steps off the GUI thread and shows how far they got.

What is on screen is the list of steps, each marked as it runs, ends or fails. The commands and their output are one button away, and open by themselves on a failure.

Used for anything that touches a remote host or boots a cluster: testing an executor, preparing its environment, ray up. Modality is the caller's call — blocking=True for the compute backend the app is about to depend on, non-modal for everything else so HPC/cluster setup can run in the background.

Functions:

Name Description
run_steps_dialog

Show the dialog and start the run. Blocking calls exec(), the rest show().

run_steps_dialog

run_steps_dialog(parent: QWidget | None, title: str, steps: Iterable[Step], *, blocking: bool = False, on_success: Callable[[], None] | None = None) -> StepRunnerDialog

Show the dialog and start the run. Blocking calls exec(), the rest show().

Source code in src/ms_components/step_dialog.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def run_steps_dialog(
    parent: QWidget | None,
    title: str,
    steps: Iterable[Step],
    *,
    blocking: bool = False,
    on_success: Callable[[], None] | None = None,
) -> StepRunnerDialog:
    """Show the dialog and start the run. Blocking calls exec(), the rest show()."""
    dialog = StepRunnerDialog(title, steps, blocking=blocking, parent=parent, on_success=on_success)
    if blocking:
        dialog.show()
        dialog.start()
        dialog.exec()
    else:
        dialog.show()
        dialog.start()
    return dialog

Theme

theme

Color themes for every MolSuite Qt app (QPalette-based).

Palette data and the QPalette role mapping are derived from qt-themes by Beat Reichenbach, MIT-licensed:

https://github.com/beatreichenbach/qt-themes
Copyright (c) 2024 Beat Reichenbach

The color values are vendored here (they are data, not code) so no extra runtime dependency is needed. Full MIT text: licenses/qt-themes-LICENSE.txt; keep this attribution if you redistribute.

Widgets should not hardcode colors. Two mechanisms cover everything:

  • palette(role) in QSS / QApplication.palette() in paint code — resolved live, so a theme switch repaints without touching a single widget.
  • color("red"/"green"/...) for semantic accents (status dots, warnings) that have no QPalette role.

Same idea for type: font(role) derives from the app font instead of hardcoding sizes, so the OS/user font and DPI carry through. Roles are data (FONT_ROLES), not classes -- a title is a scale factor and a weight, nothing worth an object.

Functions:

Name Description
is_dark

A theme is dark when its text is brighter than its base background.

current_theme

The theme dict currently applied (defaults before any apply_theme).

color

A semantic accent from the live theme (red, green, orange, ...).

contrast_ratio

WCAG contrast ratio used to keep generated palette states readable.

font

A QFont for a named role, derived from the application font.

set_base_font

Set the application base font: the root everything else inherits from.

build_palette

Build a QPalette from a theme dict.

resolve

Resolve auto, retired and invalid names to an available concrete theme.

tone_qss

QProgressBar[tone="green"] and siblings, from the live theme.

base_qss

The shared geometry stylesheet (colors resolved from the live palette).

apply_theme

Apply a theme (or "auto") to the QApplication.

is_dark

is_dark(theme: dict[str, str] | None = None) -> bool

A theme is dark when its text is brighter than its base background.

Source code in src/ms_components/theme.py
41
42
43
44
def is_dark(theme: dict[str, str] | None = None) -> bool:
    """A theme is dark when its text is brighter than its base background."""
    theme = theme or _current
    return QColor(theme["text"]).value() > QColor(theme["base"]).value()

current_theme

current_theme() -> dict[str, str]

The theme dict currently applied (defaults before any apply_theme).

Source code in src/ms_components/theme.py
47
48
49
def current_theme() -> dict[str, str]:
    """The theme dict currently applied (defaults before any `apply_theme`)."""
    return _current

color

color(role: str) -> QColor

A semantic accent from the live theme (red, green, orange, ...).

For anything with a QPalette role (background, text, borders, highlight) use the palette instead — this is only for accents Qt has no role for.

Source code in src/ms_components/theme.py
52
53
54
55
56
57
58
def color(role: str) -> QColor:
    """A semantic accent from the live theme (`red`, `green`, `orange`, ...).

    For anything with a QPalette role (background, text, borders, highlight) use
    the palette instead — this is only for accents Qt has no role for.
    """
    return QColor(_current[role])

contrast_ratio

contrast_ratio(left: QColor, right: QColor) -> float

WCAG contrast ratio used to keep generated palette states readable.

Source code in src/ms_components/theme.py
71
72
73
74
def contrast_ratio(left: QColor, right: QColor) -> float:
    """WCAG contrast ratio used to keep generated palette states readable."""
    lighter, darker = sorted((_luminance(left), _luminance(right)), reverse=True)
    return (lighter + 0.05) / (darker + 0.05)

font

font(role: str = 'body', *, mono: bool = False) -> QFont

A QFont for a named role, derived from the application font.

mono=True swaps in the platform's fixed-width family (scores, ids, paths) and keeps the role's size/weight.

Source code in src/ms_components/theme.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def font(role: str = "body", *, mono: bool = False) -> QFont:
    """A QFont for a named role, derived from the application font.

    `mono=True` swaps in the platform's fixed-width family (scores, ids, paths) and
    keeps the role's size/weight.
    """
    app = QApplication.instance()
    base = app.font() if app is not None else QFont()
    scale, bold = FONT_ROLES.get(role, FONT_ROLES["body"])
    result = QFontDatabase.systemFont(QFontDatabase.FixedFont) if mono else QFont(base)
    if base.pointSizeF() > 0:  # a font set in pixels reports -1 here
        result.setPointSizeF(base.pointSizeF() * scale)
    else:
        result.setPixelSize(max(1, round(base.pixelSize() * scale)))
    result.setBold(bold)
    return result

set_base_font

set_base_font(points: float = DEFAULT_FONT_PT, *, family: str | None = None, app: QApplication | None = None) -> QFont

Set the application base font: the root everything else inherits from.

One place instead of sizes scattered through the QSS: font(role) scales off this one, widgets that do not override it inherit it, and the QSS can go without a single font-size rule (a stylesheet rule would override this).

Call it before creating windows: widgets already built with their own font keep it.

Source code in src/ms_components/theme.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def set_base_font(points: float = DEFAULT_FONT_PT, *, family: str | None = None,
                  app: QApplication | None = None) -> QFont:
    """Set the application base font: the root everything else inherits from.

    One place instead of sizes scattered through the QSS: `font(role)` scales off this
    one, widgets that do not override it inherit it, and the QSS can go without a single
    `font-size` rule (a stylesheet rule would override this).

    Call it before creating windows: widgets already built with their own font keep it.
    """
    app = app or QApplication.instance()
    if app is None:
        raise RuntimeError("set_base_font requires a running QApplication")
    result = QFont(app.font())
    if family:  # by default the system family is kept and only the size is set
        result.setFamily(family)
    result.setPointSizeF(float(points))
    app.setFont(result)
    return result

build_palette

build_palette(theme: dict[str, str]) -> QPalette

Build a QPalette from a theme dict.

Port of qt-themes' update_palette (MIT, Beat Reichenbach), adapted to read from a flat dict instead of a dataclass.

Source code in src/ms_components/theme.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
def build_palette(theme: dict[str, str]) -> QPalette:
    """Build a QPalette from a theme dict.

    Port of qt-themes' `update_palette` (MIT, Beat Reichenbach), adapted to read
    from a flat dict instead of a dataclass.
    """
    c = lambda role: QColor(theme[role])  # noqa: E731
    Role = QPalette.ColorRole
    Group = QPalette.ColorGroup
    pal = QPalette()

    dark_theme = is_dark(theme)
    table_base = c("mantle") if dark_theme else c("crust")
    table_alternate = c("base") if dark_theme else c("mantle")
    highlight = _surface_with_contrast(
        c("primary"),
        foreground=_contrasting_text(c("primary"), theme),
        adjacent=(table_base, table_alternate),
        text_minimum=4.5,
        separation_minimum=3.0,
    )
    highlight_text = _contrasting_text(highlight, theme)
    header = _surface_with_contrast(
        c("surface1"),
        foreground=c("text"),
        adjacent=(table_base, table_alternate),
        text_minimum=4.5,
        separation_minimum=1.45,
    )

    h, s, v, a = c("text").getHsvF()
    bright_text = QColor.fromHsvF(h, s, 1 - v, a)

    # Active/Normal group
    if is_dark(theme):
        pal.setColor(Role.Base, c("mantle"))
        pal.setColor(Role.AlternateBase, c("base"))
    else:
        pal.setColor(Role.Base, c("crust"))
        pal.setColor(Role.AlternateBase, c("mantle"))
    pal.setColor(Role.Window, c("base"))
    pal.setColor(Role.WindowText, c("text"))
    pal.setColor(Role.PlaceholderText, c("overlay1"))
    pal.setColor(Role.Text, c("text"))
    pal.setColor(Role.Button, c("base"))
    pal.setColor(Role.ButtonText, c("text"))
    pal.setColor(Role.BrightText, bright_text)
    pal.setColor(Role.ToolTipBase, c("mantle"))
    pal.setColor(Role.ToolTipText, c("overlay2"))

    pal.setColor(Role.Highlight, highlight)
    pal.setColor(Role.HighlightedText, highlight_text)
    pal.setColor(Role.Link, c("secondary"))
    pal.setColor(Role.LinkVisited, c("secondary"))

    pal.setColor(Role.Light, c("crust"))
    pal.setColor(Role.Midlight, c("mantle"))
    # Mid is *the* border tone: `palette(mid)` is what every stylesheet uses for
    # hairlines, so it must be the conventional Catppuccin-style separator.
    pal.setColor(Role.Mid, c("surface2"))
    pal.setColor(Role.Dark, header)
    pal.setColor(Role.Shadow, c("overlay0"))

    # Inactive group
    pal.setColor(Group.Inactive, Role.Highlight, highlight)
    pal.setColor(Group.Inactive, Role.HighlightedText, highlight_text)
    pal.setColor(Group.Inactive, Role.Link, c("surface1"))
    pal.setColor(Group.Inactive, Role.LinkVisited, c("surface1"))

    # Disabled group
    pal.setColor(Group.Disabled, Role.WindowText, c("overlay1"))
    pal.setColor(Group.Disabled, Role.Base, c("base"))
    pal.setColor(Group.Disabled, Role.AlternateBase, c("base"))
    pal.setColor(Group.Disabled, Role.Text, c("overlay1"))
    pal.setColor(Group.Disabled, Role.PlaceholderText, c("overlay1"))
    pal.setColor(Group.Disabled, Role.Button, c("base"))
    pal.setColor(Group.Disabled, Role.ButtonText, c("overlay1"))
    pal.setColor(Group.Disabled, Role.BrightText, c("mantle"))
    pal.setColor(Group.Disabled, Role.Highlight, c("surface2"))
    pal.setColor(Group.Disabled, Role.HighlightedText, _contrasting_text(c("surface2"), theme))
    pal.setColor(Group.Disabled, Role.Link, c("surface0"))
    pal.setColor(Group.Disabled, Role.LinkVisited, c("surface0"))

    pal.setColor(Role.Accent, c("secondary"))
    pal.setColor(Group.Inactive, Role.Accent, c("surface1"))
    pal.setColor(Group.Disabled, Role.Accent, c("surface2"))

    return pal

resolve

resolve(name: str) -> str

Resolve auto, retired and invalid names to an available concrete theme.

Source code in src/ms_components/theme.py
267
268
269
270
271
272
273
274
275
276
def resolve(name: str) -> str:
    """Resolve auto, retired and invalid names to an available concrete theme."""
    if name != "auto":
        candidate = _REMOVED_THEME_FALLBACKS.get(name, name)
        return candidate if candidate in THEMES else DEFAULT_DARK
    from PySide6.QtCore import Qt

    hints = QApplication.styleHints()
    scheme = hints.colorScheme() if hints else Qt.ColorScheme.Unknown
    return DEFAULT_DARK if scheme == Qt.ColorScheme.Dark else DEFAULT_LIGHT

tone_qss

tone_qss(theme: dict[str, str] | None = None) -> str

QProgressBar[tone="green"] and siblings, from the live theme.

QSS has no palette role for the accents, so these rules are written per theme. Translucent, so the bar's text stays readable on dark and light themes alike.

Source code in src/ms_components/theme.py
283
284
285
286
287
288
289
290
291
292
293
294
295
def tone_qss(theme: dict[str, str] | None = None) -> str:
    """`QProgressBar[tone="green"]` and siblings, from the live theme.

    QSS has no palette role for the accents, so these rules are written per theme.
    Translucent, so the bar's text stays readable on dark and light themes alike.
    """
    theme = theme or _current
    rules = []
    for tone in TONES:
        accent = QColor(theme[tone])
        rgba = f"rgba({accent.red()}, {accent.green()}, {accent.blue()}, 150)"
        rules.append(f'QProgressBar[tone="{tone}"]::chunk {{ background: {rgba}; }}')
    return "\n".join(rules) + "\n"

base_qss

base_qss() -> str

The shared geometry stylesheet (colors resolved from the live palette).

Source code in src/ms_components/theme.py
298
299
300
def base_qss() -> str:
    """The shared geometry stylesheet (colors resolved from the live palette)."""
    return files(__package__).joinpath("base.qss").read_text(encoding="utf-8")

apply_theme

apply_theme(name: str, app: QApplication | None = None, *, base_font_pt: float | None = None) -> None

Apply a theme (or "auto") to the QApplication.

Re-applies live to every existing widget. Non-Qt "islands" (PyMOL, pyqtgraph) do not observe this and must be pushed separately.

Source code in src/ms_components/theme.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def apply_theme(name: str, app: QApplication | None = None, *,
                base_font_pt: float | None = None) -> None:
    """Apply a theme (or "auto") to the QApplication.

    Re-applies live to every existing widget. Non-Qt "islands" (PyMOL, pyqtgraph)
    do not observe this and must be pushed separately.
    """
    global _current
    app = app or QApplication.instance()
    if app is None:
        raise RuntimeError("apply_theme requires a running QApplication")
    _current = THEMES[resolve(name)]
    if base_font_pt is not None:  # optional: omit it and the system font stays
        set_base_font(base_font_pt, app=app)
    app.setStyle("Fusion")  # Fusion honors QPalette ColorRoles most faithfully
    app.setPalette(build_palette(_current))
    app.setStyleSheet(base_qss() + tone_qss())