refactor: compose UI primitives with canonical Views

This commit is contained in:
Kinneyzhang 2026-08-27 12:14:51 +08:00
parent afa1b251e0
commit c590ed08eb
2 changed files with 106 additions and 121 deletions

View File

@ -174,19 +174,6 @@ plist or a legacy caller-owned string remains untouched for compatibility."
(etaf-value value)
value))
(defun etaf-ui--interactive-surface-properties (label disabled)
"Return shared text properties for an interactive LABEL surface.
DISABLED selects a non-pointer help description instead of an activation
affordance.
The properties are deliberately backend-neutral: Ebox turns them into the
native Emacs pointer/hover/help affordances while other renderers may ignore
the optional text properties and keep the semantic role/event contract."
(if disabled
(list 'help-echo (format "%s (disabled)" (or label "Control")))
(list 'pointer 'hand
'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)
@ -205,10 +192,10 @@ the optional text properties and keep the semantic role/event contract."
(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))))
:font-weight (if (or disabled (eq variant 'ghost)) 'normal 'bold))))
(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 font-weight
tab-index aria-label use)
"Return a Button Host showing LABEL.
@ -220,24 +207,22 @@ TAB-INDEX, and ARIA-LABEL provide Host identity and presentation."
(if disabled "disabled" "enabled")
class))
(tab-value (unless disabled (or tab-index 0)))
(label-value (or aria-label label))
(surface (etaf-ui--interactive-surface-properties label disabled)))
(label-value (or aria-label label)))
(if on-press
(etaf-view
(text :class class-value :role 'button :ref ref :disabled disabled
(box :class class-value :role 'button :ref ref :disabled disabled
:tab-index tab-value :aria-label label-value
:color color :bgcolor bgcolor :border border
:padding padding :face face
:surface-properties surface
:padding padding :font-weight font-weight
:use (unless disabled use) :on-press on-press
(expr :value label)))
(text (expr :value label))))
(etaf-view
(text :class class-value :role 'button :ref ref :disabled disabled
(box :class class-value :role 'button :ref ref :disabled disabled
:tab-index tab-value :aria-label label-value
:color color :bgcolor bgcolor :border border
:padding padding :face face
:surface-properties surface
:use (unless disabled use) (expr :value label))))))
:padding padding :font-weight font-weight
:use (unless disabled use)
(text (expr :value label)))))))
(defun etaf-ui--checkbox-variant-values (theme disabled)
"Return semantic Theme values from THEME for DISABLED Checkbox state."
@ -296,19 +281,23 @@ too small; the original ROW remains intact for selection and callbacks."
(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--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)))))
(etaf--view-call
'box
(list :class "etaf-data-grid-header-cell"
:width (etaf-ui--grid-track-width column gap-p))
(list
(etaf--view-call
'text nil
(list
(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 theme)
"Return a View header row for COLUMNS using semantic THEME colors."
(etaf-view
(box :layout '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
@ -319,9 +308,12 @@ too small; the original ROW remains intact for selection and callbacks."
(defun etaf-ui--grid-cell (row column gap-p host-ref)
"Return one data cell View for ROW and COLUMN using HOST-REF and GAP-P."
(etaf-view
(text :ref host-ref :width (etaf-ui--grid-track-width column gap-p)
(expr :value (etaf-ui--grid-display-value row column)))))
(etaf--view-call
'box
(list :ref host-ref :width (etaf-ui--grid-track-width column gap-p))
(list
(etaf--view-call
'text nil (list (etaf-ui--grid-display-value row column))))))
(defun etaf-ui--grid-cells (row columns cell-refs)
"Return data cell Views for ROW and COLUMNS using stable CELL-REFS."
@ -382,9 +374,8 @@ ROW-ACTIONS owns stable keyed callbacks across body reevaluation."
(unless host-ref
(error "ETAF DataGrid :row-ref must return a non-nil stable ref")))
(etaf--view-call
'box
(list :layout 'row
:key key
'row
(list :key key
:class
(etaf--expr-create
:thunk (lambda ()
@ -530,15 +521,15 @@ customize state messages. The parent Component directly owns the result."
(or (etaf-current-prop :border)
(plist-get variant-values :border))
(etaf-current-prop :padding)
(or (etaf-current-prop :face)
(plist-get variant-values :face))
(or (etaf-current-prop :font-weight)
(plist-get variant-values :font-weight))
(etaf-current-prop :tab-index)
(etaf-current-prop :aria-label)
use))))))
;;;###autoload
(etaf-define-component etaf-button
(&key label on-press disabled ref class color bgcolor border padding face
(&key label on-press disabled ref class color bgcolor border padding font-weight
tab-index aria-label use variant)
"Render a standard pressable button with LABEL and ON-PRESS.
@ -551,8 +542,8 @@ contract; callers can still override presentation with the ordinary props."
;; State classes carry semantic state only. Resolved presentation 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))
("&.disabled" :padding (0 1) :font-weight normal)
("&.enabled" :padding (0 1) :font-weight bold))
:setup
(etaf-ui--button-setup))
@ -594,28 +585,28 @@ validation; the caller owns the value and subsequent state write."
(defun etaf-ui--checkbox-view
(checked label on-change ref disabled class color bgcolor border padding
face tab-index aria-label)
font-weight tab-index aria-label)
"Return LABEL checkbox View with captured CHECKED and ON-PRESS.
REF, DISABLED, CLASS, COLOR, BGCOLOR, BORDER, PADDING, FACE, TAB-INDEX, and
ARIA-LABEL provide its semantic and presentation properties."
(etaf-view
(box :layout 'row
(row
:class (etaf-ui--class-value
"etaf-checkbox" (if disabled "disabled" "enabled") class)
:role 'checkbox :ref ref :disabled disabled
:aria-label (or aria-label label)
:tab-index (unless disabled (or tab-index 0))
:color color :bgcolor bgcolor :border border :padding padding :face face
:surface-properties (etaf-ui--interactive-surface-properties label disabled)
:color color :bgcolor bgcolor :border border :padding padding :font-weight font-weight
:on-press on-change
(text :class "etaf-checkbox-mark"
(expr :value (if (etaf-ui--reactive-value checked) "" "")))
(box :class "etaf-checkbox-mark"
(text (expr :value
(if (etaf-ui--reactive-value checked) "" ""))))
(text (expr :value (if label (concat " " label) ""))))))
;;;###autoload
(etaf-define-component etaf-checkbox
(&key checked label on-change ref disabled class color bgcolor border padding
face tab-index aria-label)
font-weight tab-index aria-label)
"Render a controlled checkbox with CHECKED, LABEL, and ON-CHANGE.
CHECKED may be a boolean or an ETAF reactive source. ON-CHANGE receives the
@ -628,7 +619,7 @@ works with local refs or Data-backed forms."
;; Theme map in :setup; styles keep only geometry defaults.
("&.disabled" :padding (0 1))
("&.enabled" :padding (0 1))
(".etaf-checkbox-mark" :face bold :width 1))
(".etaf-checkbox-mark" :font-weight bold :width 1))
:setup
(let* ((current-checked nil)
(current-callback nil)
@ -664,21 +655,27 @@ works with local refs or Data-backed forms."
(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 :padding) (etaf-current-prop :font-weight)
(etaf-current-prop :tab-index) (etaf-current-prop :aria-label))))))
;;;###autoload
(etaf-define-component etaf-label
(&key text face class color bgcolor border padding ref width)
(&key text font-weight class color bgcolor border padding ref width)
"Render TEXT as a semantic label with presentation properties.
TEXT may be an ordinary value or an ETAF reactive source."
:view
(expr
:value
(etaf-view
(text :class class :face face :color color :bgcolor bgcolor :border border
:padding padding :ref ref :width width
(expr :value (etaf-ui--reactive-value text))))))
(if (or border padding width)
(etaf-view
(box :class class :font-weight font-weight :color color
:bgcolor bgcolor :border border :padding padding
:ref ref :width width
(text (expr :value (etaf-ui--reactive-value text)))))
(etaf-view
(text :class class :font-weight font-weight :color color
:bgcolor bgcolor :ref ref
(expr :value (etaf-ui--reactive-value text)))))))
(defun etaf-ui--panel-view (title class color bgcolor border padding ref)
"Render a themed Panel View.
@ -686,7 +683,7 @@ Arguments are TITLE, CLASS, COLOR, BGCOLOR, BORDER, PADDING, and REF."
(let ((theme (etaf-ui-theme-tokens :ui-panel-fg :ui-panel-bg
:ui-panel-border)))
(etaf-view
(box :layout 'column
(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))
@ -709,7 +706,7 @@ Arguments are TITLE, CLASS, COLOR, BGCOLOR, BORDER, PADDING, and REF."
:styles
(styles
("&" :padding (1 2))
(".etaf-panel-title" :face bold))
(".etaf-panel-title" :font-weight bold))
:view
(expr :value
(etaf-ui--panel-view title class color bgcolor border padding ref)))
@ -728,8 +725,8 @@ 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))
(".etaf-data-grid-header-cell" :face bold)
(".etaf-data-grid-header" :font-weight bold :padding (0 1))
(".etaf-data-grid-header-cell" :font-weight bold)
(".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.
@ -822,12 +819,12 @@ a stable Host reference."
:range-key #'car
:range-item body-range-item)))
(etaf--view-call
'box
(list :layout 'column :class "etaf-data-grid" :color theme-color)
'column
(list :class "etaf-data-grid" :color theme-color)
(list
(etaf-ui--grid-header columns theme)
(etaf--view-call
'box (list :layout 'column :class "etaf-data-grid-body")
'column (list :class "etaf-data-grid-body")
(list body-expr))
(etaf--slot-projection-create
:name 'footer :token 'etaf-ui-data-grid-footer :fallback nil)))))))
@ -846,7 +843,7 @@ readable in both GUI and text review."
:styles
(styles
("&" :width stretch)
(".etaf-pagination-label" :face bold))
(".etaf-pagination-label" :font-weight bold))
:setup
(let* ((current-controller nil)
(current-parent-color nil)
@ -905,7 +902,7 @@ readable in both GUI and text review."
current-parent-color parent-color
current-parent-bgcolor parent-bgcolor)
(etaf-view
(box :layout 'flex
(flex
:class (etaf-ui--class-value "etaf-pagination" nil
(etaf-current-prop :class))
:width 'stretch
@ -919,7 +916,7 @@ readable in both GUI and text review."
:box-sizing 'border-box
:padding (or (etaf-current-prop :padding) '(0 1))
:gap '(0 (1))
(box :layout 'column :width 'max-content
(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"
@ -930,29 +927,33 @@ readable in both GUI and text review."
(plist-get theme :ui-disabled-fg)
current-parent-color)
:bgcolor current-parent-bgcolor
:face 'bold
:font-weight 'bold
:on-press previous))
(box :layout 'column
(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"
(funcall page-value)
(funcall pages-value))))
(text :class "etaf-pagination-summary" :text-align 'center
:color (plist-get theme :ui-pagination-muted-fg)
(expr :value
(let* ((page (funcall page-value))
(page-size (funcall page-size-value))
(total (funcall total-value))
(first-item
(if (zerop total)
0
(1+ (* (1- page) page-size))))
(last-item (min total (* page page-size))))
(format "%d%d of %d"
first-item last-item total)))))
(box :layout 'column :width 'max-content
(box :class "etaf-pagination-label" :text-align 'center
:wrap-mode 'none :min-width 'max-content
(text
(expr :value
(format "Page %d / %d"
(funcall page-value)
(funcall pages-value)))))
(box :class "etaf-pagination-summary" :text-align 'center
:color (plist-get theme :ui-pagination-muted-fg)
:wrap-mode 'none :min-width 'max-content
(text
(expr :value
(let* ((page (funcall page-value))
(page-size (funcall page-size-value))
(total (funcall total-value))
(first-item
(if (zerop total)
0
(1+ (* (1- page) page-size))))
(last-item (min total (* page page-size))))
(format "%d%d of %d"
first-item last-item total))))))
(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"
@ -963,7 +964,7 @@ readable in both GUI and text review."
(plist-get theme :ui-disabled-fg)
current-parent-color)
:bgcolor current-parent-bgcolor
:face 'bold
:font-weight 'bold
:on-press next))))))))
(provide 'etaf-ui)

View File

@ -34,15 +34,6 @@
(etaf-runtime-host-props (etaf-runtime-for-buffer buffer-name)))
found))
(defun etaf-ui-test--surface-properties (buffer-name ref)
"Return interactive text properties at REF's first rendered position."
(let* ((runtime (etaf-runtime-for-buffer buffer-name))
(position (etaf-host-ref-position runtime ref)))
(with-current-buffer buffer-name
(list (get-text-property position 'pointer)
(get-text-property position 'mouse-face)
(get-text-property position 'help-echo)))))
(defun etaf-ui-test--paint-color (value property)
"Return effective paint color from VALUE for Ebox PROPERTY."
(if (not (tp-paint-slot-p value))
@ -76,7 +67,7 @@
:padding (9 9)))
(lambda ()
(etaf-view
(box :layout 'row
(row
(button :label "Styled" :ref 'styled-button)
(button :label "Custom" :ref 'custom-button
:color "explicit-color")
@ -97,7 +88,7 @@
:ui-button-secondary-border "secondary-border"))
(lambda ()
(etaf-view
(box :layout 'row
(row
(button :label "Token" :ref 'token-button)
(button :label "Secondary" :ref 'token-secondary
:variant 'secondary))))))
@ -153,7 +144,7 @@
(should (equal (plist-get custom :bgcolor) "#2F6B43"))
(should (equal (plist-get themed :color) "theme-color"))
(should (equal (plist-get themed :bgcolor) "theme-bg"))
(should (equal (plist-get themed :padding) '(9 9)))
(should-not (plist-get themed :padding))
(should (equal (plist-get panel :color) "#252A2E"))
(should (equal (plist-get panel :bgcolor) "#FFFDF8"))
(should (equal (plist-get panel :padding) '(1 2)))))
@ -219,7 +210,7 @@
:bgcolor "#2F6B43"
:border "#2F6B43"
:padding '(0 2)
:face 'bold
:font-weight 'bold
:tab-index 3
:aria-label "Save changes"
:on-press (lambda () (cl-incf presses)))))
@ -233,7 +224,7 @@
(should (equal (plist-get props :bgcolor) "#2F6B43"))
(should (equal (plist-get props :border) "#2F6B43"))
(should (equal (plist-get props :padding) '(0 2)))
(should (equal (plist-get props :face) 'bold))
(should (equal (plist-get props :font-weight) 'bold))
(should (string-match-p "primary" (plist-get props :class))))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'save 'press)
@ -252,7 +243,7 @@
(progn
(etaf-mount buffer-name
(etaf-view
(box :layout 'row
(row
(button :label "Save" :ref 'enabled-save
:on-press (lambda () (cl-incf presses)))
(button :label "Delete" :ref 'disabled-delete
@ -263,7 +254,7 @@
(should (equal (plist-get props :color) "#FFFFFF"))
(should (equal (plist-get props :bgcolor) "#2F6B43"))
(should (equal (plist-get props :padding) '(0 1)))
(should (equal (plist-get props :face) 'bold)))
(should (equal (plist-get props :font-weight) 'bold)))
(let ((props (etaf-ui-test--props buffer-name 'disabled-delete)))
(should (eq (plist-get props :disabled) t))
(should-not (plist-get props :tab-index))
@ -309,8 +300,8 @@
(when-let* ((buffer (get-buffer buffer-name)))
(kill-buffer buffer))))))
(ert-deftest etaf-ui-button-owns-native-hover-and-dispatch ()
"Buttons expose native hover affordances without retained press state."
(ert-deftest etaf-ui-button-dispatches-without-retained-press-state ()
"Buttons dispatch through semantic Host state without local press state."
(let ((buffer-name " *etaf-ui-button-surface-test*")
(presses 0))
(unwind-protect
@ -321,10 +312,6 @@
(button :label "Run health check" :ref 'health
:variant 'secondary
:on-press (lambda () (cl-incf presses)))))
(let ((surface (etaf-ui-test--surface-properties buffer-name 'health)))
(should (eq (nth 0 surface) 'hand))
(should (eq (nth 1 surface) 'highlight))
(should (string-match-p "RET" (nth 2 surface))))
(let ((props (etaf-ui-test--props buffer-name 'health)))
(should (equal (plist-get props :color) "#142235"))
(should (equal (plist-get props :bgcolor) "#D9EEEA")))
@ -446,7 +433,7 @@
(progn
(etaf-mount buffer-name
(etaf-view
(box :layout 'row
(row
(checkbox :label "Open" :ref 'open-box
:on-change (lambda (_value)
(cl-incf changes)))
@ -487,7 +474,7 @@
(slot :name 'header
(label :text "Settings" :ref 'settings-label
:class "eyebrow" :color "#66706A"
:face 'bold :width 12))
:font-weight 'bold :width 12))
(label :text "Body"))))
(let ((panel (etaf-ui-test--props buffer-name 'account-panel))
(label (etaf-ui-test--props buffer-name 'settings-label)))
@ -498,7 +485,7 @@
(should (equal (plist-get panel :padding) '(1 2)))
(should (string-match-p "eyebrow" (plist-get label :class)))
(should (equal (plist-get label :color) "#66706A"))
(should (equal (plist-get label :face) 'bold))
(should (equal (plist-get label :font-weight) 'bold))
(should (equal (plist-get label :width) 12)))
(dolist (label '("Account" "Settings" "Body"))
(should (string-match-p (regexp-quote label)
@ -728,9 +715,6 @@
(line-end-position)))
360))
(forward-line 1)))
(should (eq (nth 0 (etaf-ui-test--surface-properties
buffer-name 'page-next))
'hand))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'page-next 'press)
(should (string-match-p "Page 2 / 3" (etaf-ui-test--text buffer-name)))
@ -759,10 +743,10 @@
(etaf-mount
buffer-name
(etaf-view
(box :layout 'grid :width '(360)
(grid :width '(360)
:grid-template-columns '((80) (200) (80))
(text "Left")
(box :layout 'column
(column
:width 'stretch :padding '(0 2) :border "#CBD5E1"
(pagination :controller controller
:previous-ref 'nested-page-previous