Stabilize themed DataGrid and pagination UI

This commit is contained in:
Kinneyzhang 2026-08-24 02:08:53 +08:00
parent e2f8fd812c
commit 7ee8d65679
4 changed files with 455 additions and 82 deletions

View File

@ -36,6 +36,22 @@ role `checkbox`, a stable `:ref`, and tab index `0` while enabled. Its
`:on-change` receives the next boolean value; disabled checkboxes have no `:on-change` receives the next boolean value; disabled checkboxes have no
handler or tab stop. handler or tab stop.
`etaf-number-input` is a small controlled input primitive for values edited
through Emacs's native minibuffer. It accepts `:value`, `:label`, `:on-change`,
`:min`, `:max`, `:ref`, and `:disabled`; the callback receives the validated
integer while the caller remains the owner of the ref.
The catalog uses one semantic Theme vocabulary for color roles. The main
tokens are `:ui-fg`, `:ui-bg`, `:ui-border`, `:ui-muted-fg`,
`:ui-disabled-*`, `:ui-button-primary-*`, `:ui-button-secondary-*`,
`:ui-button-ghost-*`, `:ui-checkbox-*`, `:ui-grid-*`, and
`:ui-panel-*`. Applications provide these through ETAF's
`etaf-theme-provide`; legacy `:ui-button-color`-style aliases remain accepted
for compatibility. `etaf-ui` does not depend on TP or on a renderer-specific
palette registry. If an application wants TP's light/dark palette data, the
optional `etaf-theme-tp` adapter converts it into the same ETAF Theme plist
before the UI catalog sees it.
Component `:styles` declarations own default appearance. A non-nil Component `:styles` declarations own default appearance. A non-nil
presentation prop supplied by the caller wins; an omitted or nil prop leaves presentation prop supplied by the caller wins; an omitted or nil prop leaves
the inherited Theme/default style available. Button has the deliberately small the inherited Theme/default style available. Button has the deliberately small

View File

@ -33,6 +33,19 @@ Button 和 Checkbox 都是受控组件。`etaf-button` 支持 `:label`、
`checkbox` role、稳定的 `:ref``tab-index 0``:on-change` 接收下一个 `checkbox` role、稳定的 `:ref``tab-index 0``:on-change` 接收下一个
布尔值。禁用 checkbox 没有回调或 tab stop。 布尔值。禁用 checkbox 没有回调或 tab stop。
`etaf-number-input` 是一个受控的轻量输入组件;需要编辑数值时,它使用
Emacs 原生 minibuffer。它支持 `:value`、`:label`、`:on-change`、`:min`、
`:max`、`:ref` 和 `:disabled`回调接收校验后的整数ref 仍由调用方持有。
目录统一使用语义化的 Theme 色彩 vocabulary主要 token 包括
`:ui-fg`、`:ui-bg`、`:ui-border`、`:ui-muted-fg`、`:ui-disabled-*`、
`:ui-button-primary-*`、`:ui-button-secondary-*`、`:ui-button-ghost-*`、
`:ui-checkbox-*`、`:ui-grid-*` 和 `:ui-panel-*`。应用通过 ETAF 的
`etaf-theme-provide` 提供这些 token旧的 `:ui-button-color` 一类 alias
仍兼容。`etaf-ui` 不依赖 TP也不直接依赖 renderer-specific palette
registry如果应用想使用 TP 的亮/暗 palette 数据,应先通过可选的
`etaf-theme-tp` adapter 转成同一份 ETAF Theme plist再交给 UI 目录。
Component 的 `:styles` 声明负责默认外观。调用者提供的非 nil presentation Component 的 `:styles` 声明负责默认外观。调用者提供的非 nil presentation
属性会覆盖默认值;省略或传 nil 时,继承的 Theme/默认样式仍可生效。Button 属性会覆盖默认值;省略或传 nil 时,继承的 Theme/默认样式仍可生效。Button
只提供刻意收敛的 `primary`、`secondary`、`ghost` 三种视觉 variant这是 只提供刻意收敛的 `primary`、`secondary`、`ghost` 三种视觉 variant这是

View File

