Simplify UI interaction and stabilize DataGrid tracks

This commit is contained in:
Kinneyzhang 2026-08-22 17:37:27 +08:00
parent 5333d607ed
commit e2f8fd812c
4 changed files with 176 additions and 117 deletions

View File

@ -43,9 +43,9 @@ the inherited Theme/default style available. Button has the deliberately small
set, not a second widget taxonomy.
Enabled buttons and checkboxes expose the same interaction affordances: a hand
pointer, `mouse-face` hover feedback, a readable `help-echo`, a numeric tab
stop, and a semantic role. Button presses briefly enter a `pressed` visual
state. Disabled controls retain their text and disabled appearance but expose
pointer, native `mouse-face` hover feedback, a readable `help-echo`, a numeric
tab stop, and a semantic role. Hover ends when the pointer leaves; activation
does not create retained visual state. Disabled controls retain their text and disabled appearance but expose
no callback, pointer activation, or tab stop. The Runtime owns composition of
an explicit `:on-*` callback with a Behavior callback; the UI package only
declares the control and its visual state.
@ -69,9 +69,12 @@ have no callback or tab stop. `:row-key` remains a required non-nil stable
scalar identity for retained rows. DataGrid retains one action closure per
row key, so selection or data Range updates do not recreate handlers for
unchanged rows.
An integer column `:width` is readable character capacity, not a raw pixel
value. DataGrid passes Ebox's native character unit through and reserves one
native character between non-final tracks.
`etaf-pagination` is a controlled Data Component. It accepts a Data controller
plus stable `:previous-ref` and `:next-ref` values, renders readable ``/``
plus stable `:previous-ref` and `:next-ref` values, renders readable `←`/`→`
controls and a `Page N / M` summary, and disables the boundary action while a
page is loading or already at the first/last page. It owns no page state and
uses the same Button interaction contract.

View File

@ -38,9 +38,10 @@ Component 的 `:styles` 声明负责默认外观。调用者提供的非 nil pre
只提供刻意收敛的 `primary`、`secondary`、`ghost` 三种视觉 variant这是
视觉状态集合,不是第二套 widget 分类体系。
启用的 button 和 checkbox 共享同一套交互反馈:小手指针、`mouse-face` 悬停
反馈、可读的 `help-echo`、数字 tab stop 和语义 role。Button 按下时短暂进入
`pressed` 外观。禁用控件保留文字和禁用样式,但没有回调、指针激活或 tab stop。
启用的 button 和 checkbox 共享同一套交互反馈:小手指针、原生 `mouse-face`
悬停反馈、可读的 `help-echo`、数字 tab stop 和语义 role。指针离开后 hover
自动结束,点击不会产生 retained 视觉状态。禁用控件保留文字和禁用样式,但
没有回调、指针激活或 tab stop。
显式 `:on-*` 回调和 Behavior 回调的组合由 Runtime 负责UI 包只声明控件和
视觉状态。
@ -59,9 +60,11 @@ DataGrid 支持列描述、函数型 `:row-key`,以及可选的
行没有回调或 tab stop。`:row-key` 仍然是 retained row 必须具备的非 nil
稳定标量 identity。DataGrid 按 row key 保留唯一 action closure因此 selection
或 Data Range 更新不会为未变化的行重建 handler。
整数列 `:width` 表示可读字符容量不是裸像素DataGrid 原样使用 Ebox 原生字符单位,
并在非末列之间保留一个原生字符间距。
`etaf-pagination` 是受控 Data Component。它接收 Data controller 以及稳定的
`:previous-ref`、`:next-ref`,显示易读的 ``/`` 控件和 `Page N / M` 摘要;
`:previous-ref`、`:next-ref`,显示易读的 `←`/`→` 控件和 `Page N / M` 摘要;
加载中或已经位于首/末页时会禁用对应动作。它不拥有页码状态,并复用 Button
的交互契约。

View File

