422 lines
18 KiB
Python
422 lines
18 KiB
Python
"""InstrumentDelegate — the kit's table cell renderer/editor (spec §5.4 / §5.6).
|
|
|
|
One instance per table, constructed with the view as parent. Rendering is driven by
|
|
a per-column *cell kind* (``set_column_kind``) with a per-item override (``KIND_ROLE``):
|
|
|
|
TEXT sans, left — the default; renders through the base class, so
|
|
item-level brushes (severity/staleness tints) keep working
|
|
NUMERIC mono, right-aligned — data values
|
|
IDENTIFIER mono, left, ``sub`` color — serials/MACs: present but recessive
|
|
STATUS dot + microcaps label tag; state from ``STATUS_ROLE`` (§1.2 shapes)
|
|
TOGGLE instrument switch bound to the item's check state
|
|
|
|
Inline editing (spec §5.6): hovering an editable cell paints an ``accent_soft``
|
|
chip with a pencil glyph — read-only cells get nothing, and that absence is the
|
|
affordance (``Qt.ItemIsEditable`` is the single source of truth). The in-place
|
|
editor is a ``#CellEditor`` QLineEdit styled in the theme QSS (signal ring; alarm
|
|
ring while its ``state`` property is ``"invalid"``). Per-column validators
|
|
(``set_column_validator``) block Enter/Tab while invalid and show the message as a
|
|
tooltip; Tab/Shift+Tab save and move to the next/previous editable cell; Esc cancels.
|
|
|
|
Row visuals (spec §5.4): the selected row's first column gets a 3px ``signal`` edge
|
|
bar; rows flagged with ``ALARM_ROW_ROLE`` (on the row's column-0 item) get the
|
|
``alarm_row`` full-row tint plus an ``alarm`` edge bar, overriding selection. Write
|
|
feedback (spec §5.6): ``mark_pending(row, col)`` shows a busy tint until
|
|
``resolve(row, col, ok=...)`` flashes ``ok_bg`` / ``alarm_bg`` for ~400ms.
|
|
|
|
All colors are read from ``theme.current()`` at paint time, so theme switches just
|
|
repaint — never cache token values here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from functools import lru_cache
|
|
|
|
from PySide6.QtCore import QEvent, QModelIndex, QPersistentModelIndex, QRectF, Qt, QTimer
|
|
from PySide6.QtGui import QBrush, QColor, QPainter, QPen, QRadialGradient
|
|
from PySide6.QtWidgets import (
|
|
QAbstractItemView,
|
|
QLineEdit,
|
|
QStyle,
|
|
QStyledItemDelegate,
|
|
QStyleOptionViewItem,
|
|
QWidget,
|
|
)
|
|
|
|
from ..theme import qcolor, type_styles
|
|
from ..theme.manager import current
|
|
from ..theme.tokens import Metrics
|
|
from .icons import icon_pixmap
|
|
from .toggle_switch import draw_toggle
|
|
|
|
|
|
class CellKind:
|
|
TEXT = "text"
|
|
NUMERIC = "numeric"
|
|
IDENTIFIER = "identifier"
|
|
STATUS = "status"
|
|
TOGGLE = "toggle"
|
|
|
|
|
|
KIND_ROLE = Qt.ItemDataRole.UserRole + 101 # per-item CellKind override
|
|
STATUS_ROLE = Qt.ItemDataRole.UserRole + 102 # "ok" | "warn" | "alarm" | "off" (later task)
|
|
ALARM_ROW_ROLE = Qt.ItemDataRole.UserRole + 103 # True on a row's column-0 item
|
|
|
|
_FLASH_MS = 400 # spec §5.6 write-confirm flash
|
|
|
|
|
|
def _as_brush(value) -> QBrush | None:
|
|
"""Normalize a Background/ForegroundRole value (QBrush or QColor) to a brush."""
|
|
if isinstance(value, QBrush) and value.style() != Qt.BrushStyle.NoBrush:
|
|
return value
|
|
if isinstance(value, QColor):
|
|
return QBrush(value)
|
|
return None
|
|
|
|
|
|
class InstrumentDelegate(QStyledItemDelegate):
|
|
def __init__(self, parent: QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self._view = parent if isinstance(parent, QAbstractItemView) else None
|
|
self._kinds: dict[int, str] = {}
|
|
self._pending: set[tuple[int, int]] = set()
|
|
self._flash: dict[tuple[int, int], bool] = {}
|
|
self._flash_gen: dict[tuple[int, int], int] = {}
|
|
self._validators: dict[int, Callable[[str], str | None]] = {}
|
|
self._editing: QPersistentModelIndex | None = None
|
|
|
|
# --- configuration ------------------------------------------------------
|
|
def set_column_kind(self, col: int, kind: str) -> None:
|
|
self._kinds[col] = kind
|
|
|
|
def set_column_validator(self, col: int, validator: Callable[[str], str | None]) -> None:
|
|
"""``validator(text)`` returns an error message, or None when valid (§5.6)."""
|
|
self._validators[col] = validator
|
|
|
|
def _kind(self, index) -> str:
|
|
return index.data(KIND_ROLE) or self._kinds.get(index.column(), CellKind.TEXT)
|
|
|
|
def _row_alarm(self, index) -> bool:
|
|
return bool(index.siblingAtColumn(0).data(ALARM_ROW_ROLE))
|
|
|
|
# --- painting -------------------------------------------------------------
|
|
def paint(self, painter, option, index) -> None:
|
|
t = current()
|
|
kind = self._kind(index)
|
|
rect = option.rect
|
|
cell = (index.row(), index.column())
|
|
alarm = self._row_alarm(index)
|
|
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
|
|
|
painter.save()
|
|
# Background precedence: flash > pending > alarm row > item brush > selection.
|
|
# Fix 2: accept QColor as well as QBrush for BackgroundRole.
|
|
item_brush = _as_brush(index.data(Qt.ItemDataRole.BackgroundRole))
|
|
has_item_bg = item_brush is not None
|
|
|
|
# Fix 1: apply item background brushes for ALL kinds (incl. TEXT).
|
|
# The app QSS suppresses the base-class backgroundBrush fill, so we must
|
|
# fill manually here regardless of kind.
|
|
filled = True
|
|
if cell in self._flash:
|
|
painter.fillRect(rect, qcolor(t.ok_bg if self._flash[cell] else t.alarm_bg))
|
|
elif cell in self._pending:
|
|
painter.fillRect(rect, qcolor(t.accent_soft))
|
|
elif alarm:
|
|
painter.fillRect(rect, qcolor(t.alarm_row))
|
|
elif has_item_bg:
|
|
painter.fillRect(rect, item_brush)
|
|
elif selected:
|
|
painter.fillRect(rect, qcolor(t.accent_soft))
|
|
else:
|
|
filled = False
|
|
|
|
editable = bool(index.flags() & Qt.ItemFlag.ItemIsEditable)
|
|
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
|
editing = self._editing is not None and QModelIndex(self._editing) == index
|
|
show_affordance = (
|
|
editable and hovered and not editing
|
|
and kind not in (CellKind.TOGGLE, CellKind.STATUS)
|
|
)
|
|
if show_affordance:
|
|
self._paint_hover_chip(painter, rect, t)
|
|
|
|
# Content.
|
|
if kind in (CellKind.NUMERIC, CellKind.IDENTIFIER):
|
|
# Fix 5: dim manual-path content for disabled cells (spec §5.1: 40%).
|
|
# Save/restore scopes the opacity change so the edge bar is unaffected.
|
|
# Only apply when the option state is initialised (non-zero) and the
|
|
# enabled flag is explicitly absent — a bare QStyleOptionViewItem (state=0)
|
|
# is treated as enabled so tests without a full option don't dim.
|
|
painter.save()
|
|
if option.state and not (option.state & QStyle.StateFlag.State_Enabled):
|
|
painter.setOpacity(painter.opacity() * 0.4)
|
|
self._paint_text(painter, rect, index, kind, t)
|
|
painter.restore()
|
|
elif kind == CellKind.STATUS:
|
|
self._paint_status(painter, rect, index, t)
|
|
elif kind == CellKind.TOGGLE:
|
|
state = index.data(Qt.ItemDataRole.CheckStateRole)
|
|
checked = state in (Qt.CheckState.Checked, Qt.CheckState.Checked.value)
|
|
enabled = bool(index.flags() & Qt.ItemFlag.ItemIsEnabled)
|
|
draw_toggle(painter, rect, checked, 1.0 if checked else 0.0, enabled=enabled)
|
|
else:
|
|
opt = QStyleOptionViewItem(option)
|
|
if filled:
|
|
opt.state &= ~QStyle.StateFlag.State_Selected # our fill already won
|
|
super().paint(painter, opt, index)
|
|
|
|
if show_affordance:
|
|
self._paint_pencil(painter, rect, t)
|
|
|
|
# Edge bar (spec §5.4): alarm beats selection; column 0 only.
|
|
if index.column() == 0 and (alarm or selected):
|
|
painter.fillRect(
|
|
rect.left(), rect.top(), Metrics.EDGE_BAR_W, rect.height(),
|
|
qcolor(t.alarm if alarm else t.signal),
|
|
)
|
|
painter.restore()
|
|
|
|
def _paint_text(self, painter: QPainter, rect, index, kind: str, t) -> None:
|
|
# Fix 2: accept QColor as well as QBrush for ForegroundRole.
|
|
fg = _as_brush(index.data(Qt.ItemDataRole.ForegroundRole))
|
|
if fg is not None:
|
|
pen = fg.color()
|
|
else:
|
|
pen = qcolor(t.sub if kind == CellKind.IDENTIFIER else t.ink)
|
|
align = (
|
|
Qt.AlignmentFlag.AlignRight
|
|
if kind == CellKind.NUMERIC
|
|
else Qt.AlignmentFlag.AlignLeft
|
|
) | Qt.AlignmentFlag.AlignVCenter
|
|
painter.setFont(type_styles.data_cell())
|
|
painter.setPen(pen)
|
|
pad = rect.adjusted(Metrics.CELL_HPAD, 0, -Metrics.CELL_HPAD, 0)
|
|
# Fix 3: falsy data (0, 0.0, False) must render, not blank.
|
|
data = index.data()
|
|
text = "" if data is None else str(data)
|
|
painter.drawText(pad, align, text)
|
|
|
|
# --- status cells --------------------------------------------------------
|
|
_DOT = 7 # status glyph box, px
|
|
|
|
def _paint_status(self, painter: QPainter, rect, index, t) -> None:
|
|
state = index.data(STATUS_ROLE) or "off"
|
|
if state not in ("ok", "warn", "alarm"):
|
|
state = "off"
|
|
color = qcolor({
|
|
"ok": t.ok, "warn": t.warn, "alarm": t.alarm, "off": t.offline,
|
|
}.get(state, t.offline))
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
d = self._DOT
|
|
rf = QRectF(rect)
|
|
box = QRectF(rf.left() + Metrics.CELL_HPAD, rf.center().y() - d / 2, d, d)
|
|
if t.name == "dark" and state != "off":
|
|
# §1.2: dark theme only — soft 6px glow behind the dot.
|
|
glow = QRadialGradient(box.center(), d)
|
|
inner = QColor(color)
|
|
inner.setAlpha(90)
|
|
outer = QColor(color)
|
|
outer.setAlpha(0)
|
|
glow.setColorAt(0.0, inner)
|
|
glow.setColorAt(1.0, outer)
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(glow)
|
|
painter.drawEllipse(box.center(), float(d), float(d))
|
|
if state == "alarm":
|
|
painter.fillRect(box, color) # square = alarm (§1.2 shape redundancy)
|
|
elif state == "off":
|
|
pen = QPen(color)
|
|
pen.setWidthF(1.5)
|
|
painter.setPen(pen)
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.drawEllipse(box.adjusted(0.75, 0.75, -0.75, -0.75)) # hollow circle
|
|
else:
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(color)
|
|
painter.drawEllipse(box)
|
|
painter.setFont(type_styles.status_label())
|
|
painter.setPen(QPen(color))
|
|
label_rect = QRectF(
|
|
box.right() + 6, rect.top(), rect.right() - box.right() - 6, rect.height()
|
|
)
|
|
label = str(index.data() or state)
|
|
painter.drawText(
|
|
label_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, label.upper()
|
|
)
|
|
|
|
# --- hover affordance (spec §5.6 steps 1-2) ----------------------------------
|
|
def _paint_hover_chip(self, painter: QPainter, rect, t) -> None:
|
|
"""§5.6 step 2: accent_soft chip + signal outline at ~33% alpha."""
|
|
painter.save()
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
outline = qcolor(t.signal)
|
|
outline.setAlphaF(outline.alphaF() * 0.33)
|
|
painter.setPen(QPen(outline))
|
|
painter.setBrush(qcolor(t.accent_soft))
|
|
painter.drawRoundedRect(QRectF(rect).adjusted(3.5, 3.5, -3.5, -3.5), 3, 3)
|
|
painter.restore()
|
|
|
|
_PENCIL = 13 # spec §5.1 icon size
|
|
|
|
def _paint_pencil(self, painter: QPainter, rect, t) -> None:
|
|
pixmap = _pencil_pixmap(t.accent, self._PENCIL)
|
|
painter.drawPixmap(
|
|
rect.right() - self._PENCIL - 6,
|
|
rect.center().y() - self._PENCIL // 2,
|
|
pixmap,
|
|
)
|
|
|
|
# --- toggle cells ------------------------------------------------------------
|
|
def editorEvent(self, event, model, option, index) -> bool: # noqa: N802 (Qt)
|
|
if self._kind(index) == CellKind.TOGGLE:
|
|
flags = index.flags()
|
|
interactive = bool(flags & Qt.ItemFlag.ItemIsUserCheckable) and bool(
|
|
flags & Qt.ItemFlag.ItemIsEnabled
|
|
)
|
|
if not interactive:
|
|
return False
|
|
if (
|
|
event.type() == QEvent.Type.MouseButtonRelease
|
|
and event.button() == Qt.MouseButton.LeftButton
|
|
):
|
|
state = index.data(Qt.ItemDataRole.CheckStateRole)
|
|
checked = state in (Qt.CheckState.Checked, Qt.CheckState.Checked.value)
|
|
model.setData(
|
|
index,
|
|
Qt.CheckState.Unchecked if checked else Qt.CheckState.Checked,
|
|
Qt.ItemDataRole.CheckStateRole,
|
|
)
|
|
return True
|
|
if event.type() == QEvent.Type.MouseButtonDblClick:
|
|
return True # swallow, so the base checkbox hit-test never runs
|
|
if event.type() == QEvent.Type.MouseButtonPress:
|
|
return False # let the view select the row; release still toggles
|
|
return super().editorEvent(event, model, option, index)
|
|
|
|
# --- inline editor (spec §5.6) ------------------------------------------------
|
|
def createEditor(self, parent, option, index): # noqa: N802 (Qt)
|
|
editor = super().createEditor(parent, option, index)
|
|
if isinstance(editor, QLineEdit):
|
|
editor.setObjectName("CellEditor")
|
|
kind = self._kind(index)
|
|
if kind in (CellKind.NUMERIC, CellKind.IDENTIFIER):
|
|
editor.setFont(type_styles.data_cell())
|
|
_set_editor_state(editor, "valid")
|
|
editor.textChanged.connect(lambda _t, e=editor: _set_editor_state(e, "valid"))
|
|
self._editing = QPersistentModelIndex(index)
|
|
return editor
|
|
|
|
def destroyEditor(self, editor, index) -> None: # noqa: N802 (Qt)
|
|
self._editing = None
|
|
super().destroyEditor(editor, index)
|
|
|
|
def eventFilter(self, editor, event) -> bool: # noqa: N802 (Qt)
|
|
if event.type() == QEvent.Type.KeyPress:
|
|
key = event.key()
|
|
if key in (Qt.Key.Key_Tab, Qt.Key.Key_Backtab):
|
|
return self._save_and_move(editor, backwards=key == Qt.Key.Key_Backtab)
|
|
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and isinstance(editor, QLineEdit):
|
|
if not self._validate(editor):
|
|
return True # §5.6 step 5: Enter blocked while invalid
|
|
# Commit synchronously — the base-class filter queues the commit
|
|
# (Qt invokes it via a QueuedConnection), which the same-line
|
|
# save-then-read flows in tests/callers would miss.
|
|
self.commitData.emit(editor)
|
|
self.closeEditor.emit(editor, QStyledItemDelegate.EndEditHint.SubmitModelCache)
|
|
return True
|
|
return super().eventFilter(editor, event)
|
|
|
|
def _save_and_move(self, editor, *, backwards: bool) -> bool:
|
|
"""§5.6 step 4: Tab/Shift+Tab save, then edit the next/previous editable cell."""
|
|
if not self._validate(editor):
|
|
return True
|
|
start = QModelIndex(self._editing) if self._editing is not None else QModelIndex()
|
|
self.commitData.emit(editor)
|
|
self.closeEditor.emit(editor, QStyledItemDelegate.EndEditHint.NoHint)
|
|
if self._view is None or not start.isValid():
|
|
return True
|
|
target = self._next_editable(start, -1 if backwards else 1)
|
|
if target is not None:
|
|
self._view.setCurrentIndex(target)
|
|
self._view.edit(target)
|
|
return True
|
|
|
|
@staticmethod
|
|
def _next_editable(index, step: int):
|
|
model = index.model()
|
|
rows, cols = model.rowCount(), model.columnCount()
|
|
if rows * cols <= 1:
|
|
return None
|
|
pos = index.row() * cols + index.column()
|
|
for _ in range(rows * cols - 1):
|
|
pos = (pos + step) % (rows * cols)
|
|
candidate = model.index(pos // cols, pos % cols)
|
|
if candidate.flags() & Qt.ItemFlag.ItemIsEditable:
|
|
return candidate
|
|
return None
|
|
|
|
def _validate(self, editor) -> bool:
|
|
if not isinstance(editor, QLineEdit) or self._editing is None:
|
|
return True
|
|
validator = self._validators.get(self._editing.column())
|
|
if validator is None:
|
|
return True
|
|
error = validator(editor.text())
|
|
if error:
|
|
editor.setToolTip(error)
|
|
_set_editor_state(editor, "invalid")
|
|
return False
|
|
return True
|
|
|
|
# --- write feedback (spec §5.6 step 6) --------------------------------------
|
|
def mark_pending(self, row: int, col: int) -> None:
|
|
"""Subtle busy tint while a hardware write is in flight."""
|
|
self._pending.add((row, col))
|
|
self._repaint()
|
|
|
|
def resolve(self, row: int, col: int, *, ok: bool) -> None:
|
|
"""End the busy state: flash ``ok_bg`` on confirm / ``alarm_bg`` on failure."""
|
|
# Fix 4: use a generation counter so overlapping resolves don't truncate the
|
|
# newer flash — each timer only clears the generation it was scheduled for.
|
|
self._pending.discard((row, col))
|
|
self._flash[(row, col)] = bool(ok)
|
|
gen = self._flash_gen.get((row, col), 0) + 1
|
|
self._flash_gen[(row, col)] = gen
|
|
self._repaint()
|
|
QTimer.singleShot(_FLASH_MS, lambda: self._end_flash(row, col, gen))
|
|
|
|
def _end_flash(self, row: int, col: int, gen: int) -> None:
|
|
# Only clear if this timer is still the current generation for this cell.
|
|
if self._flash_gen.get((row, col)) == gen:
|
|
self._flash.pop((row, col), None)
|
|
self._flash_gen.pop((row, col), None)
|
|
self._repaint()
|
|
|
|
def _repaint(self) -> None:
|
|
if self._view is None:
|
|
return
|
|
try:
|
|
self._view.viewport().update()
|
|
except RuntimeError:
|
|
pass # view torn down before the flash timer fired
|
|
|
|
|
|
@lru_cache(maxsize=8)
|
|
def _pencil_pixmap(color: str, size: int):
|
|
"""Hover pencil, cached per resolved token color — paint-path hot.
|
|
|
|
The cache key includes the color string, so a theme switch (different token
|
|
value) renders fresh; it can never serve stale-theme pixels.
|
|
"""
|
|
return icon_pixmap("pencil", color, size)
|
|
|
|
|
|
def _set_editor_state(editor: QLineEdit, state: str) -> None:
|
|
"""Drive the QSS ``#CellEditor[state=...]`` border, repolishing only on change."""
|
|
if editor.property("state") != state:
|
|
editor.setProperty("state", state)
|
|
editor.style().unpolish(editor)
|
|
editor.style().polish(editor)
|