From d84e475a43227bf3f5e7dcedf7028095bb3d79a1 Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 10 Jun 2026 17:11:22 -0400 Subject: [PATCH] feat(kit): inline-edit UX - hover pencil chip, styled editor, validation, tab-move --- cim_suite/core/ui/kit/delegate.py | 149 +++++++++++++++++++++++- cim_suite/core/ui/theme/stylesheet.py | 12 ++ packaging/suite.spec | 2 +- tests/core/kit/test_delegate_editing.py | 96 +++++++++++++++ tests/core/test_theme.py | 1 + 5 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 tests/core/kit/test_delegate_editing.py diff --git a/cim_suite/core/ui/kit/delegate.py b/cim_suite/core/ui/kit/delegate.py index bca4aaf..e30f6cc 100644 --- a/cim_suite/core/ui/kit/delegate.py +++ b/cim_suite/core/ui/kit/delegate.py @@ -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) diff --git a/cim_suite/core/ui/theme/stylesheet.py b/cim_suite/core/ui/theme/stylesheet.py index 4c4f78e..f6d6e90 100644 --- a/cim_suite/core/ui/theme/stylesheet.py +++ b/cim_suite/core/ui/theme/stylesheet.py @@ -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}; diff --git a/packaging/suite.spec b/packaging/suite.spec index 67fa240..5de58eb 100644 --- a/packaging/suite.spec +++ b/packaging/suite.spec @@ -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=[ diff --git a/tests/core/kit/test_delegate_editing.py b/tests/core/kit/test_delegate_editing.py new file mode 100644 index 0000000..75b745a --- /dev/null +++ b/tests/core/kit/test_delegate_editing.py @@ -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) diff --git a/tests/core/test_theme.py b/tests/core/test_theme.py index 5798de6..aacd10a 100644 --- a/tests/core/test_theme.py +++ b/tests/core/test_theme.py @@ -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}"