feat(kit): inline-edit UX - hover pencil chip, styled editor, validation, tab-move
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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=[
|
||||
|
||||
96
tests/core/kit/test_delegate_editing.py
Normal file
96
tests/core/kit/test_delegate_editing.py
Normal 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)
|
||||
@@ -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}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user