Skip to content

visualdynamics.gui.tables

tables

Tables that behave like a spreadsheet.

The tables are model/view, not a widget per cell, so a table costs nothing until it is scrolled, an edit is validated and written straight back to the visualdynamics object under it, and switching display units restates the values without rebuilding anything.

CopyPasteTableView uses Excel's clipboard format (tab-separated cells, one row per line), so a selection copies straight into a spreadsheet and a block of spreadsheet cells pastes back in. It also has Batch Edit, Clear, a fill handle, a copy button for the whole table, and Delete for whole rows where its owner allows it.

Classes:

Name Description
ItemCheckStyle

The platform's style, with the check box in a table or list cell

Column

One column of a TableModel: how to read it, how to show it, and

TableModel

A table over a visualdynamics object, described by a list of

TransposedTableModel

A TableModel the other way up: each property a row and each

ChoiceDialog

Pick one of a column's choices, shown the way its cells show them.

ChoiceDelegate

Edit a column that declares choices with a drop-down, and a date

CopyPasteTableView

A table view that behaves like a spreadsheet: copy and paste as

Functions:

Name Description
wear_item_checks

Put ItemCheckStyle on the application when it runs the macOS

trace_units

Write one line to the units-editor log for an event in a choice

Classes

ItemCheckStyle

ItemCheckStyle(key: str)

Bases: QProxyStyle

The platform's style, with the check box in a table or list cell drawn by Fusion.

Qt 6.12's macOS style draws a checked cell's check box as nothing and an unchecked one as a faint rounded square, so a flag column such as the channel table's Control reads as empty. Everything else is drawn by the platform's style, the check box widget included.

Source code in src/visualdynamics/gui/tables.py
def __init__(self, key: str) -> None:
    super().__init__(key)
    self._fusion = QStyleFactory.create('Fusion')
    self._fusion.setParent(self)

Column dataclass

Column(title: str, get: Callable, set: Callable | None = None, format: Callable | None = None, decoration: Callable | None = None, background: Callable | None = None, choices: list | None = None, choice_icon: Callable | None = None, row_choices: Callable | None = None, choices_editable: bool = False, checkbox: bool = False, date: bool = False, affects_row: bool = False, affects_table: bool = False, carries: tuple = (), journal: Callable | None = None, does_not_apply: Callable | None = None, alignment: int = int(AlignRight | AlignVCenter))

One column of a TableModel: how to read it, how to show it, and whether it can be set.

Methods:

Name Description
choices_for

What one cell offers, which can be narrower than the column's

why_not

Why the property does not apply to one item, or '' where it

text

The cell's text: format applied to the value when the column

Attributes:

Name Type Description
editable bool

Whether the column can be set, which is whether it has a

Attributes
editable property
editable: bool

Whether the column can be set, which is whether it has a set.

Methods:
choices_for
choices_for(obj: Any, row: int) -> list | None

What one cell offers, which can be narrower than the column's list.

A channel the file said was an acceleration offers the acceleration units, not every unit, while the channel below it offers its own.

Source code in src/visualdynamics/gui/tables.py
def choices_for(self, obj: Any, row: int) -> list | None:
    """What one cell offers, which can be narrower than the column's
    list.

    A channel the file said was an acceleration offers the
    acceleration units, not every unit, while the channel below it
    offers its own.
    """
    if self.row_choices is not None:
        return self.row_choices(obj, row)
    return self.choices
why_not
why_not(obj: Any, row: int) -> str

Why the property does not apply to one item, or '' where it does.

Source code in src/visualdynamics/gui/tables.py
def why_not(self, obj: Any, row: int) -> str:
    """Why the property does not apply to one item, or '' where it
    does."""
    return self.does_not_apply(obj, row) if self.does_not_apply else ''
text
text(obj: Any, row: int) -> str

The cell's text: format applied to the value when the column has one, otherwise a float to six significant figures and '' for None.

Source code in src/visualdynamics/gui/tables.py
def text(self, obj: Any, row: int) -> str:
    """The cell's text: `format` applied to the value when the column
    has one, otherwise a float to six significant figures and '' for
    None."""
    value = self.get(obj, row)
    if self.format is not None:
        return self.format(value)
    if isinstance(value, float):
        return f'{value:.6g}'
    return '' if value is None else str(value)

TableModel

TableModel(obj: Any, columns: Sequence[Column], row_count: Callable[[Any], int], parent: QObject | None = None)

Bases: QAbstractTableModel

A table over a visualdynamics object, described by a list of Column objects.

edit_rejected carries the reason a cell refused a value. For each successful edit of a journaled column, edit_journaled carries the replay line's suffix; the window puts project[name] in front of it and appends it to the journal.

Methods:

Name Description
column_of

The property a cell belongs to.

column_number

The position of a cell's property in columns.

item_of

The item a cell belongs to, as the object counts them.

cell

The index of an item's cell in a property.

item_range

The first and last cells of one item.

inapplicable

Why a property does not apply to an item, or '' where it

refresh_row

Redraw one item, leaving the selection alone.

set_cells

Write a different value into each of many cells, as one change,

set_many

Write one value into many cells as one change, and return

