diff --git a/README.md b/README.md index ce9fed3..6235b6a 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ the inherited Theme/default style available. Button has the deliberately small set, not a second widget taxonomy. Enabled buttons and checkboxes expose the same interaction affordances: a hand -pointer, `mouse-face` hover feedback, a readable `help-echo`, a numeric tab -stop, and a semantic role. Button presses briefly enter a `pressed` visual -state. Disabled controls retain their text and disabled appearance but expose +pointer, native `mouse-face` hover feedback, a readable `help-echo`, a numeric +tab stop, and a semantic role. Hover ends when the pointer leaves; activation +does not create retained visual state. Disabled controls retain their text and disabled appearance but expose no callback, pointer activation, or tab stop. The Runtime owns composition of an explicit `:on-*` callback with a Behavior callback; the UI package only declares the control and its visual state. @@ -69,9 +69,12 @@ have no callback or tab stop. `:row-key` remains a required non-nil stable scalar identity for retained rows. DataGrid retains one action closure per row key, so selection or data Range updates do not recreate handlers for unchanged rows. +An integer column `:width` is readable character capacity, not a raw pixel +value. DataGrid passes Ebox's native character unit through and reserves one +native character between non-final tracks. `etaf-pagination` is a controlled Data Component. It accepts a Data controller -plus stable `:previous-ref` and `:next-ref` values, renders readable `‹`/`›` +plus stable `:previous-ref` and `:next-ref` values, renders readable `←`/`→` controls and a `Page N / M` summary, and disables the boundary action while a page is loading or already at the first/last page. It owns no page state and uses the same Button interaction contract. diff --git a/README.zh-CN.md b/README.zh-CN.md index 97324cd..8e21074 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,9 +38,10 @@ Component 的 `:styles` 声明负责默认外观。调用者提供的非 nil pre 只提供刻意收敛的 `primary`、`secondary`、`ghost` 三种视觉 variant;这是 视觉状态集合,不是第二套 widget 分类体系。 -启用的 button 和 checkbox 共享同一套交互反馈:小手指针、`mouse-face` 悬停 -反馈、可读的 `help-echo`、数字 tab stop 和语义 role。Button 按下时短暂进入 -`pressed` 外观。禁用控件保留文字和禁用样式,但没有回调、指针激活或 tab stop。 +启用的 button 和 checkbox 共享同一套交互反馈:小手指针、原生 `mouse-face` +悬停反馈、可读的 `help-echo`、数字 tab stop 和语义 role。指针离开后 hover +自动结束,点击不会产生 retained 视觉状态。禁用控件保留文字和禁用样式,但 +没有回调、指针激活或 tab stop。 显式 `:on-*` 回调和 Behavior 回调的组合由 Runtime 负责;UI 包只声明控件和 视觉状态。 @@ -59,9 +60,11 @@ DataGrid 支持列描述、函数型 `:row-key`,以及可选的 行没有回调或 tab stop。`:row-key` 仍然是 retained row 必须具备的非 nil 稳定标量 identity。DataGrid 按 row key 保留唯一 action closure,因此 selection 或 Data Range 更新不会为未变化的行重建 handler。 +整数列 `:width` 表示可读字符容量,不是裸像素;DataGrid 原样使用 Ebox 原生字符单位, +并在非末列之间保留一个原生字符间距。 `etaf-pagination` 是受控 Data Component。它接收 Data controller 以及稳定的 -`:previous-ref`、`:next-ref`,显示易读的 `‹`/`›` 控件和 `Page N / M` 摘要; +`:previous-ref`、`:next-ref`,显示易读的 `←`/`→` 控件和 `Page N / M` 摘要; 加载中或已经位于首/末页时会禁用对应动作。它不拥有页码状态,并复用 Button 的交互契约。 diff --git a/etaf-ui.el b/etaf-ui.el index a2ebf46..34c34ec 100644 --- a/etaf-ui.el +++ b/etaf-ui.el @@ -27,6 +27,7 @@ (declare-function text "etaf-view" (&rest arguments)) (declare-function row "etaf-view" (&rest arguments)) (declare-function column "etaf-view" (&rest arguments)) +(declare-function flex "etaf-view" (&rest arguments)) (declare-function expr "etaf-view" (&rest arguments)) (declare-function slot "etaf-view" (&rest arguments)) @@ -57,7 +58,7 @@ the optional text properties and keep the semantic role/event contract." (defun etaf-ui--button-view (label on-press disabled ref class color bgcolor border padding face - tab-index aria-label use pressed) + tab-index aria-label use) "Return a Button Host showing LABEL. ON-PRESS and USE provide callbacks and Behaviors. DISABLED controls whether @@ -65,9 +66,7 @@ the Host is interactive. REF, CLASS, COLOR, BGCOLOR, BORDER, PADDING, FACE, TAB-INDEX, and ARIA-LABEL provide Host identity and presentation." (let ((class-value (etaf-ui--class-value "etaf-button" - (cond (disabled "disabled") - (pressed "pressed") - (t "enabled")) + (if disabled "disabled" "enabled") class)) (tab-value (unless disabled (or tab-index 0))) (label-value (or aria-label label)) @@ -106,49 +105,64 @@ TAB-INDEX, and ARIA-LABEL provide Host identity and presentation." ((listp row) (alist-get key row)) (t nil))) +(defun etaf-ui--grid-fit-text (value column) + "Return VALUE fitted to COLUMN's declared character capacity." + (let* ((value (format "%s" (or value ""))) + (width (etaf-ui--column-value column :width))) + (if (and (integerp width) (> width 0) + (> (string-width value) width)) + (truncate-string-to-width value width 0 nil "…") + value))) + (defun etaf-ui--grid-display-value (row column) "Return one single-line display value for ROW and COLUMN. DataGrid columns are tabular tracks, not prose paragraphs. Keep each cell on one visual line and use an ellipsis when a fixed character-width descriptor is too small; the original ROW remains intact for selection and callbacks." - (let* ((value (format "%s" - (or (etaf-ui--grid-cell-value - row (etaf-ui--column-value column :key)) ""))) - (width (etaf-ui--column-value column :width))) - (if (and (integerp width) (> width 1) - (> (string-width value) width)) - ;; Leave one character of the declared track for inter-column air; - ;; Ebox's left justification fills that remainder without changing - ;; the stable column geometry. - (truncate-string-to-width value (1- width) 0 nil "…") - value))) + (etaf-ui--grid-fit-text + (etaf-ui--grid-cell-value row (etaf-ui--column-value column :key)) + column)) -(defun etaf-ui--grid-header-cell (column) - "Return one header View for COLUMN." +(defun etaf-ui--grid-track-width (column gap-p) + "Return COLUMN width with one native-character gap when GAP-P is non-nil." + (let ((width (etaf-ui--column-value column :width))) + (if (and gap-p (integerp width) (> width 0)) + (1+ width) + width))) + +(defun etaf-ui--grid-header-cell (column gap-p) + "Return one header View for COLUMN, adding air when GAP-P is non-nil." (etaf-view (text :class "etaf-data-grid-header-cell" - :width (etaf-ui--column-value column :width) - (expr :value (format "%s" - (or (etaf-ui--column-value column :label) - (etaf-ui--column-value column :key))))))) + :width (etaf-ui--grid-track-width column gap-p) + (expr :value + (etaf-ui--grid-fit-text + (or (etaf-ui--column-value column :label) + (etaf-ui--column-value column :key)) + column))))) (defun etaf-ui--grid-header (columns) "Return a View header row for COLUMNS." (etaf-view - (row - :class "etaf-data-grid-header" - (expr :value (mapcar #'etaf-ui--grid-header-cell columns))))) + (row :class "etaf-data-grid-header" + (expr :value + (cl-loop for column in columns + for tail on columns + collect (etaf-ui--grid-header-cell + column (cdr tail))))))) -(defun etaf-ui--grid-cell (row column) - "Return one data cell View for ROW and COLUMN." +(defun etaf-ui--grid-cell (row column gap-p) + "Return one data cell View for ROW and COLUMN, using GAP-P for air." (etaf-view - (text :width (etaf-ui--column-value column :width) + (text :width (etaf-ui--grid-track-width column gap-p) (expr :value (etaf-ui--grid-display-value row column))))) (defun etaf-ui--grid-cells (row columns) "Return data cell Views for ROW and COLUMNS." - (mapcar (lambda (column) (etaf-ui--grid-cell row column)) columns)) + (cl-loop for column in columns + for tail on columns + collect (etaf-ui--grid-cell row column (cdr tail)))) (defun etaf-ui--grid-selected-p (row key selected-key row-selected-p) "Return whether ROW with KEY matches SELECTED-KEY or ROW-SELECTED-P." @@ -192,10 +206,9 @@ owns stable keyed callbacks across Range reevaluation." (unless host-ref (error "ETAF DataGrid :row-ref must return a non-nil stable ref"))) (etaf-view - (row - :key key - :class (concat "etaf-data-grid-row" - (if selected-p " selected" "")) + (row :key key + :class (concat "etaf-data-grid-row" + (if selected-p " selected" "")) :ref host-ref :role (when interactive-p 'button) :tab-index (when interactive-p 0) @@ -230,31 +243,14 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state." (defun etaf-ui--button-setup () "Create the retained renderer for one Button instance." - (let* ((pressed (etaf-ref nil)) - (timer nil) - (scope (etaf-current-effect-scope)) - (current-callback nil) + (let* ((current-callback nil) (current-press-p nil) (press nil)) (setq press (lambda () (when current-press-p - (when (timerp timer) - (cancel-timer timer)) - (setf (etaf-value pressed) t) - (unwind-protect - (when current-callback - (funcall current-callback)) - (setq timer - (run-at-time - 0.09 nil - (lambda () - (when (etaf-effect-scope-active-p scope) - (setf (etaf-value pressed) nil))))))))) - (etaf-on-unmounted - (lambda () - (when (timerp timer) - (cancel-timer timer)))) + (when current-callback + (funcall current-callback))))) (lambda () (let* ((label (etaf-current-prop :label)) (callback (etaf-current-prop :on-press)) @@ -263,41 +259,27 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state." (press-p (and (not disabled) (or callback use)))) (setq current-callback callback current-press-p press-p) - (let* ((state-pressed (and (not disabled) (etaf-value pressed))) - (state-class (cond (disabled "disabled") - (state-pressed "pressed") - (t "enabled"))) - (variant (and (not disabled) (etaf-current-prop :variant))) + (let* ((variant (and (not disabled) (etaf-current-prop :variant))) (variant-values (cond (disabled '(:color "#687386" :bgcolor "#E5E7EB" :border ((1) solid "#9CA3AF") :face normal)) - ((and (eq variant 'secondary) state-pressed) - '(:color "#142235" :bgcolor "#B9DED7" - :border ((1) solid "#24736C") :face bold)) ((eq variant 'secondary) '(:color "#142235" :bgcolor "#D9EEEA" :border ((1) solid "#2E8B83") :face bold)) - ((and (eq variant 'ghost) state-pressed) - '(:color "#142235" :bgcolor "#EEEAE2" - :border ((1) solid "#A79F93") :face normal)) ((eq variant 'ghost) '(:color "#142235" :bgcolor "#FFFDF8" :border ((1) solid "#C8C1B6") :face normal)) - ;; The enabled/pressed catalog defaults used to come from - ;; state selectors alone. Resolve them as props too so an + ;; The enabled catalog defaults used to come from state + ;; selectors alone. Resolve them as props too so an ;; explicit theme can override the same state boundary. - (state-pressed - '(:color "#FFFFFF" :bgcolor "#1E5A56" - :border ((1) solid "#174A47") :face bold)) (t '(:color "#FFFFFF" :bgcolor "#2F6B43" :border ((1) solid "#2F6B43") :face bold))))) (etaf-ui--button-view label (and press-p press) disabled (etaf-current-prop :ref) - (let ((custom-class (etaf-current-prop :class))) - (delq nil (list custom-class state-class))) + (etaf-current-prop :class) (or (etaf-current-prop :color) (plist-get variant-values :color)) (or (etaf-current-prop :bgcolor) @@ -309,7 +291,7 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state." (plist-get variant-values :face)) (etaf-current-prop :tab-index) (etaf-current-prop :aria-label) - use state-pressed)))))) + use)))))) ;;;###autoload (etaf-define-component etaf-button @@ -327,8 +309,7 @@ contract; callers can still override presentation with the ordinary props." ;; above remain authoritative, so a themed disabled Button cannot inherit ;; the catalog's light default surface. ("&.disabled" :padding (0 1) :face normal) - ("&.enabled" :padding (0 1) :face bold) - ("&.pressed" :padding (0 1) :face bold)) + ("&.enabled" :padding (0 1) :face bold)) :setup (etaf-ui--button-setup)) @@ -505,7 +486,7 @@ a stable Host reference." The pager owns no data state: page, page-size, total, loading, and error stay with CONTROLLER. PREVIOUS-REF and NEXT-REF should be stable public refs when the pager participates in keyboard/mouse interaction. The visible glyphs -(`‹' and `›') are paired with labels and help text so the compact control is +(`←' and `→') are paired with labels and help text so the compact control is readable in both GUI and text review." :styles (styles @@ -538,39 +519,47 @@ readable in both GUI and text review." (arrow-border '((0) solid "transparent"))) (setq current-controller controller-value) (etaf-view - (row :class (etaf-ui--class-value "etaf-pagination" nil - (etaf-current-prop :class)) - :role 'navigation - :aria-label (or (etaf-current-prop :aria-label) "Pagination") - :color parent-color - :bgcolor parent-bgcolor - :border (etaf-current-prop :border) - :padding (or (etaf-current-prop :padding) '(0 1)) - :gap '(0 (2)) - (button :label "‹" :ref (etaf-current-prop :previous-ref) - :aria-label "Previous page" - :disabled previous-disabled - :padding '(0 0) - :border arrow-border - :color (if previous-disabled "#9CA3AF" parent-color) - :bgcolor parent-bgcolor - :face 'bold - :on-press (unless previous-disabled previous)) + (flex :class (etaf-ui--class-value "etaf-pagination" nil + (etaf-current-prop :class)) + :width 'stretch + :flex-direction 'row + :align-items 'center + :role 'navigation + :aria-label (or (etaf-current-prop :aria-label) "Pagination") + :color parent-color + :bgcolor parent-bgcolor + :border (etaf-current-prop :border) + :box-sizing 'border-box + :padding (or (etaf-current-prop :padding) '(0 1)) + :gap '(0 (1)) + (column :width 'max-content + :flex-grow 0 :flex-shrink 0 :flex-basis 'auto + (button :label "←" :ref (etaf-current-prop :previous-ref) + :aria-label "Previous page" + :disabled previous-disabled + :padding '(0 0) + :border arrow-border + :color (if previous-disabled "#9CA3AF" parent-color) + :bgcolor parent-bgcolor + :face 'bold + :on-press (unless previous-disabled previous))) (column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0 (text :class "etaf-pagination-label" :text-align 'center (expr :value (format "Page %d / %d" page pages))) (text :class "etaf-pagination-summary" :text-align 'center (expr :value (format "%d–%d of %d" first-item last-item total)))) - (button :label "›" :ref (etaf-current-prop :next-ref) - :aria-label "Next page" - :disabled next-disabled - :padding '(0 0) - :border arrow-border - :color (if next-disabled "#9CA3AF" parent-color) - :bgcolor parent-bgcolor - :face 'bold - :on-press (unless next-disabled next)))))))) + (column :width 'max-content + :flex-grow 0 :flex-shrink 0 :flex-basis 'auto + (button :label "→" :ref (etaf-current-prop :next-ref) + :aria-label "Next page" + :disabled next-disabled + :padding '(0 0) + :border arrow-border + :color (if next-disabled "#9CA3AF" parent-color) + :bgcolor parent-bgcolor + :face 'bold + :on-press (unless next-disabled next))))))))) (provide 'etaf-ui) diff --git a/tests/etaf-ui-tests.el b/tests/etaf-ui-tests.el index fa5e696..9ef8cfb 100644 --- a/tests/etaf-ui-tests.el +++ b/tests/etaf-ui-tests.el @@ -210,8 +210,8 @@ (when-let ((buffer (get-buffer buffer-name))) (kill-buffer buffer)))))) -(ert-deftest etaf-ui-button-owns-pointer-hover-and-pressed-feedback () - "Buttons expose native pointer/hover affordances and a pressed state." +(ert-deftest etaf-ui-button-owns-native-hover-and-dispatch () + "Buttons expose native hover affordances without retained press state." (let ((buffer-name " *etaf-ui-button-surface-test*") (presses 0)) (unwind-protect @@ -237,9 +237,13 @@ 'health 'press) (should (= presses 1)) (should (string-match-p - "pressed" + "enabled" (plist-get (etaf-ui-test--props buffer-name 'health) :class))) + (should-not (string-match-p + "pressed" + (plist-get (etaf-ui-test--props buffer-name 'health) + :class))) (should (eq before (cdr (assq 'press (etaf-runtime-handler-for runtime 'health))))))) @@ -381,8 +385,10 @@ (should (equal (plist-get first :tab-index) 0)) (should (equal (plist-get second :role) 'button)) (should (equal (plist-get second :tab-index) 0)) - (should (string-match-p "selected" (plist-get second :class))) - (should-not (string-match-p "selected" (plist-get first :class)))) + (should (string-match-p + "selected" (or (plist-get second :class) ""))) + (should-not (string-match-p + "selected" (or (plist-get first :class) "")))) (let* ((runtime (etaf-runtime-for-buffer buffer-name)) (handler (cdr (assq 'press (etaf-runtime-handler-for runtime @@ -419,8 +425,8 @@ :previous-ref 'page-previous :next-ref 'page-next))) (should (string-match-p "Page 1 / 3" (etaf-ui-test--text buffer-name))) - (should (string-match-p "‹" (etaf-ui-test--text buffer-name))) - (should (string-match-p "›" (etaf-ui-test--text buffer-name))) + (should (string-match-p "←" (etaf-ui-test--text buffer-name))) + (should (string-match-p "→" (etaf-ui-test--text buffer-name))) ;; Live Ebox windows reserve two pixels for the exclusive display ;; boundary; the pager must stay inside the corresponding 360px row. (ebox-surface-update-buffer-viewport (get-buffer buffer-name) 358 20) @@ -451,6 +457,40 @@ (when-let ((buffer (get-buffer buffer-name))) (kill-buffer buffer))))) +(ert-deftest etaf-ui-pagination-composes-inside-a-grid-track () + "Pagination must distribute free space inside its assigned Grid track." + (let* ((source (etaf-data-memory-source + '((:id 1) (:id 2) (:id 3) (:id 4) (:id 5)) + :id-key :id)) + (controller (etaf-data-controller source :page-size 2 :auto-load t)) + (buffer-name " *etaf-ui-pagination-grid-test*")) + (unwind-protect + (progn + (etaf-mount + buffer-name + (etaf-view + (grid :width '(360) + :grid-template-columns '((80) (200) (80)) + (text "Left") + (column :width 'stretch :padding '(0 2) :border "#CBD5E1" + (pagination :controller controller + :previous-ref 'nested-page-previous + :next-ref 'nested-page-next)) + (text "Right")))) + (with-current-buffer buffer-name + (goto-char (point-min)) + (while (< (point) (point-max)) + (should (<= (ebox-string-pixel-width + (buffer-substring (line-beginning-position) + (line-end-position))) + 360)) + (forward-line 1)))) + (when-let ((runtime (etaf-runtime-for-buffer buffer-name))) + (etaf-unmount runtime)) + (etaf-data-stop controller) + (when-let ((buffer (get-buffer buffer-name))) + (kill-buffer buffer))))) + (ert-deftest etaf-ui-data-grid-keeps-long-cells-on-one-line () "Long tabular values use an ellipsis instead of increasing row height." (let* ((source (etaf-data-memory-source @@ -468,7 +508,7 @@ :columns '((:key :title :label "Title" :width 22)) :row-key (lambda (row) (plist-get row :id))))) (let ((text (etaf-ui-test--text buffer-name))) - (should (string-match-p "The Cathedral and th…" text)) + (should (string-match-p "The Cathedral and the…" text)) (should-not (string-match-p "The Cathedral and the Bazaar" text)))) (when-let ((runtime (etaf-runtime-for-buffer buffer-name))) (etaf-unmount runtime)) @@ -476,6 +516,30 @@ (when-let ((buffer (get-buffer buffer-name))) (kill-buffer buffer))))) +(ert-deftest etaf-ui-data-grid-converts-character-columns-and-keeps-air () + "Character column descriptors become pixel widths with a stable gap." + (let* ((source (etaf-data-memory-source + '((:id 1 :progress 64 :kind "Essay")) :id-key :id)) + (controller (etaf-data-controller source :auto-load t)) + (buffer-name " *etaf-ui-grid-column-unit-test*")) + (unwind-protect + (progn + (etaf-mount + buffer-name + (etaf-view + (data-grid + :controller controller + :columns '((:key :progress :label "Progress" :width 8) + (:key :kind :label "Kind" :width 7)) + :row-key (lambda (row) (plist-get row :id))))) + (should (string-match-p "Progress[[:space:]]+Kind" + (etaf-ui-test--text buffer-name)))) + (when-let ((runtime (etaf-runtime-for-buffer buffer-name))) + (etaf-unmount runtime)) + (etaf-data-stop controller) + (when-let ((buffer (get-buffer buffer-name))) + (kill-buffer buffer))))) + (ert-deftest etaf-ui-data-grid-noninteractive-rows-have-no-focus-contract () "Rows without ON-ROW-PRESS have no role, ref callback, or tab stop." (let* ((source (etaf-data-memory-source '((:id 1 :name "Ada"))