@ -27,6 +27,7 @@
(declare-function text "etaf-view" (&rest arguments))
(declare-function row "etaf-view" (&rest arguments))
(declare-function column "etaf-view" (&rest arguments))
(declare-function flex "etaf-view" (&rest arguments))
(declare-function expr "etaf-view" (&rest arguments))
(declare-function slot "etaf-view" (&rest arguments))
@ -57,7 +58,7 @@ the optional text properties and keep the semantic role/event contract."
(defun etaf-ui--button-view
(label on-press disabled ref class color bgcolor border padding face
tab-index aria-label use pressed)
tab-index aria-label use)
"Return a Button Host showing LABEL.
ON-PRESS and USE provide callbacks and Behaviors. DISABLED controls whether
@ -65,9 +66,7 @@ the Host is interactive. REF, CLASS, COLOR, BGCOLOR, BORDER, PADDING, FACE,
TAB-INDEX, and ARIA-LABEL provide Host identity and presentation."
(let ((class-value (etaf-ui--class-value
"etaf-button"
(cond (disabled "disabled")
(pressed "pressed")
(t "enabled"))
(if disabled "disabled" "enabled")
class))
(tab-value (unless disabled (or tab-index 0)))
(label-value (or aria-label label))
@ -106,49 +105,64 @@ TAB-INDEX, and ARIA-LABEL provide Host identity and presentation."
((listp row) (alist-get key row))
(t nil)))
(defun etaf-ui--grid-fit-text (value column)
"Return VALUE fitted to COLUMN's declared character capacity."
(let* ((value (format "%s" (or value "")))
(width (etaf-ui--column-value column :width)))
(if (and (integerp width) (> width 0)
(> (string-width value) width))
(truncate-string-to-width value width 0 nil "")
value)))
(defun etaf-ui--grid-display-value (row column)
"Return one single-line display value for ROW and COLUMN.
DataGrid columns are tabular tracks, not prose paragraphs. Keep each cell on
one visual line and use an ellipsis when a fixed character-width descriptor is
too small; the original ROW remains intact for selection and callbacks."
(let* ((value (format "%s"
(or (etaf-ui--grid-cell-value
row (etaf-ui--column-value column :key)) "")))
(width (etaf-ui--column-value column :width)))
(if (and (integerp width) (> width 1)
(> (string-width value) width))
;; Leave one character of the declared track for inter-column air;
;; Ebox's left justification fills that remainder without changing
;; the stable column geometry.
(truncate-string-to-width value (1- width) 0 nil "")
value)))
(etaf-ui--grid-fit-text
(etaf-ui--grid-cell-value row (etaf-ui--column-value column :key))
column))
(defun etaf-ui--grid-header-cell (column)
"Return one header View for COLUMN."
(defun etaf-ui--grid-track-width (column gap-p)
"Return COLUMN width with one native-character gap when GAP-P is non-nil."
(let ((width (etaf-ui--column-value column :width)))
(if (and gap-p (integerp width) (> width 0))
(1+ width)
width)))
(defun etaf-ui--grid-header-cell (column gap-p)
"Return one header View for COLUMN, adding air when GAP-P is non-nil."
(etaf-view
(text :class "etaf-data-grid-header-cell"
:width (etaf-ui--column-value column :width)
(expr :value (format "%s"
(or (etaf-ui--column-value column :label)
(etaf-ui--column-value column :key)))))))
:width (etaf-ui--grid-track-width column gap-p)
(expr :value
(etaf-ui--grid-fit-text
(or (etaf-ui--column-value column :label)
(etaf-ui--column-value column :key))
column)))))
(defun etaf-ui--grid-header (columns)
"Return a View header row for COLUMNS."
(etaf-view
(row
:class "etaf-data-grid-header"
(expr :value (mapcar #'etaf-ui--grid-header-cell columns)))))
(row :class "etaf-data-grid-header"
(expr :value
(cl-loop for column in columns
for tail on columns
collect (etaf-ui--grid-header-cell
column (cdr tail)))))))
(defun etaf-ui--grid-cell (row column)
"Return one data cell View for ROW and COLUMN."
(defun etaf-ui--grid-cell (row column gap-p)
"Return one data cell View for ROW and COLUMN, using GAP-P for air."
(etaf-view
(text :width (etaf-ui--column-value column :width)
(text :width (etaf-ui--grid-track-width column gap-p)
(expr :value (etaf-ui--grid-display-value row column)))))
(defun etaf-ui--grid-cells (row columns)
"Return data cell Views for ROW and COLUMNS."
(mapcar (lambda (column) (etaf-ui--grid-cell row column)) columns))
(cl-loop for column in columns
for tail on columns
collect (etaf-ui--grid-cell row column (cdr tail))))
(defun etaf-ui--grid-selected-p (row key selected-key row-selected-p)
"Return whether ROW with KEY matches SELECTED-KEY or ROW-SELECTED-P."
@ -192,10 +206,9 @@ owns stable keyed callbacks across Range reevaluation."
(unless host-ref
(error "ETAF DataGrid :row-ref must return a non-nil stable ref")))
(etaf-view
(row
:key key
:class (concat "etaf-data-grid-row"
(if selected-p " selected" ""))
(row :key key
:class (concat "etaf-data-grid-row"
(if selected-p " selected" ""))
:ref host-ref
:role (when interactive-p 'button)
:tab-index (when interactive-p 0)
@ -230,31 +243,14 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
(defun etaf-ui--button-setup ()
"Create the retained renderer for one Button instance."
(let* ((pressed (etaf-ref nil))
(timer nil)
(scope (etaf-current-effect-scope))
(current-callback nil)
(let* ((current-callback nil)
(current-press-p nil)
(press nil))
(setq press
(lambda ()
(when current-press-p
(when (timerp timer)
(cancel-timer timer))
(setf (etaf-value pressed) t)
(unwind-protect
(when current-callback
(funcall current-callback))
(setq timer
(run-at-time
0.09 nil
(lambda ()
(when (etaf-effect-scope-active-p scope)
(setf (etaf-value pressed) nil)))))))))
(etaf-on-unmounted
(lambda ()
(when (timerp timer)
(cancel-timer timer))))
(when current-callback
(funcall current-callback)))))
(lambda ()
(let* ((label (etaf-current-prop :label))
(callback (etaf-current-prop :on-press))
@ -263,41 +259,27 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
(press-p (and (not disabled) (or callback use))))
(setq current-callback callback
current-press-p press-p)
(let* ((state-pressed (and (not disabled) (etaf-value pressed)))
(state-class (cond (disabled "disabled")
(state-pressed "pressed")
(t "enabled")))
(variant (and (not disabled) (etaf-current-prop :variant)))
(let* ((variant (and (not disabled) (etaf-current-prop :variant)))
(variant-values
(cond
(disabled
'(:color "#687386" :bgcolor "#E5E7EB"
:border ((1) solid "#9CA3AF") :face normal))
((and (eq variant 'secondary) state-pressed)
'(:color "#142235" :bgcolor "#B9DED7"
:border ((1) solid "#24736C") :face bold))
((eq variant 'secondary)
'(:color "#142235" :bgcolor "#D9EEEA"
:border ((1) solid "#2E8B83") :face bold))
((and (eq variant 'ghost) state-pressed)
'(:color "#142235" :bgcolor "#EEEAE2"
:border ((1) solid "#A79F93") :face normal))
((eq variant 'ghost)
'(:color "#142235" :bgcolor "#FFFDF8"
:border ((1) solid "#C8C1B6") :face normal))
;; The enabled/pressed catalog defaults used to come from
;; state selectors alone. Resolve them as props too so an
;; The enabled catalog defaults used to come from state
;; selectors alone. Resolve them as props too so an
;; explicit theme can override the same state boundary.
(state-pressed
'(:color "#FFFFFF" :bgcolor "#1E5A56"
:border ((1) solid "#174A47") :face bold))
(t
'(:color "#FFFFFF" :bgcolor "#2F6B43"
:border ((1) solid "#2F6B43") :face bold)))))
(etaf-ui--button-view
label (and press-p press) disabled (etaf-current-prop :ref)
(let ((custom-class (etaf-current-prop :class)))
(delq nil (list custom-class state-class)))
(etaf-current-prop :class)
(or (etaf-current-prop :color)
(plist-get variant-values :color))
(or (etaf-current-prop :bgcolor)
@ -309,7 +291,7 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
(plist-get variant-values :face))
(etaf-current-prop :tab-index)
(etaf-current-prop :aria-label)
use state-pressed))))))
use))))))
;;;###autoload
(etaf-define-component etaf-button
@ -327,8 +309,7 @@ contract; callers can still override presentation with the ordinary props."
;; above remain authoritative, so a themed disabled Button cannot inherit
;; the catalog's light default surface.
("&.disabled" :padding (0 1) :face normal)
("&.enabled" :padding (0 1) :face bold)
("&.pressed" :padding (0 1) :face bold))
("&.enabled" :padding (0 1) :face bold))
:setup
(etaf-ui--button-setup))
@ -505,7 +486,7 @@ a stable Host reference."
The pager owns no data state: page, page-size, total, loading, and error stay
with CONTROLLER. PREVIOUS-REF and NEXT-REF should be stable public refs when
the pager participates in keyboard/mouse interaction. The visible glyphs
(`' and `') are paired with labels and help text so the compact control is
(`' and `') are paired with labels and help text so the compact control is
readable in both GUI and text review."
:styles
(styles
@ -538,39 +519,47 @@ readable in both GUI and text review."
(arrow-border '((0) solid "transparent")))
(setq current-controller controller-value)
(etaf-view
(row :class (etaf-ui--class-value "etaf-pagination" nil
(etaf-current-prop :class))
:role 'navigation
:aria-label (or (etaf-current-prop :aria-label) "Pagination")
:color parent-color
:bgcolor parent-bgcolor
:border (etaf-current-prop :border)
:padding (or (etaf-current-prop :padding) '(0 1))
:gap '(0 (2))
(button :label "" :ref (etaf-current-prop :previous-ref)
:aria-label "Previous page"
:disabled previous-disabled
:padding '(0 0)
:border arrow-border
:color (if previous-disabled "#9CA3AF" parent-color)
:bgcolor parent-bgcolor
:face 'bold
:on-press (unless previous-disabled previous))
(flex :class (etaf-ui--class-value "etaf-pagination" nil
(etaf-current-prop :class))
:width 'stretch
:flex-direction 'row
:align-items 'center
:role 'navigation
:aria-label (or (etaf-current-prop :aria-label) "Pagination")
:color parent-color
:bgcolor parent-bgcolor
:border (etaf-current-prop :border)
:box-sizing 'border-box
:padding (or (etaf-current-prop :padding) '(0 1))
:gap '(0 (1))
(column :width 'max-content
:flex-grow 0 :flex-shrink 0 :flex-basis 'auto
(button :label "" :ref (etaf-current-prop :previous-ref)
:aria-label "Previous page"
:disabled previous-disabled
:padding '(0 0)
:border arrow-border
:color (if previous-disabled "#9CA3AF" parent-color)
:bgcolor parent-bgcolor
:face 'bold
:on-press (unless previous-disabled previous)))
(column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0
(text :class "etaf-pagination-label" :text-align 'center
(expr :value (format "Page %d / %d" page pages)))
(text :class "etaf-pagination-summary" :text-align 'center
(expr :value (format "%d%d of %d"
first-item last-item total))))
(button :label "" :ref (etaf-current-prop :next-ref)
:aria-label "Next page"
:disabled next-disabled
:padding '(0 0)
:border arrow-border
:color (if next-disabled "#9CA3AF" parent-color)
:bgcolor parent-bgcolor
:face 'bold
:on-press (unless next-disabled next))))))))
(column :width 'max-content
:flex-grow 0 :flex-shrink 0 :flex-basis 'auto
(button :label "" :ref (etaf-current-prop :next-ref)
:aria-label "Next page"
:disabled next-disabled
:padding '(0 0)
:border arrow-border
:color (if next-disabled "#9CA3AF" parent-color)
:bgcolor parent-bgcolor
:face 'bold
:on-press (unless next-disabled next)))))))))
(provide 'etaf-ui)

View File

@ -210,8 +210,8 @@
(when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer))))))
(ert-deftest etaf-ui-button-owns-pointer-hover-and-pressed-feedback ()
"Buttons expose native pointer/hover affordances and a pressed state."
(ert-deftest etaf-ui-button-owns-native-hover-and-dispatch ()
"Buttons expose native hover affordances without retained press state."
(let ((buffer-name " *etaf-ui-button-surface-test*")
(presses 0))
(unwind-protect
@ -237,9 +237,13 @@
'health 'press)
(should (= presses 1))
(should (string-match-p
"pressed"
"enabled"
(plist-get (etaf-ui-test--props buffer-name 'health)
:class)))
(should-not (string-match-p
"pressed"
(plist-get (etaf-ui-test--props buffer-name 'health)
:class)))
(should (eq before
(cdr (assq 'press
(etaf-runtime-handler-for runtime 'health)))))))
@ -381,8 +385,10 @@
(should (equal (plist-get first :tab-index) 0))
(should (equal (plist-get second :role) 'button))
(should (equal (plist-get second :tab-index) 0))
(should (string-match-p "selected" (plist-get second :class)))
(should-not (string-match-p "selected" (plist-get first :class))))
(should (string-match-p
"selected" (or (plist-get second :class) "")))
(should-not (string-match-p
"selected" (or (plist-get first :class) ""))))
(let* ((runtime (etaf-runtime-for-buffer buffer-name))
(handler (cdr (assq 'press
(etaf-runtime-handler-for runtime
@ -419,8 +425,8 @@
:previous-ref 'page-previous
:next-ref 'page-next)))
(should (string-match-p "Page 1 / 3" (etaf-ui-test--text buffer-name)))
(should (string-match-p "" (etaf-ui-test--text buffer-name)))
(should (string-match-p "" (etaf-ui-test--text buffer-name)))
(should (string-match-p "" (etaf-ui-test--text buffer-name)))
(should (string-match-p "" (etaf-ui-test--text buffer-name)))
;; Live Ebox windows reserve two pixels for the exclusive display
;; boundary; the pager must stay inside the corresponding 360px row.
(ebox-surface-update-buffer-viewport (get-buffer buffer-name) 358 20)
@ -451,6 +457,40 @@
(when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer)))))
(ert-deftest etaf-ui-pagination-composes-inside-a-grid-track ()
"Pagination must distribute free space inside its assigned Grid track."
(let* ((source (etaf-data-memory-source
'((:id 1) (:id 2) (:id 3) (:id 4) (:id 5))
:id-key :id))
(controller (etaf-data-controller source :page-size 2 :auto-load t))
(buffer-name " *etaf-ui-pagination-grid-test*"))
(unwind-protect
(progn
(etaf-mount
buffer-name
(etaf-view
(grid :width '(360)
:grid-template-columns '((80) (200) (80))
(text "Left")
(column :width 'stretch :padding '(0 2) :border "#CBD5E1"
(pagination :controller controller
:previous-ref 'nested-page-previous
:next-ref 'nested-page-next))
(text "Right"))))
(with-current-buffer buffer-name
(goto-char (point-min))
(while (< (point) (point-max))
(should (<= (ebox-string-pixel-width
(buffer-substring (line-beginning-position)
(line-end-position)))
360))
(forward-line 1))))
(when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
(etaf-unmount runtime))
(etaf-data-stop controller)
(when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer)))))
(ert-deftest etaf-ui-data-grid-keeps-long-cells-on-one-line ()
"Long tabular values use an ellipsis instead of increasing row height."
(let* ((source (etaf-data-memory-source
@ -468,7 +508,7 @@
:columns '((:key :title :label "Title" :width 22))
:row-key (lambda (row) (plist-get row :id)))))
(let ((text (etaf-ui-test--text buffer-name)))
(should (string-match-p "The Cathedral and th" text))
(should (string-match-p "The Cathedral and the" text))
(should-not (string-match-p "The Cathedral and the Bazaar" text))))
(when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
(etaf-unmount runtime))
@ -476,6 +516,30 @@
(when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer)))))
(ert-deftest etaf-ui-data-grid-converts-character-columns-and-keeps-air ()
"Character column descriptors become pixel widths with a stable gap."
(let* ((source (etaf-data-memory-source
'((:id 1 :progress 64 :kind "Essay")) :id-key :id))
(controller (etaf-data-controller source :auto-load t))
(buffer-name " *etaf-ui-grid-column-unit-test*"))
(unwind-protect
(progn
(etaf-mount
buffer-name
(etaf-view
(data-grid
:controller controller
:columns '((:key :progress :label "Progress" :width 8)
(:key :kind :label "Kind" :width 7))
:row-key (lambda (row) (plist-get row :id)))))
(should (string-match-p "Progress[[:space:]]+Kind"
(etaf-ui-test--text buffer-name))))
(when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
(etaf-unmount runtime))
(etaf-data-stop controller)
(when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer)))))
(ert-deftest etaf-ui-data-grid-noninteractive-rows-have-no-focus-contract ()
"Rows without ON-ROW-PRESS have no role, ref callback, or tab stop."
(let* ((source (etaf-data-memory-source '((:id 1 :name "Ada"))