Source code in src/visualdynamics/gui/tables.py
def __init__(self, obj: Any, columns: Sequence[Column],
             row_count: Callable[[Any], int],
             parent: QObject | None = None) -> None:
    super().__init__(parent)
    self.obj: Any = obj
    self.columns: list[Column] = list(columns)
    self._row_count = row_count
Methods:
column_of
column_of(index: QModelIndex | QPersistentModelIndex) -> Column

The property a cell belongs to.

Source code in src/visualdynamics/gui/tables.py
def column_of(self, index: QModelIndex | QPersistentModelIndex) -> Column:
    """The property a cell belongs to."""
    return self.columns[index.column()]
column_number
column_number(index: QModelIndex | QPersistentModelIndex) -> int

The position of a cell's property in columns.

Source code in src/visualdynamics/gui/tables.py
def column_number(self, index: QModelIndex | QPersistentModelIndex) -> int:
    """The position of a cell's property in `columns`."""
    return index.column()
item_of
item_of(index: QModelIndex | QPersistentModelIndex) -> int

The item a cell belongs to, as the object counts them.

Source code in src/visualdynamics/gui/tables.py
def item_of(self, index: QModelIndex | QPersistentModelIndex) -> int:
    """The item a cell belongs to, as the object counts them."""
    return index.row()
cell
cell(item: int, column_number: int) -> QModelIndex

The index of an item's cell in a property.

Source code in src/visualdynamics/gui/tables.py
def cell(self, item: int, column_number: int) -> QModelIndex:
    """The index of an item's cell in a property."""
    return self.index(item, column_number)
item_range
item_range(item: int) -> tuple[QModelIndex, QModelIndex]

The first and last cells of one item.

Source code in src/visualdynamics/gui/tables.py
def item_range(self, item: int) -> tuple[QModelIndex, QModelIndex]:
    """The first and last cells of one item."""
    return self.index(item, 0), self.index(item, self.columnCount() - 1)
inapplicable
inapplicable(item: int, column_number: int) -> str

Why a property does not apply to an item, or '' where it does (Column.does_not_apply).

Source code in src/visualdynamics/gui/tables.py
def inapplicable(self, item: int, column_number: int) -> str:
    """Why a property does not apply to an item, or '' where it
    does (`Column.does_not_apply`)."""
    return self.columns[column_number].why_not(self.obj, item)
refresh_row
refresh_row(row: int) -> None

Redraw one item, leaving the selection alone.

A full reset clears the selection, which matters when the selection is what the edit applies to: turning a coordinate system would lose the system being turned.

Source code in src/visualdynamics/gui/tables.py
def refresh_row(self, row: int) -> None:
    """Redraw one item, leaving the selection alone.

    A full reset clears the selection, which matters when the
    selection is what the edit applies to: turning a coordinate system
    would lose the system being turned.
    """
    self.dataChanged.emit(*self.item_range(row))
set_cells
set_cells(cells: Sequence[tuple[int, int, str]]) -> tuple[int, int, str]

Write a different value into each of many cells, as one change, and return (applied, rejected, reason).

cells holds (item, column number, text) for each cell. set_many is this with one value for every cell. One dataChanged covers all of them: setData cell by cell would emit once per cell, and each emission redraws the 3-D view, so setting a column of a few thousand nodes costs one redraw and not a few thousand.

Cells that refuse their value are left as they are and counted, so a batch that only partly lands is not mistaken for a clean one, and the reason for the first refusal is returned with the counts. A value written where the property does not apply is refused the same way; a blank written there is no edit at all, so a fill carried from one item to another of a different kind writes only what applies.

Source code in src/visualdynamics/gui/tables.py
def set_cells(self, cells: Sequence[tuple[int, int, str]]
              ) -> tuple[int, int, str]:
    """Write a different value into each of many cells, as one change,
    and return (applied, rejected, reason).

    `cells` holds (item, column number, text) for each cell. `set_many`
    is this with one value for every cell. One `dataChanged` covers all
    of them: setData cell by cell would emit once per cell, and each
    emission redraws the 3-D view, so setting a column of a few
    thousand nodes costs one redraw and not a few thousand.

    Cells that refuse their value are left as they are and counted,
    so a batch that only partly lands is not mistaken for a clean
    one, and the reason for the first refusal is returned with the
    counts. A value written where the property does not apply is
    refused the same way; a blank written there is no edit at all,
    so a fill carried from one item to another of a different kind
    writes only what applies.
    """
    applied = rejected = 0
    reason = ''
    touched = []
    for item, column_index, text in cells:
        column = self.columns[column_index]
        if not column.editable:
            rejected += 1
            continue
        why_not = column.why_not(self.obj, item)
        if why_not and str(text).strip():
            rejected += 1
            reason = reason or why_not
            continue
        if why_not and not column.text(self.obj, item):
            continue      # blank onto a blank that does not apply
        try:
            column.set(self.obj, item, text)
        except (ValueError, KeyError, IndexError) as e:
            rejected += 1
            reason = reason or str(e)
            continue
        self._journal_edit(column, item, text)
        applied += 1
        touched.append(self.cell(item, column_index))
    if applied:
        rows = [c.row() for c in touched]
        columns = [c.column() for c in touched]
        self.dataChanged.emit(self.index(min(rows), min(columns)),
                              self.index(max(rows), max(columns)))
    return applied, rejected, reason
