Skip to content

visualdynamics.gui.tables

tables

Tables that behave like a spreadsheet.

Model/view rather than per-cell widgets, so a table costs nothing until it is scrolled, edits write straight back to the underlying visualdynamics object with validation, and switching display units restates the values without rebuilding anything.

CopyPasteTableView speaks Excel's clipboard format — tab-separated text, newline-separated rows — so a selection copies straight into a spreadsheet and a block of spreadsheet cells pastes back in.

Classes:

Name Description
Column

One column: how to read it, how to show it, whether it can be set.

TableModel

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

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.

CopyPasteTableView

A table view with spreadsheet clipboard behaviour.

Functions:

Name Description
trace_units

A witness line per units-editor event, like the drop log: the

Classes

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,
    carries: tuple = (),
    journal: Callable | None = None,
    alignment: int = int(AlignRight | AlignVCenter),
)

One column: how to read it, how to show it, whether it can be set.

Methods:

Name Description
choices_for

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

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 should offer four units, not all twenty-one, while the channel below it offers its own four.

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 should offer four units,
    not all twenty-one, while the channel below it offers its own four.
    """
    if self.row_choices is not None:
        return self.row_choices(obj, row)
    return self.choices

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 Columns.

Methods:

Name Description
refresh_row

Restate one row, 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, reporting a single change —

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:
refresh_row
refresh_row(row: int) -> None

Restate one row, 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 very system being turned.

Source code in src/visualdynamics/gui/tables.py
def refresh_row(self, row: int) -> None:
    """Restate one row, 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 very system being turned.
    """
    self.dataChanged.emit(self.index(row, 0),
                          self.index(row, self.columnCount() - 1))
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.

set_many puts one value everywhere and is written over this; here each cell gets its own. One dataChanged for the lot: cell-by- cell setData would emit once per cell, and every one of those redraws the 3D view, so setting a column of a few thousand nodes has to cost one redraw, not a few thousand.

Cells that refuse the value are left alone and counted, so a batch that only partly lands is not mistaken for a clean one; the first refusal's reason comes back with the counts.

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.

    `set_many` puts one value everywhere and is written over this;
    here each cell gets its own. One dataChanged for the lot: cell-by-
    cell setData would emit once per cell, and every one of those
    redraws the 3D view, so setting a column of a few thousand nodes
    has to cost one redraw, not a few thousand.

    Cells that refuse the value are left alone and counted, so a batch
    that only partly lands is not mistaken for a clean one; the first
    refusal's reason comes back with the counts.
    """
    applied = rejected = 0
    reason = ''
    rows, columns = [], []
    for row, column_index, text in cells:
        column = self.columns[column_index]
        if not column.editable:
            rejected += 1
            continue
        try:
            column.set(self.obj, row, text)
        except (ValueError, KeyError, IndexError) as e:
            rejected += 1
            reason = reason or str(e)
            continue
        self._journal_edit(column, row, text)
        applied += 1
        rows.append(row)
        columns.append(column_index)
    if applied:
        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, reporting a single change — 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, reporting a single change —
    `set_cells` with the same text for every cell."""
    return self.set_cells((index.row(), index.column(), text)
                          for index in indexes)

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 colours should offer the same list of colours as editing one cell of it, not a box to type a colour name into.

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)

ChoiceDelegate

Bases: QStyledItemDelegate

Edit a column that declares choices with a drop-down.

Opening the cell opens the list: a closed combo box would make the user click twice to see what the choices even are. Picking one applies it and closes, rather than waiting for focus to move away.

Typing still works — 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 there is nothing to say a cell has a list behind it until you have already opened one. 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 — asking the style for a combo box's arrow sub-control, so it is whatever a real drop-down would draw here — rather than something hand-drawn that would look foreign.

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 there is nothing to say a cell has a list behind it until
    you have already opened one. 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 — asking the style for a combo box's
    arrow sub-control, so it is whatever a real drop-down would draw
    here — rather than something hand-drawn that would look foreign.
    """
    super().paint(painter, option, index)
    column = self._column(index)
    if column is None or not column.choices_for(
            getattr(index.model(), 'obj', None), index.row()):
        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 with spreadsheet clipboard behaviour.

Methods:

Name Description
selected_block

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

paste

Paste tab-separated text over the selection's top-left corner.

editable_selection

The selected cells that will accept a value.

shared_choices

The Column the selection shares, when they all offer one list.

batch_edit

Set every selected cell to one value, across columns as well.

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.

fill_handle_rect

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

fill_from_selection

Repeat the selected block over the rows out to through.

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 down from
    self._fill_to = None       # the row the cursor is over, while dragging
    self.viewport().setMouseTracking(True)   # to show the drag cursor
