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. set, not a second widget taxonomy.
Enabled buttons and checkboxes expose the same interaction affordances: a hand Enabled buttons and checkboxes expose the same interaction affordances: a hand
pointer, `mouse-face` hover feedback, a readable `help-echo`, a numeric tab pointer, native `mouse-face` hover feedback, a readable `help-echo`, a numeric
stop, and a semantic role. Button presses briefly enter a `pressed` visual tab stop, and a semantic role. Hover ends when the pointer leaves; activation
state. Disabled controls retain their text and disabled appearance but expose 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 no callback, pointer activation, or tab stop. The Runtime owns composition of
an explicit `:on-*` callback with a Behavior callback; the UI package only an explicit `:on-*` callback with a Behavior callback; the UI package only
declares the control and its visual state. 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 scalar identity for retained rows. DataGrid retains one action closure per
row key, so selection or data Range updates do not recreate handlers for row key, so selection or data Range updates do not recreate handlers for
unchanged rows. 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 `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 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 page is loading or already at the first/last page. It owns no page state and
uses the same Button interaction contract. uses the same Button interaction contract.

View File

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

View File

@ -27,6 +27,7 @@
(declare-function text "etaf-view" (&rest arguments)) (declare-function text "etaf-view" (&rest arguments))
(declare-function row "etaf-view" (&rest arguments)) (declare-function row "etaf-view" (&rest arguments))
(declare-function column "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 expr "etaf-view" (&rest arguments))
(declare-function slot "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 (defun etaf-ui--button-view
(label on-press disabled ref class color bgcolor border padding face (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. "Return a Button Host showing LABEL.
ON-PRESS and USE provide callbacks and Behaviors. DISABLED controls whether 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." TAB-INDEX, and ARIA-LABEL provide Host identity and presentation."
(let ((class-value (etaf-ui--class-value (let ((class-value (etaf-ui--class-value
"etaf-button" "etaf-button"
(cond (disabled "disabled") (if disabled "disabled" "enabled")
(pressed "pressed")
(t "enabled"))
class)) class))
(tab-value (unless disabled (or tab-index 0))) (tab-value (unless disabled (or tab-index 0)))
(label-value (or aria-label label)) (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)) ((listp row) (alist-get key row))
(t nil))) (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) (defun etaf-ui--grid-display-value (row column)
"Return one single-line display value for ROW and COLUMN. "Return one single-line display value for ROW and COLUMN.
DataGrid columns are tabular tracks, not prose paragraphs. Keep each cell on 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 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." too small; the original ROW remains intact for selection and callbacks."
(let* ((value (format "%s" (etaf-ui--grid-fit-text
(or (etaf-ui--grid-cell-value (etaf-ui--grid-cell-value row (etaf-ui--column-value column :key))
row (etaf-ui--column-value column :key)) ""))) column))
(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)))
(defun etaf-ui--grid-header-cell (column) (defun etaf-ui--grid-track-width (column gap-p)
"Return one header View for COLUMN." "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 (etaf-view
(text :class "etaf-data-grid-header-cell" (text :class "etaf-data-grid-header-cell"
:width (etaf-ui--column-value column :width) :width (etaf-ui--grid-track-width column gap-p)
(expr :value (format "%s" (expr :value
(etaf-ui--grid-fit-text
(or (etaf-ui--column-value column :label) (or (etaf-ui--column-value column :label)
(etaf-ui--column-value column :key))))))) (etaf-ui--column-value column :key))
column)))))
(defun etaf-ui--grid-header (columns) (defun etaf-ui--grid-header (columns)
"Return a View header row for COLUMNS." "Return a View header row for COLUMNS."
(etaf-view (etaf-view
(row (row :class "etaf-data-grid-header"
:class "etaf-data-grid-header" (expr :value
(expr :value (mapcar #'etaf-ui--grid-header-cell columns))))) (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) (defun etaf-ui--grid-cell (row column gap-p)
"Return one data cell View for ROW and COLUMN." "Return one data cell View for ROW and COLUMN, using GAP-P for air."
(etaf-view (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))))) (expr :value (etaf-ui--grid-display-value row column)))))
(defun etaf-ui--grid-cells (row columns) (defun etaf-ui--grid-cells (row columns)
"Return data cell Views for ROW and 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) (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." "Return whether ROW with KEY matches SELECTED-KEY or ROW-SELECTED-P."
@ -192,8 +206,7 @@ owns stable keyed callbacks across Range reevaluation."
(unless host-ref (unless host-ref
(error "ETAF DataGrid :row-ref must return a non-nil stable ref"))) (error "ETAF DataGrid :row-ref must return a non-nil stable ref")))
(etaf-view (etaf-view
(row (row :key key
:key key
:class (concat "etaf-data-grid-row" :class (concat "etaf-data-grid-row"
(if selected-p " selected" "")) (if selected-p " selected" ""))
:ref host-ref :ref host-ref
@ -230,31 +243,14 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
(defun etaf-ui--button-setup () (defun etaf-ui--button-setup ()
"Create the retained renderer for one Button instance." "Create the retained renderer for one Button instance."
(let* ((pressed (etaf-ref nil)) (let* ((current-callback nil)
(timer nil)
(scope (etaf-current-effect-scope))
(current-callback nil)
(current-press-p nil) (current-press-p nil)
(press nil)) (press nil))
(setq press (setq press
(lambda () (lambda ()
(when current-press-p (when current-press-p
(when (timerp timer)
(cancel-timer timer))
(setf (etaf-value pressed) t)
(unwind-protect
(when current-callback (when current-callback
(funcall 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))))
(lambda () (lambda ()
(let* ((label (etaf-current-prop :label)) (let* ((label (etaf-current-prop :label))
(callback (etaf-current-prop :on-press)) (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)))) (press-p (and (not disabled) (or callback use))))
(setq current-callback callback (setq current-callback callback
current-press-p press-p) current-press-p press-p)
(let* ((state-pressed (and (not disabled) (etaf-value pressed))) (let* ((variant (and (not disabled) (etaf-current-prop :variant)))
(state-class (cond (disabled "disabled")
(state-pressed "pressed")
(t "enabled")))
(variant (and (not disabled) (etaf-current-prop :variant)))
(variant-values (variant-values
(cond (cond
(disabled (disabled
'(:color "#687386" :bgcolor "#E5E7EB" '(:color "#687386" :bgcolor "#E5E7EB"
:border ((1) solid "#9CA3AF") :face normal)) :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) ((eq variant 'secondary)
'(:color "#142235" :bgcolor "#D9EEEA" '(:color "#142235" :bgcolor "#D9EEEA"
:border ((1) solid "#2E8B83") :face bold)) :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) ((eq variant 'ghost)
'(:color "#142235" :bgcolor "#FFFDF8" '(:color "#142235" :bgcolor "#FFFDF8"
:border ((1) solid "#C8C1B6") :face normal)) :border ((1) solid "#C8C1B6") :face normal))
;; The enabled/pressed catalog defaults used to come from ;; The enabled catalog defaults used to come from state
;; state selectors alone. Resolve them as props too so an ;; selectors alone. Resolve them as props too so an
;; explicit theme can override the same state boundary. ;; explicit theme can override the same state boundary.
(state-pressed
'(:color "#FFFFFF" :bgcolor "#1E5A56"
:border ((1) solid "#174A47") :face bold))
(t (t
'(:color "#FFFFFF" :bgcolor "#2F6B43" '(:color "#FFFFFF" :bgcolor "#2F6B43"
:border ((1) solid "#2F6B43") :face bold))))) :border ((1) solid "#2F6B43") :face bold)))))
(etaf-ui--button-view (etaf-ui--button-view
label (and press-p press) disabled (etaf-current-prop :ref) label (and press-p press) disabled (etaf-current-prop :ref)
(let ((custom-class (etaf-current-prop :class))) (etaf-current-prop :class)
(delq nil (list custom-class state-class)))
(or (etaf-current-prop :color) (or (etaf-current-prop :color)
(plist-get variant-values :color)) (plist-get variant-values :color))
(or (etaf-current-prop :bgcolor) (or (etaf-current-prop :bgcolor)
@ -309,7 +291,7 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
(plist-get variant-values :face)) (plist-get variant-values :face))
(etaf-current-prop :tab-index) (etaf-current-prop :tab-index)
(etaf-current-prop :aria-label) (etaf-current-prop :aria-label)
use state-pressed)))))) use))))))
;;;###autoload ;;;###autoload
(etaf-define-component etaf-button (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 ;; above remain authoritative, so a themed disabled Button cannot inherit
;; the catalog's light default surface. ;; the catalog's light default surface.
("&.disabled" :padding (0 1) :face normal) ("&.disabled" :padding (0 1) :face normal)
("&.enabled" :padding (0 1) :face bold) ("&.enabled" :padding (0 1) :face bold))
("&.pressed" :padding (0 1) :face bold))
:setup :setup
(etaf-ui--button-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 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 with CONTROLLER. PREVIOUS-REF and NEXT-REF should be stable public refs when
the pager participates in keyboard/mouse interaction. The visible glyphs 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." readable in both GUI and text review."
:styles :styles
(styles (styles
@ -538,16 +519,22 @@ readable in both GUI and text review."
(arrow-border '((0) solid "transparent"))) (arrow-border '((0) solid "transparent")))
(setq current-controller controller-value) (setq current-controller controller-value)
(etaf-view (etaf-view
(row :class (etaf-ui--class-value "etaf-pagination" nil (flex :class (etaf-ui--class-value "etaf-pagination" nil
(etaf-current-prop :class)) (etaf-current-prop :class))
:width 'stretch
:flex-direction 'row
:align-items 'center
:role 'navigation :role 'navigation
:aria-label (or (etaf-current-prop :aria-label) "Pagination") :aria-label (or (etaf-current-prop :aria-label) "Pagination")
:color parent-color :color parent-color
:bgcolor parent-bgcolor :bgcolor parent-bgcolor
:border (etaf-current-prop :border) :border (etaf-current-prop :border)
:box-sizing 'border-box
:padding (or (etaf-current-prop :padding) '(0 1)) :padding (or (etaf-current-prop :padding) '(0 1))
:gap '(0 (2)) :gap '(0 (1))
(button :label "" :ref (etaf-current-prop :previous-ref) (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" :aria-label "Previous page"
:disabled previous-disabled :disabled previous-disabled
:padding '(0 0) :padding '(0 0)
@ -555,14 +542,16 @@ readable in both GUI and text review."
:color (if previous-disabled "#9CA3AF" parent-color) :color (if previous-disabled "#9CA3AF" parent-color)
:bgcolor parent-bgcolor :bgcolor parent-bgcolor
:face 'bold :face 'bold
:on-press (unless previous-disabled previous)) :on-press (unless previous-disabled previous)))
(column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0 (column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0
(text :class "etaf-pagination-label" :text-align 'center (text :class "etaf-pagination-label" :text-align 'center
(expr :value (format "Page %d / %d" page pages))) (expr :value (format "Page %d / %d" page pages)))
(text :class "etaf-pagination-summary" :text-align 'center (text :class "etaf-pagination-summary" :text-align 'center
(expr :value (format "%d%d of %d" (expr :value (format "%d%d of %d"
first-item last-item total)))) first-item last-item total))))
(button :label "" :ref (etaf-current-prop :next-ref) (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" :aria-label "Next page"
:disabled next-disabled :disabled next-disabled
:padding '(0 0) :padding '(0 0)
@ -570,7 +559,7 @@ readable in both GUI and text review."
:color (if next-disabled "#9CA3AF" parent-color) :color (if next-disabled "#9CA3AF" parent-color)
:bgcolor parent-bgcolor :bgcolor parent-bgcolor
:face 'bold :face 'bold
:on-press (unless next-disabled next)))))))) :on-press (unless next-disabled next)))))))))
(provide 'etaf-ui) (provide 'etaf-ui)

View File

@ -210,8 +210,8 @@
(when-let ((buffer (get-buffer buffer-name))) (when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer)))))) (kill-buffer buffer))))))
(ert-deftest etaf-ui-button-owns-pointer-hover-and-pressed-feedback () (ert-deftest etaf-ui-button-owns-native-hover-and-dispatch ()
"Buttons expose native pointer/hover affordances and a pressed state." "Buttons expose native hover affordances without retained press state."
(let ((buffer-name " *etaf-ui-button-surface-test*") (let ((buffer-name " *etaf-ui-button-surface-test*")
(presses 0)) (presses 0))
(unwind-protect (unwind-protect
@ -237,6 +237,10 @@
'health 'press) 'health 'press)
(should (= presses 1)) (should (= presses 1))
(should (string-match-p (should (string-match-p
"enabled"
(plist-get (etaf-ui-test--props buffer-name 'health)
:class)))
(should-not (string-match-p
"pressed" "pressed"
(plist-get (etaf-ui-test--props buffer-name 'health) (plist-get (etaf-ui-test--props buffer-name 'health)
:class))) :class)))
@ -381,8 +385,10 @@
(should (equal (plist-get first :tab-index) 0)) (should (equal (plist-get first :tab-index) 0))
(should (equal (plist-get second :role) 'button)) (should (equal (plist-get second :role) 'button))
(should (equal (plist-get second :tab-index) 0)) (should (equal (plist-get second :tab-index) 0))
(should (string-match-p "selected" (plist-get second :class))) (should (string-match-p
(should-not (string-match-p "selected" (plist-get first :class)))) "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)) (let* ((runtime (etaf-runtime-for-buffer buffer-name))
(handler (cdr (assq 'press (handler (cdr (assq 'press
(etaf-runtime-handler-for runtime (etaf-runtime-handler-for runtime
@ -419,8 +425,8 @@
:previous-ref 'page-previous :previous-ref 'page-previous
:next-ref 'page-next))) :next-ref 'page-next)))
(should (string-match-p "Page 1 / 3" (etaf-ui-test--text buffer-name))) (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 ;; Live Ebox windows reserve two pixels for the exclusive display
;; boundary; the pager must stay inside the corresponding 360px row. ;; boundary; the pager must stay inside the corresponding 360px row.
(ebox-surface-update-buffer-viewport (get-buffer buffer-name) 358 20) (ebox-surface-update-buffer-viewport (get-buffer buffer-name) 358 20)
@ -451,6 +457,40 @@
(when-let ((buffer (get-buffer buffer-name))) (when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer))))) (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 () (ert-deftest etaf-ui-data-grid-keeps-long-cells-on-one-line ()
"Long tabular values use an ellipsis instead of increasing row height." "Long tabular values use an ellipsis instead of increasing row height."
(let* ((source (etaf-data-memory-source (let* ((source (etaf-data-memory-source
@ -468,7 +508,7 @@
:columns '((:key :title :label "Title" :width 22)) :columns '((:key :title :label "Title" :width 22))
:row-key (lambda (row) (plist-get row :id))))) :row-key (lambda (row) (plist-get row :id)))))
(let ((text (etaf-ui-test--text buffer-name))) (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)))) (should-not (string-match-p "The Cathedral and the Bazaar" text))))
(when-let ((runtime (etaf-runtime-for-buffer buffer-name))) (when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
(etaf-unmount runtime)) (etaf-unmount runtime))
@ -476,6 +516,30 @@
(when-let ((buffer (get-buffer buffer-name))) (when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer))))) (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 () (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." "Rows without ON-ROW-PRESS have no role, ref callback, or tab stop."
(let* ((source (etaf-data-memory-source '((:id 1 :name "Ada")) (let* ((source (etaf-data-memory-source '((:id 1 :name "Ada"))