@ -24,6 +24,7 @@
(declare-function etaf-data-total "etaf-data" (controller)) (declare-function etaf-data-total "etaf-data" (controller))
(declare-function etaf-data-previous-page "etaf-data" (controller)) (declare-function etaf-data-previous-page "etaf-data" (controller))
(declare-function etaf-data-next-page "etaf-data" (controller)) (declare-function etaf-data-next-page "etaf-data" (controller))
(declare-function etaf-theme-defaults "etaf-context" (&optional default))
(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))
@ -31,6 +32,110 @@
(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))
(defconst etaf-ui--default-theme-palette
'(:ui-fg "#252A2E"
:ui-bg "#FFFDF8"
:ui-border "#687386"
:ui-muted-fg "#526174"
:ui-danger-fg "#FF6B6B"
:ui-success-fg "#2F6B43"
:ui-disabled-fg "#687386"
:ui-disabled-bg "#E5E7EB"
:ui-disabled-border "#9CA3AF"
:ui-button-primary-fg "#FFFFFF"
:ui-button-primary-bg "#2F6B43"
:ui-button-primary-border "#2F6B43"
:ui-button-secondary-fg "#142235"
:ui-button-secondary-bg "#D9EEEA"
:ui-button-secondary-border "#2E8B83"
:ui-button-ghost-fg "#142235"
:ui-button-ghost-bg "#FFFDF8"
:ui-button-ghost-border "#C8C1B6"
:ui-checkbox-enabled-fg "#252A2E"
:ui-checkbox-enabled-bg "#DCEBDD"
:ui-checkbox-enabled-border "#6D8A73"
:ui-checkbox-disabled-fg "#6B7280"
:ui-checkbox-disabled-bg "#EEEAE2"
:ui-checkbox-disabled-border "#9CA3AF"
:ui-grid-border "#687386"
:ui-grid-selected-fg "#2F6B43"
:ui-grid-selected-bg "#DCEBDD"
:ui-grid-error-fg "#FF6B6B"
:ui-pagination-muted-fg "#526174"
:ui-panel-fg "#252A2E"
:ui-panel-bg "#FFFDF8"
:ui-panel-border "#687386")
"Default semantic UI palette, centralized outside Component definitions.
Applications normally override these tokens through ETAF Theme. Keeping the
fallback palette here gives the catalog a useful standalone appearance while
ensuring every Component reads one shared semantic vocabulary.")
(defconst etaf-ui--legacy-theme-aliases
'((:ui-fg :color)
(:ui-bg :bgcolor)
(:ui-border :border)
(:ui-button-primary-fg :ui-button-color)
(:ui-button-primary-bg :ui-button-bgcolor)
(:ui-button-primary-border :ui-button-border)
(:ui-button-secondary-fg :ui-button-secondary-color)
(:ui-button-secondary-bg :ui-button-secondary-bgcolor)
(:ui-button-secondary-border :ui-button-secondary-border)
(:ui-button-ghost-fg :ui-button-ghost-color)
(:ui-button-ghost-bg :ui-button-ghost-bgcolor)
(:ui-button-ghost-border :ui-button-ghost-border)
(:ui-disabled-fg :ui-button-disabled-color)
(:ui-disabled-bg :ui-button-disabled-bgcolor)
(:ui-disabled-border :ui-button-disabled-border))
"Compatibility aliases for the first ETAF UI Theme token spelling.")
;;;###autoload
(defun etaf-ui-theme-values (&rest requested)
"Return merged semantic UI Theme values for REQUESTED tokens.
Inherited application tokens win, legacy aliases remain readable, and the
central catalog palette fills only omitted values. This is the boundary
between generic ETAF Theme Context and etaf-ui's product-independent visual
semantics; individual Components do not own separate color systems. When
REQUESTED is nil, return the complete catalog token map."
(let* ((inherited (etaf-theme-defaults))
(defaults etaf-ui--default-theme-palette)
;; Callers that request a subset only need that semantic subset.
;; Keeping the full inherited plist is useful for the no-argument
;; catalog query, but copying it for every Button/Panel/DataGrid
;; render needlessly scales Theme work with application token count.
(result (unless requested (copy-sequence inherited)))
(keys (or requested
(cl-loop for (key _spec) on defaults by #'cddr
collect key))))
(dolist (key keys)
(let ((found
(cond
((plist-member inherited key)
(cons t (plist-get inherited key)))
(t
(cl-loop for alias in etaf-ui--legacy-theme-aliases
when (and (eq (car alias) key)
(plist-member inherited (cadr alias)))
return
(cons t (plist-get inherited (cadr alias))))))))
(setq result
(plist-put result key
(if found (cdr found) (plist-get defaults key)))))
)
result))
(defun etaf-ui--theme-border (value)
"Return Ebox border VALUE, preserving complete caller-owned specs.
Semantic Theme border tokens conventionally contain a color string. The
catalog turns a hex color into its one-cell border shape; an existing border
plist or a legacy caller-owned string remains untouched for compatibility."
(if (and (stringp value)
(string-match-p "\\`#[[:xdigit:]]+\\'" value))
(list (list 1) 'solid value)
value))
(defun etaf-ui--class-value (base state custom) (defun etaf-ui--class-value (base state custom)
"Return BASE and STATE classes with optional CUSTOM classes." "Return BASE and STATE classes with optional CUSTOM classes."
(let ((custom (cond (let ((custom (cond
@ -56,6 +161,26 @@ the optional text properties and keep the semantic role/event contract."
'mouse-face 'highlight 'mouse-face 'highlight
'help-echo (format "%s · RET or mouse-1" (or label "Activate"))))) 'help-echo (format "%s · RET or mouse-1" (or label "Activate")))))
(defun etaf-ui--button-variant-values (variant disabled)
"Return themed presentation defaults for Button VARIANT and DISABLED."
(let* ((fg (cond (disabled :ui-disabled-fg)
((eq variant 'secondary) :ui-button-secondary-fg)
((eq variant 'ghost) :ui-button-ghost-fg)
(t :ui-button-primary-fg)))
(bg (cond (disabled :ui-disabled-bg)
((eq variant 'secondary) :ui-button-secondary-bg)
((eq variant 'ghost) :ui-button-ghost-bg)
(t :ui-button-primary-bg)))
(border (cond (disabled :ui-disabled-border)
((eq variant 'secondary) :ui-button-secondary-border)
((eq variant 'ghost) :ui-button-ghost-border)
(t :ui-button-primary-border)))
(theme (etaf-ui-theme-values fg bg border)))
(list :color (plist-get theme fg)
:bgcolor (plist-get theme bg)
:border (etaf-ui--theme-border (plist-get theme border))
:face (if (or disabled (eq variant 'ghost)) 'normal 'bold))))
(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) tab-index aria-label use)
@ -88,6 +213,18 @@ TAB-INDEX, and ARIA-LABEL provide Host identity and presentation."
:surface-properties surface :surface-properties surface
:use (unless disabled use) (expr :value label)))))) :use (unless disabled use) (expr :value label))))))
(defun etaf-ui--checkbox-variant-values (theme disabled)
"Return semantic Theme values from THEME for DISABLED Checkbox state."
(let ((prefix (if disabled "disabled" "enabled")))
(list :color (plist-get theme
(intern (format ":ui-checkbox-%s-fg" prefix)))
:bgcolor (plist-get theme
(intern (format ":ui-checkbox-%s-bg" prefix)))
:border
(etaf-ui--theme-border
(plist-get theme
(intern (format ":ui-checkbox-%s-border" prefix)))))))
(defun etaf-ui--column-value (column key) (defun etaf-ui--column-value (column key)
"Return KEY from COLUMN, accepting a plist or alist descriptor." "Return KEY from COLUMN, accepting a plist or alist descriptor."
(if (and (listp column) (keywordp (car column))) (if (and (listp column) (keywordp (car column)))
@ -142,10 +279,12 @@ too small; the original ROW remains intact for selection and callbacks."
(etaf-ui--column-value column :key)) (etaf-ui--column-value column :key))
column))))) column)))))
(defun etaf-ui--grid-header (columns) (defun etaf-ui--grid-header (columns theme)
"Return a View header row for COLUMNS." "Return a View header row for COLUMNS using semantic THEME colors."
(etaf-view (etaf-view
(row :class "etaf-data-grid-header" (row :class "etaf-data-grid-header"
:border (etaf-ui--theme-border
(plist-get theme :ui-grid-border))
(expr :value (expr :value
(cl-loop for column in columns (cl-loop for column in columns
for tail on columns for tail on columns
@ -186,8 +325,8 @@ too small; the original ROW remains intact for selection and callbacks."
(defun etaf-ui--grid-row (defun etaf-ui--grid-row
(row columns row-key row-ref on-row-press selected-key row-selected-p (row columns row-key row-ref on-row-press selected-key row-selected-p
row-actions) row-actions theme)
"Return a View row for ROW and COLUMNS with the DataGrid contract. "Return a View row for ROW and COLUMNS using THEME and the DataGrid contract.
ROW-KEY returns identity; ROW-REF returns the interactive reference; ROW-KEY returns identity; ROW-REF returns the interactive reference;
ON-ROW-PRESS, SELECTED-KEY, and ROW-SELECTED-P control state. ROW-ACTIONS ON-ROW-PRESS, SELECTED-KEY, and ROW-SELECTED-P control state. ROW-ACTIONS
@ -212,29 +351,35 @@ owns stable keyed callbacks across Range reevaluation."
:ref host-ref :ref host-ref
:role (when interactive-p 'button) :role (when interactive-p 'button)
:tab-index (when interactive-p 0) :tab-index (when interactive-p 0)
:surface-properties :border-bottom-p t
(when interactive-p :border-bottom-color (plist-get theme :ui-grid-border)
(etaf-ui--interactive-surface-properties
(format "Row %s" key) nil))
:on-press (when interactive-p :on-press (when interactive-p
(etaf-ui--grid-row-action (etaf-ui--grid-row-action
row-actions key row on-row-press)) row-actions key row on-row-press))
:bgcolor (when selected-p
(plist-get theme :ui-grid-selected-bg))
(expr :value (etaf-ui--grid-cells row columns)))))) (expr :value (etaf-ui--grid-cells row columns))))))
(defun etaf-ui--grid-rows (defun etaf-ui--grid-rows
(items columns row-key row-ref on-row-press selected-key row-selected-p (items columns row-key row-ref on-row-press selected-key row-selected-p
row-actions) row-actions &optional theme)
"Return keyed item Views and prune ROW-ACTIONS outside current ITEMS. "Return keyed item Views and prune ROW-ACTIONS outside current ITEMS.
COLUMNS and ROW-KEY describe cells and identity. ROW-REF, ON-ROW-PRESS, COLUMNS and ROW-KEY describe cells and identity. ROW-REF, ON-ROW-PRESS,
SELECTED-KEY, and ROW-SELECTED-P provide interaction state." SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
(let ((seen (make-hash-table :test #'equal))) (let* ((theme (or theme
;; A Range owns the data rows, so read Theme once here to
;; keep palette changes reactive without doing it per row.
(etaf-ui-theme-values :ui-grid-border
:ui-grid-selected-fg
:ui-grid-selected-bg)))
(seen (make-hash-table :test #'equal)))
(prog1 (prog1
(mapcar (mapcar
(lambda (item) (lambda (item)
(puthash (funcall row-key item) t seen) (puthash (funcall row-key item) t seen)
(etaf-ui--grid-row (etaf-ui--grid-row
item columns row-key row-ref on-row-press selected-key item columns row-key row-ref on-row-press selected-key
row-selected-p row-actions)) row-selected-p row-actions theme))
items) items)
(maphash (lambda (key _entry) (maphash (lambda (key _entry)
(unless (gethash key seen) (unless (gethash key seen)
@ -261,22 +406,7 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
current-press-p press-p) current-press-p press-p)
(let* ((variant (and (not disabled) (etaf-current-prop :variant))) (let* ((variant (and (not disabled) (etaf-current-prop :variant)))
(variant-values (variant-values
(cond (etaf-ui--button-variant-values variant disabled)))
(disabled
'(:color "#687386" :bgcolor "#E5E7EB"
:border ((1) solid "#9CA3AF") :face normal))
((eq variant 'secondary)
'(:color "#142235" :bgcolor "#D9EEEA"
:border ((1) solid "#2E8B83") :face bold))
((eq variant 'ghost)
'(:color "#142235" :bgcolor "#FFFDF8"
:border ((1) solid "#C8C1B6") :face normal))
;; 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.
(t
'(:color "#FFFFFF" :bgcolor "#2F6B43"
: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)
(etaf-current-prop :class) (etaf-current-prop :class)
@ -313,6 +443,42 @@ contract; callers can still override presentation with the ordinary props."
:setup :setup
(etaf-ui--button-setup)) (etaf-ui--button-setup))
(etaf-define-component etaf-number-input
(&key value label on-change ref disabled min max aria-label)
"Render a controlled minibuffer-backed numeric input.
VALUE is displayed as a Button. Activating it reads a number through
Emacs's native minibuffer, validates optional MIN and MAX bounds, and calls
ON-CHANGE with the accepted integer. The Component owns prompting and
validation; the caller owns the value and subsequent state write."
:setup
(let ((callback (etaf-current-prop :on-change)))
(lambda ()
(let* ((value (etaf-current-prop :value))
(label (or (etaf-current-prop :label) "Value"))
(min-value (etaf-current-prop :min))
(max-value (etaf-current-prop :max))
(disabled (etaf-current-prop :disabled))
(ref (etaf-current-prop :ref))
(aria-label (etaf-current-prop :aria-label)))
(etaf-view
(button
:label (format "%s %s ✎" label (or value ""))
:ref ref :disabled disabled
:aria-label (or aria-label label)
:variant 'ghost
:on-press
(unless disabled
(lambda ()
(let ((next (read-number
(format "%s: " label) (or value 0))))
(unless (and (integerp next)
(or (null min-value) (>= next min-value))
(or (null max-value) (<= next max-value)))
(user-error "%s must be an integer from %s to %s"
label (or min-value "") (or max-value "")))
(when callback (funcall callback next)))))))))))
(defun etaf-ui--checkbox-view (defun etaf-ui--checkbox-view
(checked label on-change ref disabled class color bgcolor border padding (checked label on-change ref disabled class color bgcolor border padding
face tab-index aria-label) face tab-index aria-label)
@ -343,10 +509,10 @@ caller, so the Component works with local refs or Data-backed forms."
:styles :styles
(styles (styles
("&" :width max-content) ("&" :width max-content)
("&.disabled" :color "#6B7280" :bgcolor "#EEEAE2" ;; Color, background, and border are resolved through the shared semantic
:border ((1) solid "#9CA3AF") :padding (0 1)) ;; Theme map in :setup; styles keep only geometry defaults.
("&.enabled" :color "#252A2E" :bgcolor "#DCEBDD" ("&.disabled" :padding (0 1))
:border ((1) solid "#6D8A73") :padding (0 1)) ("&.enabled" :padding (0 1))
(".etaf-checkbox-mark" :face bold :width 1)) (".etaf-checkbox-mark" :face bold :width 1))
:setup :setup
(let* ((current-checked nil) (let* ((current-checked nil)
@ -356,19 +522,34 @@ caller, so the Component works with local refs or Data-backed forms."
(when current-callback (when current-callback
(funcall current-callback (not current-checked)))))) (funcall current-callback (not current-checked))))))
(lambda () (lambda ()
(setq current-checked (etaf-current-prop :checked) (let* ((theme (etaf-ui-theme-values :ui-fg :ui-bg
:ui-checkbox-enabled-fg
:ui-checkbox-enabled-bg
:ui-checkbox-enabled-border
:ui-checkbox-disabled-fg
:ui-checkbox-disabled-bg
:ui-checkbox-disabled-border))
(variant-values
(etaf-ui--checkbox-variant-values
theme (etaf-current-prop :disabled))))
(setq current-checked (etaf-current-prop :checked)
current-callback current-callback
(when (and (not (etaf-current-prop :disabled)) (when (and (not (etaf-current-prop :disabled))
(etaf-current-prop :on-change)) (etaf-current-prop :on-change))
(etaf-current-prop :on-change))) (etaf-current-prop :on-change)))
(etaf-ui--checkbox-view (etaf-ui--checkbox-view
current-checked (etaf-current-prop :label) current-checked (etaf-current-prop :label)
(and current-callback press) (and current-callback press)
(etaf-current-prop :ref) (etaf-current-prop :disabled) (etaf-current-prop :ref) (etaf-current-prop :disabled)
(etaf-current-prop :class) (etaf-current-prop :color) (etaf-current-prop :class)
(etaf-current-prop :bgcolor) (etaf-current-prop :border) (or (etaf-current-prop :color)
(etaf-current-prop :padding) (etaf-current-prop :face) (plist-get variant-values :color))
(etaf-current-prop :tab-index) (etaf-current-prop :aria-label))))) (or (etaf-current-prop :bgcolor)
(plist-get variant-values :bgcolor))
(or (etaf-current-prop :border)
(plist-get variant-values :border))
(etaf-current-prop :padding) (etaf-current-prop :face)
(etaf-current-prop :tab-index) (etaf-current-prop :aria-label))))))
;;;###autoload ;;;###autoload
(etaf-define-component etaf-label (etaf-define-component etaf-label
@ -381,29 +562,39 @@ caller, so the Component works with local refs or Data-backed forms."
(text :class class :face face :color color :bgcolor bgcolor :border border (text :class class :face face :color color :bgcolor bgcolor :border border
:padding padding :ref ref :width width (expr :value text))))) :padding padding :ref ref :width width (expr :value text)))))
(defun etaf-ui--panel-view (title class color bgcolor border padding ref)
"Render a themed Panel View.
Arguments are TITLE, CLASS, COLOR, BGCOLOR, BORDER, PADDING, and REF."
(let ((theme (etaf-ui-theme-values :ui-panel-fg :ui-panel-bg
:ui-panel-border)))
(etaf-view
(column
:class (etaf-ui--class-value "etaf-panel" nil class)
:color (or color (plist-get theme :ui-panel-fg))
:bgcolor (or bgcolor (plist-get theme :ui-panel-bg))
:border (or border
(etaf-ui--theme-border
(plist-get theme :ui-panel-border)))
:padding padding :ref ref
(expr
:value
(when title
(etaf-view (text :class "etaf-panel-title"
(expr :value title)))))
(slot :name 'header)
(slot)))))
;;;###autoload ;;;###autoload
(etaf-define-component etaf-panel (etaf-define-component etaf-panel
(&key title class color bgcolor border padding ref) (&key title class color bgcolor border padding ref)
"Render a titled panel with header and default slot projections." "Render a titled panel with header and default slot projections."
:styles :styles
(styles (styles
("&" :padding (1 2) :border ((1) solid "#687386") ("&" :padding (1 2))
:color "#252A2E" :bgcolor "#FFFDF8")
(".etaf-panel-title" :face bold)) (".etaf-panel-title" :face bold))
:view :view
(expr (expr :value
:value (etaf-ui--panel-view title class color bgcolor border padding ref)))
(etaf-view
(column
:class (etaf-ui--class-value "etaf-panel" nil class)
:color color :bgcolor bgcolor :border border :padding padding :ref ref
(expr
:value
(when title
(etaf-view (text :class "etaf-panel-title"
(expr :value title)))))
(slot :name 'header)
(slot)))))
;;;###autoload ;;;###autoload
(etaf-define-component etaf-data-grid (etaf-define-component etaf-data-grid
@ -418,32 +609,43 @@ errors, pagination, mutation, and selection; this Component only projects
those values into ordinary Hosts. Interactive rows require ROW-REF to return those values into ordinary Hosts. Interactive rows require ROW-REF to return
a stable Host reference." a stable Host reference."
:styles :styles
(styles (styles
(".etaf-data-grid-header" :face bold :padding (0 1) (".etaf-data-grid-header" :face bold :padding (0 1))
:border ((1) solid "#687386"))
(".etaf-data-grid-header-cell" :face bold) (".etaf-data-grid-header-cell" :face bold)
(".etaf-data-grid-row" :padding (0 1) (".etaf-data-grid-row" :padding (0 1))
:border ((1) solid "#687386")) ;; Selection color and error color are dynamic semantic props below, so
(".selected" :color "#2F6B43" ;; this Component style scope contains geometry only.
:border ((1) solid "#73A982")) )
(".etaf-data-grid-error" :color "#FF6B6B"))
:setup :setup
(let ((row-actions (make-hash-table :test #'equal))) (let ((row-actions (make-hash-table :test #'equal)))
(lambda () (lambda ()
(let ((controller (etaf-current-prop :controller)) (let* ((controller (etaf-current-prop :controller))
(columns (etaf-current-prop :columns)) (columns (etaf-current-prop :columns))
(row-key (etaf-current-prop :row-key)) (row-key (etaf-current-prop :row-key))
(row-ref (etaf-current-prop :row-ref)) (row-ref (etaf-current-prop :row-ref))
(on-row-press (etaf-current-prop :on-row-press)) (on-row-press (etaf-current-prop :on-row-press))
(selected-key (etaf-current-prop :selected-key)) (selected-key (etaf-current-prop :selected-key))
(row-selected-p (etaf-current-prop :row-selected-p)) (row-selected-p (etaf-current-prop :row-selected-p))
(loading-label (etaf-current-prop :loading-label)) (loading-label (etaf-current-prop :loading-label))
(error-label (etaf-current-prop :error-label)) (error-label (etaf-current-prop :error-label))
(empty-label (etaf-current-prop :empty-label))) (empty-label (etaf-current-prop :empty-label))
;; Resolve the shared semantic UI Theme once at the DataGrid
;; boundary. Rows reuse this immutable snapshot instead of
;; allocating or resolving border values independently during
;; every retained Range update.
(raw-theme (etaf-ui-theme-values :ui-fg
:ui-grid-border
:ui-grid-selected-bg))
(theme (plist-put (copy-sequence raw-theme)
:ui-grid-border
(etaf-ui--theme-border
(plist-get raw-theme :ui-grid-border))))
(theme-color (plist-get theme :ui-fg)))
(etaf-view (etaf-view
(column (column
:class "etaf-data-grid" :class "etaf-data-grid" :color theme-color
(expr :value (etaf-ui--grid-header columns)) (expr :value (etaf-ui--grid-header
columns theme))
(column (column
:class "etaf-data-grid-body" :class "etaf-data-grid-body"
(expr (expr
@ -466,12 +668,18 @@ a stable Host reference."
((eq status 'error) ((eq status 'error)
(etaf-view (etaf-view
(text :class "etaf-data-grid-error" (text :class "etaf-data-grid-error"
:color (plist-get (etaf-ui-theme-values
:ui-grid-error-fg)
:ui-grid-error-fg)
(expr :value (expr :value
(or error-label "Unable to load data."))))) (or error-label "Unable to load data.")))))
((null items) ((null items)
(etaf-view (etaf-view
(text (expr :value (or empty-label "No data."))))) (text (expr :value (or empty-label "No data.")))))
(t (t
;; Read Theme inside this Range's evaluator so retained row
;; Hosts follow palette changes without requiring the parent
;; DataGrid Component to be rebuilt.
(etaf-ui--grid-rows (etaf-ui--grid-rows
items columns row-key row-ref on-row-press selected-key items columns row-key row-ref on-row-press selected-key
row-selected-p row-actions)))))) row-selected-p row-actions))))))
@ -491,8 +699,7 @@ readable in both GUI and text review."
:styles :styles
(styles (styles
("&" :width stretch) ("&" :width stretch)
(".etaf-pagination-label" :face bold) (".etaf-pagination-label" :face bold))
(".etaf-pagination-summary" :color "#526174"))
:setup :setup
(let* ((current-controller nil) (let* ((current-controller nil)
(previous (previous
@ -514,8 +721,13 @@ readable in both GUI and text review."
(last-item (min total (* page page-size))) (last-item (min total (* page page-size)))
(previous-disabled (or (eq status 'loading) (<= page 1))) (previous-disabled (or (eq status 'loading) (<= page 1)))
(next-disabled (or (eq status 'loading) (>= page pages))) (next-disabled (or (eq status 'loading) (>= page pages)))
(parent-color (etaf-current-prop :color)) (theme (etaf-ui-theme-values :ui-fg :ui-bg
(parent-bgcolor (etaf-current-prop :bgcolor)) :ui-disabled-fg
:ui-pagination-muted-fg))
(parent-color (or (etaf-current-prop :color)
(plist-get theme :ui-fg)))
(parent-bgcolor (or (etaf-current-prop :bgcolor)
(plist-get theme :ui-bg)))
(arrow-border '((0) solid "transparent"))) (arrow-border '((0) solid "transparent")))
(setq current-controller controller-value) (setq current-controller controller-value)
(etaf-view (etaf-view
@ -539,7 +751,9 @@ readable in both GUI and text review."
:disabled previous-disabled :disabled previous-disabled
:padding '(0 0) :padding '(0 0)
:border arrow-border :border arrow-border
:color (if previous-disabled "#9CA3AF" parent-color) :color (if previous-disabled
(plist-get theme :ui-disabled-fg)
parent-color)
:bgcolor parent-bgcolor :bgcolor parent-bgcolor
:face 'bold :face 'bold
:on-press (unless previous-disabled previous))) :on-press (unless previous-disabled previous)))
@ -547,6 +761,7 @@ readable in both GUI and text review."
(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
:color (plist-get theme :ui-pagination-muted-fg)
(expr :value (format "%d%d of %d" (expr :value (format "%d%d of %d"
first-item last-item total)))) first-item last-item total))))
(column :width 'max-content (column :width 'max-content
@ -556,7 +771,9 @@ readable in both GUI and text review."
:disabled next-disabled :disabled next-disabled
:padding '(0 0) :padding '(0 0)
:border arrow-border :border arrow-border
:color (if next-disabled "#9CA3AF" parent-color) :color (if next-disabled
(plist-get theme :ui-disabled-fg)
parent-color)
:bgcolor parent-bgcolor :bgcolor parent-bgcolor
:face 'bold :face 'bold
:on-press (unless next-disabled next))))))))) :on-press (unless next-disabled next)))))))))

View File

@ -19,7 +19,17 @@
(let (found) (let (found)
(maphash (maphash
(lambda (_ref props) (lambda (_ref props)
(when (equal (plist-get props :key) key) (when (equal (plist-get props :key) key)
(setq found props)))
(etaf-runtime-host-props (etaf-runtime-for-buffer buffer-name)))
found))
(defun etaf-ui-test--props-with-class (buffer-name class)
"Return mounted Host properties with CLASS in BUFFER-NAME."
(let (found)
(maphash
(lambda (_ref props)
(when (equal (plist-get props :class) class)
(setq found props))) (setq found props)))
(etaf-runtime-host-props (etaf-runtime-for-buffer buffer-name))) (etaf-runtime-host-props (etaf-runtime-for-buffer buffer-name)))
found)) found))
@ -60,6 +70,38 @@
:color nil :bgcolor nil) :color nil :bgcolor nil)
(panel :title "Styled panel" :ref 'styled-panel)))))) (panel :title "Styled panel" :ref 'styled-panel))))))
(etaf-define-component etaf-ui-test-token-theme-fixture ()
"Provide explicit UI Button tokens through ETAF Theme."
:setup
(progn
(etaf-theme-provide
'(:ui-button-color "token-fg"
:ui-button-bgcolor "token-bg"
:ui-button-border "token-border"
:ui-button-secondary-color "secondary-fg"
:ui-button-secondary-bgcolor "secondary-bg"
:ui-button-secondary-border "secondary-border"))
(lambda ()
(etaf-view
(row
(button :label "Token" :ref 'token-button)
(button :label "Secondary" :ref 'token-secondary
:variant 'secondary))))))
(etaf-define-component etaf-ui-test-grid-theme-fixture
(&key controller theme)
"Provide a reactive Theme around one DataGrid for palette tests."
:setup
(progn
(etaf-theme-provide (etaf-current-prop :theme))
(lambda ()
(etaf-view
(data-grid
:controller (etaf-current-prop :controller)
:columns '((:key :id :label "ID") (:key :name :label "Name"))
:row-key (lambda (row) (plist-get row :id))
:selected-key 1)))))
(ert-deftest etaf-ui-button-use-behavior-dispatches-through-host () (ert-deftest etaf-ui-button-use-behavior-dispatches-through-host ()
"Install Button `:use' Behavior and dispatch its merged callback." "Install Button `:use' Behavior and dispatch its merged callback."
(let ((buffer-name " *etaf-ui-button-use-test*")) (let ((buffer-name " *etaf-ui-button-use-test*"))
@ -106,6 +148,49 @@
(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-reads-variant-tokens-from-theme ()
"Resolve Button presentation defaults from inherited Theme tokens."
(let ((buffer-name " *etaf-ui-button-token-theme-test*"))
(unwind-protect
(progn
(etaf-mount buffer-name
(etaf-view (ui-test-token-theme-fixture)))
(let ((token (etaf-ui-test--props buffer-name 'token-button))
(secondary (etaf-ui-test--props buffer-name 'token-secondary)))
(should (equal "token-fg" (plist-get token :color)))
(should (equal "token-bg" (plist-get token :bgcolor)))
(should (equal "token-border" (plist-get token :border)))
(should (equal "secondary-fg" (plist-get secondary :color)))
(should (equal "secondary-bg" (plist-get secondary :bgcolor)))
(should (equal "secondary-border"
(plist-get secondary :border)))))
(when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
(etaf-unmount runtime))
(when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer)))))
(ert-deftest etaf-ui-number-input-validates-and-emits-value ()
"Prompt-backed number input owns bounds and emits the accepted value."
(let ((buffer-name " *etaf-ui-number-input-test*")
(value 3))
(unwind-protect
(progn
(etaf-mount
buffer-name
(etaf-view
(number-input :label "Rows" :value value :ref 'rows
:min 1 :max 10
:on-change (lambda (next) (setq value next)))))
(cl-letf (((symbol-function 'read-number)
(lambda (&rest _args) 7)))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'rows 'press))
(should (= 7 value)))
(when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
(etaf-unmount runtime))
(when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer)))))
(ert-deftest etaf-ui-button-dispatches-and-exposes-enabled-props () (ert-deftest etaf-ui-button-dispatches-and-exposes-enabled-props ()
"Render an enabled button with semantic and presentation properties." "Render an enabled button with semantic and presentation properties."
(let ((buffer-name " *etaf-ui-button-test*") (let ((buffer-name " *etaf-ui-button-test*")
@ -409,6 +494,48 @@
(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-follows-inherited-theme-color ()
"Re-render DataGrid rows when only inherited Theme color changes."
(let* ((theme (etaf-ref '(:color "light-ink"
:ui-grid-border "light-grid-border"
:ui-grid-selected-fg "light-selected"
:ui-grid-selected-bg "light-selected-bg")))
(source (etaf-data-memory-source
'((:id 1 :name "Ada") (:id 2 :name "Grace")) :id-key :id))
(controller (etaf-data-controller source :auto-load t))
(buffer-name " *etaf-ui-grid-theme-test*"))
(unwind-protect
(progn
(etaf-mount
buffer-name
(etaf-view
(ui-test-grid-theme-fixture :controller controller :theme theme)))
(should (equal "light-selected-bg"
(plist-get (etaf-ui-test--props-with-key buffer-name 1)
:bgcolor)))
(should (equal "light-grid-border"
(plist-get (etaf-ui-test--props-with-key
buffer-name 1)
:border-bottom-color)))
(should (plist-get (etaf-ui-test--props-with-key buffer-name 1)
:border-bottom-p))
(setf (etaf-value theme) '(:color "dark-ink"
:ui-grid-border "dark-grid-border"
:ui-grid-selected-fg "dark-selected"
:ui-grid-selected-bg "dark-selected-bg"))
(should (equal "dark-selected-bg"
(plist-get (etaf-ui-test--props-with-key buffer-name 1)
:bgcolor)))
(should (equal "dark-grid-border"
(plist-get (etaf-ui-test--props-with-key
buffer-name 1)
:border-bottom-color))))
(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-pagination-is-readable-and-boundary-safe () (ert-deftest etaf-ui-pagination-is-readable-and-boundary-safe ()
"Pagination renders Unicode controls, stable refs, and page boundaries." "Pagination renders Unicode controls, stable refs, and page boundaries."
(let* ((source (etaf-data-memory-source (let* ((source (etaf-data-memory-source