diff --git a/README.md b/README.md index 6235b6a..0291b91 100644 --- a/README.md +++ b/README.md @@ -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 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 presentation prop supplied by the caller wins; an omitted or nil prop leaves the inherited Theme/default style available. Button has the deliberately small diff --git a/README.zh-CN.md b/README.zh-CN.md index 8e21074..b1fe370 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -33,6 +33,19 @@ Button 和 Checkbox 都是受控组件。`etaf-button` 支持 `:label`、 `checkbox` role、稳定的 `:ref` 和 `tab-index 0`;`:on-change` 接收下一个 布尔值。禁用 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 属性会覆盖默认值;省略或传 nil 时,继承的 Theme/默认样式仍可生效。Button 只提供刻意收敛的 `primary`、`secondary`、`ghost` 三种视觉 variant;这是 diff --git a/etaf-ui.el b/etaf-ui.el index 34c34ec..1ef4c5a 100644 --- a/etaf-ui.el +++ b/etaf-ui.el @@ -24,6 +24,7 @@ (declare-function etaf-data-total "etaf-data" (controller)) (declare-function etaf-data-previous-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 row "etaf-view" (&rest arguments)) (declare-function column "etaf-view" (&rest arguments)) @@ -31,6 +32,110 @@ (declare-function expr "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) "Return BASE and STATE classes with optional CUSTOM classes." (let ((custom (cond @@ -56,6 +161,26 @@ the optional text properties and keep the semantic role/event contract." 'mouse-face 'highlight '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 (label on-press disabled ref class color bgcolor border padding face tab-index aria-label use) @@ -88,6 +213,18 @@ TAB-INDEX, and ARIA-LABEL provide Host identity and presentation." :surface-properties surface :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) "Return KEY from COLUMN, accepting a plist or alist descriptor." (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)) column))))) -(defun etaf-ui--grid-header (columns) - "Return a View header row for COLUMNS." +(defun etaf-ui--grid-header (columns theme) + "Return a View header row for COLUMNS using semantic THEME colors." (etaf-view (row :class "etaf-data-grid-header" + :border (etaf-ui--theme-border + (plist-get theme :ui-grid-border)) (expr :value (cl-loop for column in 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 (row columns row-key row-ref on-row-press selected-key row-selected-p - row-actions) - "Return a View row for ROW and COLUMNS with the DataGrid contract. + row-actions theme) + "Return a View row for ROW and COLUMNS using THEME and the DataGrid contract. ROW-KEY returns identity; ROW-REF returns the interactive reference; 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 :role (when interactive-p 'button) :tab-index (when interactive-p 0) - :surface-properties - (when interactive-p - (etaf-ui--interactive-surface-properties - (format "Row %s" key) nil)) + :border-bottom-p t + :border-bottom-color (plist-get theme :ui-grid-border) :on-press (when interactive-p (etaf-ui--grid-row-action 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)))))) (defun etaf-ui--grid-rows (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. COLUMNS and ROW-KEY describe cells and identity. ROW-REF, ON-ROW-PRESS, 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 (mapcar (lambda (item) (puthash (funcall row-key item) t seen) (etaf-ui--grid-row item columns row-key row-ref on-row-press selected-key - row-selected-p row-actions)) + row-selected-p row-actions theme)) items) (maphash (lambda (key _entry) (unless (gethash key seen) @@ -261,22 +406,7 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state." current-press-p press-p) (let* ((variant (and (not disabled) (etaf-current-prop :variant))) (variant-values - (cond - (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-variant-values variant disabled))) (etaf-ui--button-view label (and press-p press) disabled (etaf-current-prop :ref) (etaf-current-prop :class) @@ -313,6 +443,42 @@ contract; callers can still override presentation with the ordinary props." :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 (checked label on-change ref disabled class color bgcolor border padding face tab-index aria-label) @@ -343,10 +509,10 @@ caller, so the Component works with local refs or Data-backed forms." :styles (styles ("&" :width max-content) - ("&.disabled" :color "#6B7280" :bgcolor "#EEEAE2" - :border ((1) solid "#9CA3AF") :padding (0 1)) - ("&.enabled" :color "#252A2E" :bgcolor "#DCEBDD" - :border ((1) solid "#6D8A73") :padding (0 1)) + ;; Color, background, and border are resolved through the shared semantic + ;; Theme map in :setup; styles keep only geometry defaults. + ("&.disabled" :padding (0 1)) + ("&.enabled" :padding (0 1)) (".etaf-checkbox-mark" :face bold :width 1)) :setup (let* ((current-checked nil) @@ -356,19 +522,34 @@ caller, so the Component works with local refs or Data-backed forms." (when current-callback (funcall current-callback (not current-checked)))))) (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 (when (and (not (etaf-current-prop :disabled)) (etaf-current-prop :on-change)) (etaf-current-prop :on-change))) - (etaf-ui--checkbox-view - current-checked (etaf-current-prop :label) - (and current-callback press) - (etaf-current-prop :ref) (etaf-current-prop :disabled) - (etaf-current-prop :class) (etaf-current-prop :color) - (etaf-current-prop :bgcolor) (etaf-current-prop :border) - (etaf-current-prop :padding) (etaf-current-prop :face) - (etaf-current-prop :tab-index) (etaf-current-prop :aria-label))))) + (etaf-ui--checkbox-view + current-checked (etaf-current-prop :label) + (and current-callback press) + (etaf-current-prop :ref) (etaf-current-prop :disabled) + (etaf-current-prop :class) + (or (etaf-current-prop :color) + (plist-get variant-values :color)) + (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 (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 :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 (etaf-define-component etaf-panel (&key title class color bgcolor border padding ref) "Render a titled panel with header and default slot projections." :styles (styles - ("&" :padding (1 2) :border ((1) solid "#687386") - :color "#252A2E" :bgcolor "#FFFDF8") + ("&" :padding (1 2)) (".etaf-panel-title" :face bold)) :view - (expr - :value - (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))))) + (expr :value + (etaf-ui--panel-view title class color bgcolor border padding ref))) ;;;###autoload (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 a stable Host reference." :styles - (styles - (".etaf-data-grid-header" :face bold :padding (0 1) - :border ((1) solid "#687386")) + (styles + (".etaf-data-grid-header" :face bold :padding (0 1)) (".etaf-data-grid-header-cell" :face bold) - (".etaf-data-grid-row" :padding (0 1) - :border ((1) solid "#687386")) - (".selected" :color "#2F6B43" - :border ((1) solid "#73A982")) - (".etaf-data-grid-error" :color "#FF6B6B")) + (".etaf-data-grid-row" :padding (0 1)) + ;; Selection color and error color are dynamic semantic props below, so + ;; this Component style scope contains geometry only. + ) :setup (let ((row-actions (make-hash-table :test #'equal))) (lambda () - (let ((controller (etaf-current-prop :controller)) - (columns (etaf-current-prop :columns)) - (row-key (etaf-current-prop :row-key)) - (row-ref (etaf-current-prop :row-ref)) - (on-row-press (etaf-current-prop :on-row-press)) - (selected-key (etaf-current-prop :selected-key)) - (row-selected-p (etaf-current-prop :row-selected-p)) - (loading-label (etaf-current-prop :loading-label)) - (error-label (etaf-current-prop :error-label)) - (empty-label (etaf-current-prop :empty-label))) + (let* ((controller (etaf-current-prop :controller)) + (columns (etaf-current-prop :columns)) + (row-key (etaf-current-prop :row-key)) + (row-ref (etaf-current-prop :row-ref)) + (on-row-press (etaf-current-prop :on-row-press)) + (selected-key (etaf-current-prop :selected-key)) + (row-selected-p (etaf-current-prop :row-selected-p)) + (loading-label (etaf-current-prop :loading-label)) + (error-label (etaf-current-prop :error-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 (column - :class "etaf-data-grid" - (expr :value (etaf-ui--grid-header columns)) + :class "etaf-data-grid" :color theme-color + (expr :value (etaf-ui--grid-header + columns theme)) (column :class "etaf-data-grid-body" (expr @@ -466,12 +668,18 @@ a stable Host reference." ((eq status 'error) (etaf-view (text :class "etaf-data-grid-error" + :color (plist-get (etaf-ui-theme-values + :ui-grid-error-fg) + :ui-grid-error-fg) (expr :value (or error-label "Unable to load data."))))) ((null items) (etaf-view (text (expr :value (or empty-label "No data."))))) (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 items columns row-key row-ref on-row-press selected-key row-selected-p row-actions)))))) @@ -491,8 +699,7 @@ readable in both GUI and text review." :styles (styles ("&" :width stretch) - (".etaf-pagination-label" :face bold) - (".etaf-pagination-summary" :color "#526174")) + (".etaf-pagination-label" :face bold)) :setup (let* ((current-controller nil) (previous @@ -514,8 +721,13 @@ readable in both GUI and text review." (last-item (min total (* page page-size))) (previous-disabled (or (eq status 'loading) (<= page 1))) (next-disabled (or (eq status 'loading) (>= page pages))) - (parent-color (etaf-current-prop :color)) - (parent-bgcolor (etaf-current-prop :bgcolor)) + (theme (etaf-ui-theme-values :ui-fg :ui-bg + :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"))) (setq current-controller controller-value) (etaf-view @@ -539,7 +751,9 @@ readable in both GUI and text review." :disabled previous-disabled :padding '(0 0) :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 :face 'bold :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 (expr :value (format "Page %d / %d" page pages))) (text :class "etaf-pagination-summary" :text-align 'center + :color (plist-get theme :ui-pagination-muted-fg) (expr :value (format "%d–%d of %d" first-item last-item total)))) (column :width 'max-content @@ -556,7 +771,9 @@ readable in both GUI and text review." :disabled next-disabled :padding '(0 0) :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 :face 'bold :on-press (unless next-disabled next))))))))) diff --git a/tests/etaf-ui-tests.el b/tests/etaf-ui-tests.el index 9ef8cfb..fc50e37 100644 --- a/tests/etaf-ui-tests.el +++ b/tests/etaf-ui-tests.el @@ -19,7 +19,17 @@ (let (found) (maphash (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))) (etaf-runtime-host-props (etaf-runtime-for-buffer buffer-name))) found)) @@ -60,6 +70,38 @@ :color nil :bgcolor nil) (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 () "Install Button `:use' Behavior and dispatch its merged callback." (let ((buffer-name " *etaf-ui-button-use-test*")) @@ -106,6 +148,49 @@ (when-let ((buffer (get-buffer buffer-name))) (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 () "Render an enabled button with semantic and presentation properties." (let ((buffer-name " *etaf-ui-button-test*") @@ -409,6 +494,48 @@ (when-let ((buffer (get-buffer buffer-name))) (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 () "Pagination renders Unicode controls, stable refs, and page boundaries." (let* ((source (etaf-data-memory-source