Files
cimtechniques-service-suite/cim_suite/core/ui/kit/delegate.py

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)