set_many
set_many(indexes: Sequence[QModelIndex], text: str) -> tuple[int, int, str]

Write one value into many cells as one change, and return (applied, rejected, reason): set_cells with the same text for every cell.

Source code in src/visualdynamics/gui/tables.py
def set_many(self, indexes: Sequence[QModelIndex],
             text: str) -> tuple[int, int, str]:
    """Write one value into many cells as one change, and return
    (applied, rejected, reason): `set_cells` with the same text for
    every cell."""
    return self.set_cells((self.item_of(index), self.column_number(index), text)
                          for index in indexes)

TransposedTableModel

TransposedTableModel(obj: Any, columns: Sequence[Column], row_count: Callable[[Any], int], parent: QObject | None = None, item_label: Callable[[Any, int], str] | None = None)

Bases: TableModel

A TableModel the other way up: each property a row and each item a column, with item_label naming the columns. The Element Groups table is one (Brandon, 2026-10-20): a group's properties read as a column of entries, and several groups side by side are edited at once by selecting across them.

Source code in src/visualdynamics/gui/tables.py
def __init__(self, obj: Any, columns: Sequence[Column],
             row_count: Callable[[Any], int],
             parent: QObject | None = None,
             item_label: Callable[[Any, int], str] | None = None) -> None:
    super().__init__(obj, columns, row_count, parent)
    self._item_label = item_label

ChoiceDialog

ChoiceDialog(parent: QWidget | None, prompt: str, column: Column)

Bases: QDialog

Pick one of a column's choices, shown the way its cells show them.

Batch editing a column of colors offers the same list of colors as editing one cell of it, not a box to type a color name into.

Methods:

Name Description
value

The choice picked, as text.

Source code in src/visualdynamics/gui/tables.py
def __init__(self, parent: QWidget | None, prompt: str,
             column: Column) -> None:
    super().__init__(parent)
    self.setWindowTitle('Batch Edit')
    self.combo: QComboBox = QComboBox()
    for choice in column.choices:
        if column.choice_icon is not None:
            self.combo.addItem(column.choice_icon(choice), str(choice))
        else:
            self.combo.addItem(str(choice))
    buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
                               | QDialogButtonBox.StandardButton.Cancel)
    buttons.accepted.connect(self.accept)
    buttons.rejected.connect(self.reject)
    layout = QVBoxLayout(self)
    layout.addWidget(QLabel(prompt))
    layout.addWidget(self.combo)
    layout.addWidget(buttons)
Methods:
value
value() -> str

The choice picked, as text.

Source code in src/visualdynamics/gui/tables.py
def value(self) -> str:
    """The choice picked, as text."""
    return self.combo.currentText()

ChoiceDelegate

Bases: QStyledItemDelegate

Edit a column that declares choices with a drop-down, and a date column with a calendar.

Opening the cell opens the list, because a closed combo box would take a second click to show the choices. Picking one applies it and closes the editor, without waiting for the focus to move. Typing still works, since the model accepts the same values either way, so pasting a column of them from a spreadsheet is unaffected.

Methods:

Name Description
paint

Mark the selected cell of a choice column with a drop-down arrow.

Methods:
paint
paint(painter: QPainter, option: Any, index: QModelIndex | QPersistentModelIndex) -> None

Mark the selected cell of a choice column with a drop-down arrow.

Otherwise nothing shows that a cell has a list behind it until one has been opened. Only the selected cell is marked: an arrow in every cell of a 200k-row column is noise, and a spreadsheet marks the active cell the same way.

The arrow is the platform's own, drawn by the style as a combo box's arrow sub-control, so it is what a real drop-down would draw here.

Source code in src/visualdynamics/gui/tables.py
def paint(self, painter: QPainter, option: Any,
          index: QModelIndex | QPersistentModelIndex) -> None:
    """Mark the selected cell of a choice column with a drop-down arrow.

    Otherwise nothing shows that a cell has a list behind it until
    one has been opened. Only the selected cell is marked: an arrow in
    every cell of a 200k-row column is noise, and a spreadsheet marks
    the active cell the same way.

    The arrow is the platform's own, drawn by the style as a combo
    box's arrow sub-control, so it is what a real drop-down would draw
    here.
    """
    super().paint(painter, option, index)
    column = self._column(index)
    if column is None or not column.choices_for(
            getattr(index.model(), 'obj', None), self._item(index)):
        return
    if not option.state & QStyle.StateFlag.State_Selected:
        return
    combo = QStyleOptionComboBox()
    # the whole cell, so the style lays the arrow out where it would put
    # one; a cropped rect gets a squashed arrow on macOS
    combo.rect = option.rect
    combo.state = (QStyle.StateFlag.State_Enabled
                   | QStyle.StateFlag.State_Active)
    combo.subControls = QStyle.SubControl.SC_ComboBoxArrow
    combo.editable = column.choices_editable
    style = option.widget.style() if option.widget else QApplication.style()
    painter.save()
    painter.setClipRect(option.rect)
    style.drawComplexControl(QStyle.ComplexControl.CC_ComboBox, combo,
                             painter, option.widget)
    painter.restore()

