feat(kit): inline-edit UX - hover pencil chip, styled editor, validation, tab-move

This commit is contained in:
2026-06-10 17:11:22 -04:00
parent a6394a2889
commit d84e475a43
5 changed files with 257 additions and 3 deletions

View File

@@ -10,7 +10,13 @@ a per-column *cell kind* (``set_column_kind``) with a per-item override (``KIND_
STATUS dot + microcaps label tag; state from ``STATUS_ROLE`` (§1.2 shapes)
TOGGLE instrument switch bound to the item's check state
(A later kit task adds the inline-editing UX.)
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
@@ -24,10 +30,14 @@ repaint — never cache token values here.
from __future__ import annotations
from PySide6.QtCore import QEvent, QRectF, Qt, QTimer
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,
@@ -37,6 +47,7 @@ from PySide6.QtWidgets import (
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
@@ -72,11 +83,17 @@ class InstrumentDelegate(QStyledItemDelegate):
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)
@@ -115,6 +132,16 @@ class InstrumentDelegate(QStyledItemDelegate):
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%).
@@ -140,6 +167,9 @@ class InstrumentDelegate(QStyledItemDelegate):
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(
@@ -216,6 +246,28 @@ class InstrumentDelegate(QStyledItemDelegate):
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:
@@ -243,6 +295,81 @@ class InstrumentDelegate(QStyledItemDelegate):
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."""
@@ -274,3 +401,21 @@ class InstrumentDelegate(QStyledItemDelegate):
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)

View File

@@ -227,6 +227,18 @@ QComboBox QAbstractItemView {{
outline: none;
}}
/* In-place table cell editor (spec §5.6): signal ring; alarm ring while invalid.
Qt QSS has no box-shadow / fractional borders, so the §7-sanctioned border-only
focus treatment stands in for the 1.5px+glow ring. */
QLineEdit#CellEditor {{
background-color: {t.surface};
color: {t.ink};
border: 2px solid {t.signal};
border-radius: {R.SM}px;
padding: 0px 4px;
}}
QLineEdit#CellEditor[state="invalid"] {{ border-color: {t.alarm}; }}
/* ---- Group boxes ------------------------------------------------------ */
QGroupBox {{
background-color: {t.surface};

View File

@@ -66,7 +66,7 @@ a = Analysis(
(_pyproject, "."),
(_changelog, "."),
],
hiddenimports=["serial.tools.list_ports", "openpyxl", "PySide6.QtCharts"],
hiddenimports=["serial.tools.list_ports", "openpyxl", "PySide6.QtCharts", "PySide6.QtSvg"],
hookspath=[],
runtime_hooks=[],
excludes=[

View File

@@ -0,0 +1,96 @@
"""InstrumentDelegate editing UX: styled editor, validation, Tab-move, hover chip."""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QLineEdit, QStyle, QTableWidget, QTableWidgetItem
from cim_suite.core.ui.kit import CellKind, InstrumentDelegate
from cim_suite.core.ui.theme import current
def _table(qtbot, rows, editable):
"""editable: set of (row, col) that keep Qt.ItemIsEditable."""
table = QTableWidget(len(rows), len(rows[0]))
qtbot.addWidget(table)
for r, row in enumerate(rows):
for c, text in enumerate(row):
item = QTableWidgetItem(text)
if (r, c) not in editable:
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(r, c, item)
delegate = InstrumentDelegate(table)
table.setItemDelegate(delegate)
table.show()
return table, delegate
def _editor(table):
editor = table.findChild(QLineEdit, "CellEditor")
assert editor is not None
return editor
def test_editor_is_the_styled_cell_editor(qtbot):
table, _ = _table(qtbot, [["a"]], {(0, 0)})
table.editItem(table.item(0, 0))
editor = _editor(table)
assert editor.objectName() == "CellEditor"
def test_invalid_input_blocks_enter_until_fixed(qtbot):
table, delegate = _table(qtbot, [["5"]], {(0, 0)})
delegate.set_column_validator(
0, lambda text: None if text.strip().isdigit() else "whole numbers only"
)
table.editItem(table.item(0, 0))
editor = _editor(table)
editor.setText("abc")
qtbot.keyClick(editor, Qt.Key.Key_Return)
assert editor.property("state") == "invalid"
assert editor.toolTip() == "whole numbers only"
assert table.item(0, 0).text() == "5" # commit was blocked
editor.setText("12") # textChanged clears the invalid state
assert editor.property("state") == "valid"
qtbot.keyClick(editor, Qt.Key.Key_Return)
assert table.item(0, 0).text() == "12"
def test_tab_saves_and_moves_to_next_editable_cell(qtbot):
# (0,0) and (1,0) editable; (0,1) and (1,1) read-only — Tab must skip them.
table, _ = _table(qtbot, [["a", "ro"], ["b", "ro"]], {(0, 0), (1, 0)})
table.editItem(table.item(0, 0))
editor = _editor(table)
editor.setText("edited")
qtbot.keyClick(editor, Qt.Key.Key_Tab)
assert table.item(0, 0).text() == "edited" # Tab saved
assert (table.currentRow(), table.currentColumn()) == (1, 0) # skipped the RO cell
assert table.state() == table.State.EditingState # and opened the editor
def test_shift_tab_moves_backwards(qtbot):
table, _ = _table(qtbot, [["a", "ro"], ["b", "ro"]], {(0, 0), (1, 0)})
table.editItem(table.item(1, 0))
editor = _editor(table)
qtbot.keyClick(editor, Qt.Key.Key_Backtab)
assert (table.currentRow(), table.currentColumn()) == (0, 0)
def test_escape_cancels(qtbot):
table, _ = _table(qtbot, [["keep"]], {(0, 0)})
table.editItem(table.item(0, 0))
editor = _editor(table)
editor.setText("discard me")
qtbot.keyClick(editor, Qt.Key.Key_Escape)
assert table.item(0, 0).text() == "keep"
def test_hover_chip_only_on_editable_cells(qtbot, render_cell, near):
table, delegate = _table(qtbot, [["v", "ro"]], {(0, 0)})
delegate.set_column_kind(0, CellKind.NUMERIC)
delegate.set_column_kind(1, CellKind.NUMERIC)
hover = QStyle.StateFlag.State_MouseOver | QStyle.StateFlag.State_Enabled
editable_img = render_cell(delegate, table.model().index(0, 0), state=hover)
readonly_img = render_cell(delegate, table.model().index(0, 1), state=hover)
# §5.6: the chip appears on the editable cell; its absence IS the RO affordance.
assert near(editable_img.pixelColor(60, 6), current().accent_soft, tol=12)
assert near(readonly_img.pixelColor(60, 6), current().surface, tol=8)
assert editable_img.pixelColor(60, 6) != readonly_img.pixelColor(60, 6)

View File

@@ -26,6 +26,7 @@ def test_qss_keeps_existing_widget_hooks(tokens):
"RebootBanner", "LauncherPage", "WhatsNewButton", "ModuleCard",
"CablePanel", "SubnetPreview", "LoadingOverlay", "HelpText",
"ChromeTitleBar", "TitleBarBrand", "ThemeToggle", "CloseButton",
"CellEditor",
):
assert hook in qss, f"missing QSS hook: {hook}"