Methods:
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
paste
paste() -> tuple[int, int]

Paste tab-separated text over the selection's top-left corner.

Cells that will not accept a value are left alone and counted, so a paste that partly lands is not silently half-applied.

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

    Cells that will not accept a value are left alone and counted, so a
    paste that partly lands is not silently half-applied.
    """
    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

    applied = rejected = 0
    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')):
            row, column = top + row_offset, left + column_offset
            if row >= model.rowCount() or column >= model.columnCount():
                continue      # pasting past the edge stops, not wraps
            index = model.index(row, column)
            if not (model.flags(index) & Qt.ItemFlag.ItemIsEditable):
                rejected += 1
                continue
            if model.setData(index, cell, Qt.ItemDataRole.EditRole):
                applied += 1
            else:
                rejected += 1
    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]
shared_choices
shared_choices(
    indexes: Sequence[QModelIndex],
) -> Column | None

The Column the selection shares, when they all offer one list.

A selection spanning a colour column and a coordinate column has no common list, so 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 they all offer one list.

    A selection spanning a colour column and a coordinate column has no
    common list, so it falls back to typing a value.
    """
    model = self.model()
    columns = [model.columns[column] for column
               in {index.column() for index in indexes}]
    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 cell to one value, across columns as well.

Pass text to skip the prompt.

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

    Pass `text` to skip the prompt.
    """
    indexes = self.editable_selection()
    if not indexes:
        return 0, 0
    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)
    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. The distinction Delete turns on.

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. The distinction Delete turns on."""
    selection = self.selectionModel()
    if selection is None:
        return []
    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.

A column that cannot be blank — a node id, a connectivity list — refuses and is counted, exactly as it would refuse the same value typed in. Nothing here decides what empty means; the column does.

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.

    A column that cannot be blank — a node id, a connectivity list —
    refuses and is counted, exactly as it would refuse the same value
    typed in. Nothing here decides what empty means; the column does.
    """
    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 little square at the bottom-right of the selection, or None.

Excel's affordance, and the fastest way to carry one value down a column without selecting the whole thing first.

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

    Excel's affordance, and the fastest way to carry one value down a
    column without selecting the whole thing 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 over the rows out to through.

The block repeats rather than only its last row, so filling from two alternating rows carries the alternation — which is what a spreadsheet does and what makes it worth dragging two cells.

Source code in src/visualdynamics/gui/tables.py
def fill_from_selection(self, through: int) -> tuple[int, int]:
    """Repeat the selected block over the rows out to `through`.

    The block repeats rather than only its last row, so filling from two
    alternating rows carries the alternation — which is what a
    spreadsheet does and what makes it worth dragging two cells.
    """
    rows, columns = self.selected_block()
    model = self.model()
    if not rows or model is None:
        return 0, 0
    # a column can declare companions that fill along with it —
    # sorted, so a carried Type lands before the Unit that carried it
    specs = getattr(model, 'columns', None)
    if specs is not None:
        wanted = set(columns)
        for c in columns:
            wanted.update(j for j, spec in enumerate(specs)
                          if spec.title in specs[c].carries)
        columns = sorted(wanted)
    if through > rows[-1]:
        targets = range(rows[-1] + 1, min(through, model.rowCount() - 1) + 1)
    elif through < rows[0]:
        targets = range(max(through, 0), rows[0])
    else:
        return 0, 0
    cells = []
    for target in targets:
        # step through the block in order, wrapping, measured from its top
        source = rows[(target - rows[0]) % len(rows)]
        for column in columns:
            cells.append((target, column, model.data(
                model.index(source, column), Qt.ItemDataRole.EditRole)))
    applied, rejected, reason = model.set_cells(cells)
    self.edits_applied.emit(applied, rejected, reason)
    return applied, rejected
mouseDoubleClickEvent
mouseDoubleClickEvent(event: QMouseEvent) -> None

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

The common case is "this unit, for every channel"; dragging 45 rows to say it is a chore.

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 "this unit, for every channel"; dragging 45 rows
    to say it is a chore.
    """
    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().rowCount() - 1)
        event.accept()
        return
    super().mouseDoubleClickEvent(event)

Functions:

trace_units

trace_units(what: str) -> None

A witness line per units-editor event, like the drop log: the instantly-closing drop-down could not be reproduced by synthetic clicks, so the real click writes its own story (~/Library/Logs/VisualDynamics-units.log, truncated per session by the first line written).

Source code in src/visualdynamics/gui/tables.py
def trace_units(what: str) -> None:
    """A witness line per units-editor event, like the drop log: the
    instantly-closing drop-down could not be reproduced by synthetic
    clicks, so the real click writes its own story
    (~/Library/Logs/VisualDynamics-units.log, truncated per session by
    the first line written)."""
    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