CopyPasteTableView

CopyPasteTableView(parent: QWidget | None = None)

Bases: QTableView

A table view that behaves like a spreadsheet: copy and paste as tab-separated text, Batch Edit, Clear, a fill handle, and a copy button for the whole table.

edits_applied reports (cells written, cells refused, the first refusal's reason) after a paste, a batch edit, a clear or a fill. rows_deleted carries the whole rows a person asked to remove, and is emitted only when the owner sets rows_deletable, because only the owner knows whether a row is an item (a channel, a mode) or just cells. whole_table_copy is False where the table is a working surface, as in the geometry editor, and hides the whole-table copy.

Methods:

Name Description
show_rows

Show only these rows of the model; None shows them all.

selected_items

The items any selected cell belongs to, sorted: rows down a

select_items

Select whole items, as a click on their headers would.

row_shown

Whether row is shown (see show_rows).

selected_block

(rows, columns) covered by the selection, as sorted lists.

to_tsv

The selection, or every shown row with whole_table, as

copy

Put to_tsv on the clipboard, and return it.

table_cells

(headers, rows) of the whole table's shown rows, as text.

copy_table

Copy the whole table with its headers, as tab-separated text

cap_columns

Narrow any column wider than WIDEST_COLUMN_CHARACTERS to it.

cut_short

Whether cell index is too narrow for its text.

paste

Paste tab-separated text from the clipboard over the selection's

editable_selection

The selected cells that will accept a value.

inapplicable_selection

Why each selected cell that does not apply is skipped, one

shared_choices

The Column the selection shares, when all of its columns offer

batch_edit

Set every selected editable cell to one value, across columns

whole_rows

Rows whose every column is selected (a row-header click, or a

clear_cells

Empty the selected cells, for the columns that accept empty, and

fill_handle_rect

The small square at the bottom-right of the selection, or None.

fill_from_selection

Repeat the selected block along the items out to item

fill_to

Repeat the selected block over the rectangle from it to the

mouseDoubleClickEvent

Double-clicking the handle fills all the way down, as Excel does.

Source code in src/visualdynamics/gui/tables.py
def __init__(self, parent: QWidget | None = None) -> None:
    super().__init__(parent)
    # off while editing geometry, where the table is a working surface
    # rather than something to lift wholesale into a spreadsheet
    self.whole_table_copy: bool = True
    self.setSelectionBehavior(QTableView.SelectionBehavior.SelectItems)
    self.setSelectionMode(QTableView.SelectionMode.ExtendedSelection)
    self.setAlternatingRowColors(True)
    self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
    self.customContextMenuRequested.connect(self._show_menu)
    # SelectedClicked: clicking a cell that is already current opens it,
    # so a drop-down is one click away once its arrow is showing
    self.setEditTriggers(QTableView.EditTrigger.DoubleClicked
                         | QTableView.EditTrigger.SelectedClicked
                         | QTableView.EditTrigger.EditKeyPressed
                         | QTableView.EditTrigger.AnyKeyPressed)
    self.setItemDelegate(ChoiceDelegate(self))
    self._filling = None       # (rows, columns) being dragged from
    self._fill_to = None       # (row, column) under the cursor, while dragging
    self.viewport().setMouseTracking(True)   # to show the drag cursor
    # the copy button in the corner, shown while the pointer is over
    # the table — the same button every figure and report table
    # carries (Brandon, 2026-09-24)
    self._copy_button: QToolButton = QToolButton(self)
    self._copy_button.setIcon(control_icon('copy'))
    self._copy_button.setToolTip(
        'Copy the whole table — as cells for a spreadsheet, as a '
        'table for a document or a mail')
    self._copy_button.setAutoRaise(True)
    self._copy_button.setCursor(Qt.CursorShape.PointingHandCursor)
    self._copy_button.clicked.connect(self.copy_table)
    self._copy_button.hide()
Methods:
show_rows
show_rows(rows: Iterable[int] | None) -> None

Show only these rows of the model; None shows them all.

The other rows are out of the table, not only out of sight. They cannot be selected (selectionChanged deselects them, so Select All and a drag across the gap reach only what is shown, and the owner's actions on a selection, such as a highlight or Delete, never act on a row nobody can see), and copy, paste and the fill handle skip them. Rows keep their model indices, so code that reads the selection still reads the model's own rows.

Source code in src/visualdynamics/gui/tables.py
def show_rows(self, rows: Iterable[int] | None) -> None:
    """Show only these rows of the model; None shows them all.

    The other rows are out of the table, not only out of sight. They
    cannot be selected (`selectionChanged` deselects them, so Select
    All and a drag across the gap reach only what is shown, and the
    owner's actions on a selection, such as a highlight or Delete,
    never act on a row nobody can see), and copy, paste and the fill
    handle skip them. Rows keep their model indices, so code that
    reads the selection still reads the model's own rows.
    """
    # 2026-10-10: the pencil on an element group opened every element
    # group. The rows are items: columns in a transposed table.
    self._shown_rows = None if rows is None else {int(r) for r in rows}
    model = self.model()
    count = model.item_count() if model is not None else 0
    for item in range(count):
        if self._transposed():
            self.setColumnHidden(item, not self.row_shown(item))
        else:
            self.setRowHidden(item, not self.row_shown(item))
    selection = self.selectionModel()
    if selection is not None and self._shown_rows is not None:
        self._drop_hidden(selection.selection())
selected_items
selected_items() -> list[int]

The items any selected cell belongs to, sorted: rows down a table, columns across a transposed one.

Source code in src/visualdynamics/gui/tables.py
def selected_items(self) -> list[int]:
    """The items any selected cell belongs to, sorted: rows down a
    table, columns across a transposed one."""
    selection = self.selectionModel()
    if selection is None:
        return []
    return sorted({self._item(index) for index in selection.selectedIndexes()})
select_items
select_items(items: Iterable[int], clear: bool = True) -> None

Select whole items, as a click on their headers would.

Source code in src/visualdynamics/gui/tables.py
def select_items(self, items: Iterable[int], clear: bool = True) -> None:
    """Select whole items, as a click on their headers would."""
    model, selection = self.model(), self.selectionModel()
    if model is None or selection is None:
        return
    if clear:
        selection.clearSelection()
    whole = (QItemSelectionModel.SelectionFlag.Columns if self._transposed()
             else QItemSelectionModel.SelectionFlag.Rows)
    for item in items:
        selection.select(model.cell(int(item), 0),
                         QItemSelectionModel.SelectionFlag.Select | whole)
row_shown
row_shown(row: int) -> bool

Whether row is shown (see show_rows).

Source code in src/visualdynamics/gui/tables.py
def row_shown(self, row: int) -> bool:
    """Whether `row` is shown (see `show_rows`)."""
    shown = getattr(self, '_shown_rows', None)
    return shown is None or row in shown
selected_block
selected_block() -> tuple[list[int], list[int]]

(rows, columns) covered by the selection, as sorted lists.

Source code in src/visualdynamics/gui/tables.py
def selected_block(self) -> tuple[list[int], list[int]]:
    """(rows, columns) covered by the selection, as sorted lists."""
    indexes = self.selectedIndexes()
    if not indexes:
        return [], []
    rows = sorted({index.row() for index in indexes})
    columns = sorted({index.column() for index in indexes})
    return rows, columns
to_tsv
to_tsv(whole_table: bool = False) -> str

The selection, or every shown row with whole_table, as tab-separated text without headers.

Source code in src/visualdynamics/gui/tables.py
def to_tsv(self, whole_table: bool = False) -> str:
    """The selection, or every shown row with `whole_table`, as
    tab-separated text without headers."""
    model = self.model()
    if model is None:
        return ''
    if whole_table:
        rows = self._rows_shown(range(model.rowCount()))
        columns = self._columns_shown(range(model.columnCount()))
    else:
        rows, columns = self.selected_block()
        if not rows:
            return ''
    lines = []
    for row in rows:
        # EditRole, not Display: the two agree everywhere except a
        # checkbox cell, whose display is the box and whose text —
        # what a spreadsheet paste wants — lives on the edit side
        lines.append('\t'.join(
            str(model.data(model.index(row, column),
                           Qt.ItemDataRole.EditRole) or '')
            for column in columns))
    return '\n'.join(lines)
copy
copy(whole_table: bool = False) -> str

Put to_tsv on the clipboard, and return it.

Source code in src/visualdynamics/gui/tables.py
def copy(self, whole_table: bool = False) -> str:
    """Put `to_tsv` on the clipboard, and return it."""
    text = self.to_tsv(whole_table)
    if text:
        QApplication.clipboard().setText(text)
    return text
table_cells
table_cells() -> tuple[list[str], list[list[str]]]

(headers, rows) of the whole table's shown rows, as text.

Source code in src/visualdynamics/gui/tables.py
def table_cells(self) -> tuple[list[str], list[list[str]]]:
    """(headers, rows) of the whole table's shown rows, as text."""
    model = self.model()
    if model is None:
        return [], []
    columns = range(model.columnCount())
    headers = [str(model.headerData(c, Qt.Orientation.Horizontal,
                                    Qt.ItemDataRole.DisplayRole) or '')
               for c in columns]
    rows = [[str(model.data(model.index(r, c), Qt.ItemDataRole.EditRole)
                 or '') for c in columns]
            for r in self._shown(range(model.rowCount()))]
    return headers, rows
copy_table
copy_table() -> bool

Copy the whole table with its headers, as tab-separated text and as HTML in one clipboard entry, so a spreadsheet pastes cells and a document or an email pastes a table. Returns False when there are no rows to copy.

This is the copy button in the corner. A copy of a selection stays bare cells, which is what pasting back into a sheet needs.

Source code in src/visualdynamics/gui/tables.py
def copy_table(self) -> bool:
    """Copy the whole table with its headers, as tab-separated text
    and as HTML in one clipboard entry, so a spreadsheet pastes cells
    and a document or an email pastes a table. Returns False when
    there are no rows to copy.

    This is the copy button in the corner. A copy of a selection
    stays bare cells, which is what pasting back into a sheet needs.
    """
    # The copy button: Brandon, 2026-09-24.
    headers, rows = self.table_cells()
    if not rows:
        return False
    lines = [headers] + rows
    text = '\n'.join('\t'.join(v.replace('\t', ' ').replace('\n', ' ')
                              for v in line) for line in lines)

    def cell(tag, value):
        return f'<{tag}>{html.escape(value)}</{tag}>'

    body = ''.join('<tr>' + ''.join(cell('td', v) for v in row) + '</tr>'
                   for row in rows)
    markup = ('<table><thead><tr>'
              + ''.join(cell('th', h) for h in headers)
              + '</tr></thead><tbody>' + body + '</tbody></table>')
    mime = QMimeData()
    mime.setText(text)
    mime.setHtml(markup)
    QApplication.clipboard().setMimeData(mime)
    return True
cap_columns
cap_columns() -> None

Narrow any column wider than WIDEST_COLUMN_CHARACTERS to it.

A cell cut short this way ends in an ellipsis and shows its whole text on hover; the column is still the person's to drag wider.

Source code in src/visualdynamics/gui/tables.py
def cap_columns(self) -> None:
    """Narrow any column wider than `WIDEST_COLUMN_CHARACTERS` to it.

    A cell cut short this way ends in an ellipsis and shows its whole
    text on hover; the column is still the person's to drag wider."""
    model = self.model()
    if model is None:
        return
    limit = self.fontMetrics().averageCharWidth() * self.WIDEST_COLUMN_CHARACTERS
    for column in range(model.columnCount()):
        if self.columnWidth(column) > limit:
            self.setColumnWidth(column, limit)
cut_short
cut_short(index: QModelIndex) -> bool

Whether cell index is too narrow for its text.

Source code in src/visualdynamics/gui/tables.py
def cut_short(self, index: QModelIndex) -> bool:
    """Whether cell `index` is too narrow for its text."""
    text = index.data(Qt.ItemDataRole.DisplayRole)
    if text is None or text == '':
        return False
    # the text's room: the cell less the margin the style draws it in
    margin = 2 * (self.style().pixelMetric(QStyle.PixelMetric.PM_FocusFrameHMargin) + 1)
    return (self.fontMetrics().horizontalAdvance(str(text))
            > self.visualRect(index).width() - margin)
paste
paste() -> tuple[int, int]

Paste tab-separated text from the clipboard over the selection's top-left corner, onto the shown rows, and return (applied, rejected).

Cells that will not accept a value, a cell whose property does not apply to its item among them, are left as they are and counted, so a paste that partly lands is not silently half applied; edits_applied says how it landed. A paste past the table's edge stops there and does not wrap.

Source code in src/visualdynamics/gui/tables.py
def paste(self) -> tuple[int, int]:
    """Paste tab-separated text from the clipboard over the selection's
    top-left corner, onto the shown rows, and return (applied,
    rejected).

    Cells that will not accept a value, a cell whose property does
    not apply to its item among them, are left as they are and
    counted, so a paste that partly lands is not silently half
    applied; `edits_applied` says how it landed. A paste past the table's edge stops there and does not
    wrap.
    """
    model = self.model()
    text = QApplication.clipboard().text()
    if model is None or not text:
        return 0, 0
    rows, columns = self.selected_block()
    top = rows[0] if rows else 0
    left = columns[0] if columns else 0
    # line by line onto the rows that are shown, from the top one
    # down, and cell by cell onto the columns that are shown
    landing = self._rows_shown(range(top, model.rowCount()))
    across = self._columns_shown(range(left, model.columnCount()))

    applied = rejected = 0
    reasons = []

    def heard(reason):
        reasons.append(reason)

    # the cells' own refusals, so the summary can say why
    refusals = getattr(model, 'edit_rejected', None)
    if refusals is not None:
        refusals.connect(heard)
    for row_offset, line in enumerate(text.split('\n')):
        if not line.strip() and row_offset == len(text.split('\n')) - 1:
            continue          # trailing newline from the spreadsheet
        for column_offset, cell in enumerate(line.split('\t')):
            if row_offset >= len(landing) or column_offset >= len(across):
                continue      # pasting past the edge stops, not wraps
            row, column = landing[row_offset], across[column_offset]
            index = model.index(row, column)
            if not (model.flags(index) & Qt.ItemFlag.ItemIsEditable):
                rejected += 1
                why_not = self._why_not(index)
                if why_not:
                    reasons.append(why_not)
                continue
            if model.setData(index, cell, Qt.ItemDataRole.EditRole):
                applied += 1
            else:
                rejected += 1
    if refusals is not None:
        refusals.disconnect(heard)
    self.edits_applied.emit(applied, rejected, reasons[0] if reasons else '')
    return applied, rejected
editable_selection
editable_selection() -> list[QModelIndex]

The selected cells that will accept a value.

Source code in src/visualdynamics/gui/tables.py
def editable_selection(self) -> list[QModelIndex]:
    """The selected cells that will accept a value."""
    model = self.model()
    if model is None:
        return []
    return [index for index in self.selectedIndexes()
            if model.flags(index) & Qt.ItemFlag.ItemIsEditable]
inapplicable_selection
inapplicable_selection() -> list[str]

Why each selected cell that does not apply is skipped, one reason per cell. A batch edit counts them as refused, so a value set across a group of beams and a group of bricks says what it left out.

Source code in src/visualdynamics/gui/tables.py
def inapplicable_selection(self) -> list[str]:
    """Why each selected cell that does not apply is skipped, one
    reason per cell. A batch edit counts them as refused, so a value
    set across a group of beams and a group of bricks says what it
    left out."""
    model = self.model()
    if model is None:
        return []
    return [why for index in self.selectedIndexes()
            if not model.flags(index) & Qt.ItemFlag.ItemIsEditable
            and (why := self._why_not(index))]
shared_choices
shared_choices(indexes: Sequence[QModelIndex]) -> Column | None

The Column the selection shares, when all of its columns offer the same list, or None.

A selection spanning a color column and a coordinate column has no common list, so a batch edit of it falls back to typing a value.

Source code in src/visualdynamics/gui/tables.py
def shared_choices(self,
                   indexes: Sequence[QModelIndex]) -> Column | None:
    """The Column the selection shares, when all of its columns offer
    the same list, or None.

    A selection spanning a color column and a coordinate column has
    no common list, so a batch edit of it falls back to typing a
    value.
    """
    model = self.model()
    columns = list({id(model.column_of(index)): model.column_of(index)
                    for index in indexes}.values())
    first = columns[0]
    if first.choices and all(column.choices == first.choices
                             for column in columns):
        return first
    return None
batch_edit
batch_edit(text: str | None = None) -> tuple[int, int]

Set every selected editable cell to one value, across columns as well, and return (applied, rejected).

The prompt offers the columns' choices when the selection shares a list, and otherwise asks for text. Pass text to skip the prompt. Selected cells whose property does not apply to their item are left blank and counted as refused, with the reason.

Source code in src/visualdynamics/gui/tables.py
def batch_edit(self, text: str | None = None) -> tuple[int, int]:
    """Set every selected editable cell to one value, across columns
    as well, and return (applied, rejected).

    The prompt offers the columns' choices when the selection shares
    a list, and otherwise asks for text. Pass `text` to skip the
    prompt. Selected cells whose property does not apply to their
    item are left blank and counted as refused, with the reason.
    """
    indexes = self.editable_selection()
    skipped = self.inapplicable_selection()
    if not indexes:
        if skipped:
            self.edits_applied.emit(0, len(skipped), skipped[0])
        return 0, len(skipped)
    if text is None:
        prompt = f'Set {len(indexes)} selected cells to:'
        column = self.shared_choices(indexes)
        if column is not None:
            dialog = ChoiceDialog(self, prompt, column)
            if dialog.exec() != QDialog.DialogCode.Accepted:
                return 0, 0
            text = dialog.value()
        else:
            text, confirmed = QInputDialog.getText(
                self, 'Batch Edit', prompt)
            if not confirmed:
                return 0, 0
    applied, rejected, reason = self.model().set_many(indexes, text)
    if skipped:
        rejected += len(skipped)
        reason = reason or skipped[0]
    self.edits_applied.emit(applied, rejected, reason)
    return applied, rejected
whole_rows
whole_rows() -> list[int]

Rows whose every column is selected (a row-header click, or a drag across the full width). Delete removes whole rows, where the table allows it, and clears anything less.

Source code in src/visualdynamics/gui/tables.py
def whole_rows(self) -> list[int]:
    """Rows whose every column is selected (a row-header click, or a
    drag across the full width). Delete removes whole rows, where the
    table allows it, and clears anything less."""
    selection = self.selectionModel()
    if selection is None:
        return []
    if self._transposed():
        return sorted({index.column() for index in selection.selectedColumns()})
    return sorted({index.row() for index in selection.selectedRows()})
clear_cells
clear_cells() -> tuple[int, int]

Empty the selected cells, for the columns that accept empty, and return (applied, rejected).

A column that cannot be blank (a node id, a connectivity list) refuses and is counted, as it would refuse the same value typed in. The column decides what empty means.

Source code in src/visualdynamics/gui/tables.py
def clear_cells(self) -> tuple[int, int]:
    """Empty the selected cells, for the columns that accept empty, and
    return (applied, rejected).

    A column that cannot be blank (a node id, a connectivity list)
    refuses and is counted, as it would refuse the same value typed
    in. The column decides what empty means.
    """
    indexes = self.editable_selection()
    if not indexes:
        return 0, 0
    applied, rejected, reason = self.model().set_many(indexes, '')
    self.edits_applied.emit(applied, rejected, reason)
    return applied, rejected
fill_handle_rect
fill_handle_rect() -> QRect | None

The small square at the bottom-right of the selection, or None.

It is the spreadsheet fill handle, the fastest way to carry one value down a column, or across a row, without selecting the cells first.

Source code in src/visualdynamics/gui/tables.py
def fill_handle_rect(self) -> QRect | None:
    """The small square at the bottom-right of the selection, or None.

    It is the spreadsheet fill handle, the fastest way to carry one
    value down a column, or across a row, without selecting the
    cells first.
    """
    rows, columns = self.selected_block()
    if not rows or self.model() is None:
        return None
    corner = self.visualRect(self.model().index(rows[-1], columns[-1]))
    if not corner.isValid():
        return None
    # wholly inside the cell, not straddling its corner: an overhang
    # lies outside the rect Qt invalidates when the selection moves, and
    # stayed painted on the cell you clicked away from
    return QRect(corner.right() - FILL_HANDLE + 1,
                 corner.bottom() - FILL_HANDLE + 1,
                 FILL_HANDLE, FILL_HANDLE)
fill_from_selection
fill_from_selection(through: int) -> tuple[int, int]

Repeat the selected block along the items out to item through, and return (applied, rejected).

This is the fill down a column (across the columns of a transposed table), which double-clicking the handle does to the last item; fill_to is the drag, which can also go across.

Source code in src/visualdynamics/gui/tables.py
def fill_from_selection(self, through: int) -> tuple[int, int]:
    """Repeat the selected block along the items out to item
    `through`, and return (applied, rejected).

    This is the fill down a column (across the columns of a
    transposed table), which double-clicking the handle does to the
    last item; `fill_to` is the drag, which can also go across.
    """
    return self._fill(through, None)
fill_to
fill_to(row: int, column: int) -> tuple[int, int]

Repeat the selected block over the rectangle from it to the cell at (row, column), and return (applied, rejected).

This is the drag of the fill handle. The rectangle is the one the selection and that cell span, so a cell level with the selection fills across, one in line with it fills down, and one off both ways fills the whole rectangle between, as a spreadsheet does.

Source code in src/visualdynamics/gui/tables.py
def fill_to(self, row: int, column: int) -> tuple[int, int]:
    """Repeat the selected block over the rectangle from it to the
    cell at (`row`, `column`), and return (applied, rejected).

    This is the drag of the fill handle. The rectangle is the one
    the selection and that cell span, so a cell level with the
    selection fills across, one in line with it fills down, and one
    off both ways fills the whole rectangle between, as a
    spreadsheet does.
    """
    if self._transposed():
        return self._fill(column, row)
    return self._fill(row, column)
mouseDoubleClickEvent
mouseDoubleClickEvent(event: QMouseEvent) -> None

Double-clicking the handle fills all the way down, as Excel does.

The common case is one unit for every channel, and dragging over 45 rows to say so is slow.

Source code in src/visualdynamics/gui/tables.py
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
    """Double-clicking the handle fills all the way down, as Excel does.

    The common case is one unit for every channel, and dragging over
    45 rows to say so is slow.
    """
    if (event.button() == Qt.MouseButton.LeftButton
            and self._on_handle(event.position().toPoint())):
        self._filling = self._fill_to = None
        self.fill_from_selection(self.model().item_count() - 1)
        event.accept()
        return
    super().mouseDoubleClickEvent(event)

Functions:

wear_item_checks

wear_item_checks(app: Any = None) -> bool

Put ItemCheckStyle on the application when it runs the macOS style, and say whether it did. Once is enough; a second call, or any other style, changes nothing.

Source code in src/visualdynamics/gui/tables.py
def wear_item_checks(app: Any = None) -> bool:
    """Put `ItemCheckStyle` on the application when it runs the macOS
    style, and say whether it did. Once is enough; a second call, or
    any other style, changes nothing."""
    app = app or QApplication.instance()
    if app is None or isinstance(app.style(), ItemCheckStyle):
        return False
    if app.style().name().lower() != 'macos':
        return False
    app.setStyle(ItemCheckStyle(app.style().name()))
    return True

trace_units

trace_units(what: str) -> None

Write one line to the units-editor log for an event in a choice cell's drop-down, with the mouse buttons, the open popup and the focused widget at that moment.

The log is ~/Library/Logs/VisualDynamics-units.log, overwritten by the first line each run writes. A failure to write is ignored.

Source code in src/visualdynamics/gui/tables.py
def trace_units(what: str) -> None:
    """Write one line to the units-editor log for an event in a choice
    cell's drop-down, with the mouse buttons, the open popup and the
    focused widget at that moment.

    The log is ~/Library/Logs/VisualDynamics-units.log, overwritten by
    the first line each run writes. A failure to write is ignored.
    """
    # A witness log, like the drop log: the drop-down that closed the
    # moment it opened could not be reproduced by synthetic clicks, so
    # the real click writes its own record.
    try:
        from PySide6.QtWidgets import QApplication

        mode = 'a' if getattr(trace_units, '_open', False) else 'w'
        trace_units._open = True
        with open(_UNITS_TRACE, mode, encoding='utf-8') as log:
            focus = QApplication.focusWidget()
            log.write(f'{_time.strftime("%H:%M:%S")} {what} '
                      f'[buttons={QApplication.mouseButtons()!r} '
                      f'popup={type(QApplication.activePopupWidget()).__name__} '
                      f'focus={type(focus).__name__}]\n')
    except Exception:  # noqa: BLE001, S110 — a witness line must never
        pass               # be the thing that breaks the editor