12 KiB
etaf-ui
etaf-ui 是 ETAF 的官方 Component 目录,是建立在 etaf 之上的可选独立包。它不增加第二套 Control 或 Widget 模型;Button、Checkbox、Label、Panel、DataGrid 都是使用同一套 View、属性、slot、事件和 Data 契约的普通 Component。
所有 View 都使用准确的注册名称:etaf-panel、etaf-label、etaf-button 和
etaf-checkbox。把下面完整例子放进启用 lexical-binding 的 .el 文件求值,
再切换到 *etaf-preferences* 使用控件。
;;; -*- lexical-binding: t; -*-
(require 'etaf-ui)
(etaf-define-component demo-preferences ()
:setup (list (etaf-ref nil) (etaf-ref "Unsaved"))
:render
(let* ((state (etaf-state))
(done (car state))
(saved (cadr state)))
(etaf-view
(etaf-panel :title "Account"
(slot :name 'header
(etaf-button :label "Save" :ref 'save-button
:on-press (lambda ()
(setf (etaf-value saved) "Saved"))))
(etaf-label :text (etaf-value saved))
(etaf-checkbox :checked (etaf-value done) :label "Done"
:ref 'done-checkbox
:on-change (lambda (next)
(setf (etaf-value done) next)))
(text (expr (if (etaf-value done) "Complete" "Pending")))))))
(etaf-mount "*etaf-preferences*" (etaf-view (demo-preferences)))
Core 不会自动加载 .etaf。可选的 Playground 读取 inert 结构源,并显式加载
已注册的 Elisp companion;这个文件格式不会生成更短的 Component alias。
普通本地回调不需要命名 Action,目录组件与应用自己定义的组件使用相同的 props 和 slot API。
Button 和 Checkbox 都是受控组件。etaf-button 支持 :label、
:on-press、:disabled、:ref,以及最小 presentation 属性
:class、:color、:bgcolor、:border、:padding、:font-weight、
:tab-index、:aria-label 和公共 Behavior 列表 :use。启用按钮默认使用
button role 和 tab-index 0;禁用按钮仍显示标签和禁用样式,但没有 press
回调、Behavior,也不进入 tab 顺序。etaf-checkbox 支持 :checked、:label、:on-change、
:disabled、:ref 及相同的 presentation 属性。启用时它提供
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-table-*、:ui-data-grid-error-fg、
:ui-pagination-muted-fg 和 :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;这是
视觉状态集合,不是第二套 widget 分类体系。
启用的 button 和 checkbox 共享同一套交互反馈:小手指针、原生 mouse-face
悬停反馈、可读的 help-echo、数字 tab stop 和语义 role。指针离开后 hover
自动结束,点击不会产生 retained 视觉状态。禁用控件保留文字和禁用样式,但
没有回调、指针激活或 tab stop。
显式 :on-* 回调和 Behavior 回调的组合由 Runtime 负责;UI 包只声明控件和
视觉状态。
间距由父布局负责:row 和 column 使用 :item-gap,例如
(row :item-gap 1 ...);flex 和 grid 使用 :gap。尤其需要将 row :gap
迁移为 row :item-gap。相邻控件保留明确间距,让 hover 和命中范围各自独立。
根事件透传按顺序追加:业务动作、由内到外的 wrapper callback、最后按声明顺序
运行的 Behavior。给 Checkbox 增加 :on-press 不会替换它的 :on-change 转换。
每个声明执行一次,错误会中断后续回调。wrapper 的 :use 列表顺序连接,重复
Behavior name 在安装前报错。
禁用输入取 OR,外层 nil 不能启用内部已经禁用的控件。禁用时 dispatch 和 focus
抛出 etaf-event-error,禁用 cell 控件会阻止指针激活落到父行,输入 Behavior
也不会安装。透传 :role 或 :aria-checked 等组件所有的 aria 状态时,冲突值会
导致输入错误;调用方仍可覆盖 :aria-label 与 :aria-description。完整规则见
交互与迁移说明。
etaf-label 支持 :text、:class、:color、:bgcolor、:font-weight、
:width、:border、:padding 和 :ref。
etaf-panel 支持 :title、相同的
surface presentation 属性,并投影命名的 header slot 和默认 slot。
公共 Component 的 business props 由实际定义导出,并与透传的 Host 属性分开:
| Component | Business props |
|---|---|
etaf-label |
:text、:variant |
etaf-button |
:label、:on-press、:disabled、:ref、:class、:color、:bgcolor、:border、:padding、:font-weight、:tab-index、:aria-label、:use、:variant |
etaf-checkbox |
:checked、:label、:on-change、:disabled |
etaf-panel |
:title、:variant |
etaf-number-input |
:value、:label、:on-change、:disabled、:min、:max |
etaf-table |
:columns、:rows、:row-key、:row-ref、:on-row-press、:row-selected-p |
etaf-data-grid |
:controller、:columns、:row-key、:on-row-press、:row-ref、:row-selected-p、:loading-label、:error-label、:empty-label |
etaf-pagination |
:controller、:previous-ref、:next-ref、:previous-label、:next-label、:class、:color、:bgcolor、:border、:padding、:aria-label |
目录中的每个公共 Component 都只挂载一个 Host root。未被作为 business prop
消费的所有合法 Host 属性(例如 :ref、:class 和 :aria-label)都会透传到
该 root;透传规则并不意味着这些属性都是每个 Component 的 business prop。
DataGrid 支持列描述、函数型 :row-key,以及可选的
:loading-label、:error-label、:empty-label 或函数型
:row-selected-p。提供 :on-row-press 后,每一行都是带 button role 和
tab-index 0 的可交互 button,且回调接收该行;没有 :on-row-press 时,行
没有回调或 tab stop。
etaf-table 的交互行也可省略 :row-ref,框架会提供属于当前保留实例的稳定引用。
只有外部 focus、测试或集成需要直接寻址时才提供 ref,不要根据行文字拼接全局
symbol。row key 负责数据身份,ref 负责交互 Host 地址。
DataGrid 的 :row-ref 是可选项。交互式 grid 省略它时,DataGrid 会根据
controller 持有的 row identity 派生稳定的内部 Host ref。fallback ref 不会
进入全局 symbol table,并按每个 retained DataGrid instance 隔离,因此多个 grid
可以安全复用相同行 identity;显式提供它时,该回调拥有 identity,并且必须为
每一行返回非 nil 的稳定 Host ref。
:row-key 仍然是 retained row 必须具备的非 nil 稳定标量 identity。
DataGrid 按 row key 保留唯一 action closure,因此 selection 或 Data Range
更新不会为未变化的行重建 handler。
整数列 :width 表示可读字符容量,不是裸像素;DataGrid 原样使用 Ebox 原生字符单位,
并在非末列之间保留一个原生字符间距。使用 :width '(fr 1) 让列填充剩余宽度;
多个 fractional 列按正数权重分配这部分空间。例如让
(:key :title :width (fr 1)) 与
(:key :actions :width 22 :cell render-actions) 配合,可以在保留操作控件空间的
同时自适应标题宽度。表头和数据行使用相同的轨道及间距。自适应 cell 按分配的
宽度裁剪,不会挤宽相邻列。固定列仍保留声明的空间,父布局需要容纳这些控件
及列间距。
自定义 cell 的列宽在固定值与 fractional 值之间切换时,其 Component 状态保持。 全固定列的纯文本表格仍保留紧凑的文本渲染路径。
自定义 cell 与列迁移
Table 与 DataGrid 共用列契约。每一列都必须有唯一且非 nil 的 :key
(symbol、整数或字符串),纯展示列也一样。默认 cell 将该字段显示为文字。
:cell 函数接收一个 row,返回 nil、字符串、typed Host 或 Component View,
或这些值组成的 proper sequence。Table 拥有 cell 宽度并裁剪溢出;纯文本表格
继续使用原有的紧凑表示。
下面将 :name 字段用两个不同的 column key 展示,并在第三列放入普通 Button。
行选择与 Open 按钮分别执行自己的动作:
;;; -*- lexical-binding: t; -*-
(require 'etaf-ui)
(etaf-mount
"*etaf-cell-demo*"
(etaf-view
(etaf-table
:rows '((:id 1 :name "Ada"))
:row-key (lambda (row) (plist-get row :id))
:on-row-press (lambda (row) (message "Select %s" (plist-get row :name)))
:columns
(list
'(:key :name :label "Name" :width 12)
(list :key :name-upper :label "Uppercase" :width 12
:cell (lambda (row) (upcase (plist-get row :name))))
(list :key :open :label "Action" :width 10
:cell
(lambda (row)
(etaf-view
(etaf-button
:label "Open"
:on-press (lambda ()
(message "Open %s" (plist-get row :name)))))))))))
迁移重复字段列时,保留一列的 :key :name,为另一列指定 :name-upper 等唯一
key,再通过 :cell 读取原来的 :name 字段。缺失或重复 column key 都会报错。
row key 与 column key 让 cell 身份在重排时保留;改变 key 会替换该 cell 实例。
cell 函数使用 row 参数与普通词法捕获,Context 来自消费它的 Table/Grid 挂载
位置,不会恢复作者的 props、etaf-state 或私有样式。cell 需要自己的状态、样式
或生命周期时,返回普通 Component,在它的 :setup 中创建持久 ref 和 watch。
若有意共享作者的 ref,应在创建回调前捕获稳定句柄;不要在每次 cell 函数调用时
创建持久状态。
etaf-pagination 是受控 Data Component。它接收 Data controller 以及稳定的
:previous-ref、:next-ref,使用带内边距的 secondary Button(‹ Previous /
Next ›),中间显示 Page N / M 与条数摘要。空间不足时,完整控件和摘要
自然换行。加载中或已经位于首/末页时会禁用对应动作。它不拥有页码状态,
并复用 Button 的交互契约和 Theme token。可选的 :previous-label、
:next-label 替换完整按钮文案,例如 "‹ 上一页"、"下一页 ›";稳定的英文
无障碍标签保持不变。
键盘焦点使用 ETAF 的公共 Runtime 端口:用
(etaf-focus-next runtime) 在带数值且非负 :tab-index 的可见 Host 之间
移动;需要代码触发动作时使用
(etaf-dispatch-event runtime ref 'press)。禁用控件和不可交互的 DataGrid
行不会进入 tab 顺序。
在该目录运行 make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs。