feat: establish unified etaf view foundation
Implement the P0 View grammar, expr bridge, stateless view Components, and Ebox mount path in a new independent package. Include bilingual architecture and implementation documents plus contract tests.
This commit is contained in:
commit
0185c4e05a
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
*.elc
|
||||
*~
|
||||
.DS_Store
|
||||
18
Makefile
Normal file
18
Makefile
Normal file
@ -0,0 +1,18 @@
|
||||
EMACS ?= emacs
|
||||
LOAD_PATH = -L . -L ../emacs-box
|
||||
SOURCES = etaf-view.el etaf-component.el etaf-renderer.el etaf.el
|
||||
|
||||
.PHONY: test compile check clean
|
||||
|
||||
test:
|
||||
$(EMACS) -Q --batch $(LOAD_PATH) -l tests/etaf-tests.el \
|
||||
-f ert-run-tests-batch-and-exit
|
||||
|
||||
compile:
|
||||
$(EMACS) -Q --batch $(LOAD_PATH) \
|
||||
--eval "(dolist (file '(\"etaf-view.el\" \"etaf-component.el\" \"etaf-renderer.el\" \"etaf.el\")) (byte-compile-file file))"
|
||||
|
||||
check: compile test
|
||||
|
||||
clean:
|
||||
rm -f *.elc tests/*.elc
|
||||
64
README.md
Normal file
64
README.md
Normal file
@ -0,0 +1,64 @@
|
||||
# ETAF
|
||||
|
||||
ETAF is the text-application framework built above the independent Ebox layout and rendering engine.
|
||||
|
||||
The public model is intentionally small:
|
||||
|
||||
- `View` describes Hosts and Component calls.
|
||||
- `Component` turns props into a View.
|
||||
- `Ebox` measures, lays out, paints, and publishes the result.
|
||||
|
||||
Every structural form uses one shape:
|
||||
|
||||
```elisp
|
||||
(name :property value ... child ...)
|
||||
```
|
||||
|
||||
Properties come first and children come last. Property values are ordinary Elisp expressions. `expr` is the only executable child bridge:
|
||||
|
||||
```elisp
|
||||
(etaf-view
|
||||
(column
|
||||
(text :face 'bold "Hello")
|
||||
(text (expr :value (if ready "Ready" "Waiting")))))
|
||||
```
|
||||
|
||||
Define a stateless Component with `:view`:
|
||||
|
||||
```elisp
|
||||
(etaf-define-component status-label (&key label)
|
||||
"Render a status label."
|
||||
:view
|
||||
(text :face 'bold (expr :value label)))
|
||||
|
||||
(etaf-mount
|
||||
"*etaf-demo*"
|
||||
(etaf-view
|
||||
(status-label :label "Connected")))
|
||||
```
|
||||
|
||||
`etaf-view` is the sole public View construction entry. View forms and Component `:view` forms do not use quote. Quote remains ordinary Elisp data syntax, for example `'bold` as a face symbol. A Renderable returned by ordinary Elisp must be constructed with an unquoted `(etaf-view ...)` inside `expr`.
|
||||
|
||||
## Development checkout
|
||||
|
||||
During development, load the sibling Ebox checkout before ETAF:
|
||||
|
||||
```elisp
|
||||
(add-to-list 'load-path "/path/to/github/emacs-box")
|
||||
(add-to-list 'load-path "/path/to/github/etaf")
|
||||
(require 'etaf)
|
||||
```
|
||||
|
||||
The package metadata declares Ebox `1.0.1` as the runtime dependency. ETAF never calls Ebox private functions.
|
||||
|
||||
## Scope of this first slice
|
||||
|
||||
The repository currently establishes and tests the P0 grammar, `etaf-view`, `expr`, stateless `:view` Components, core Hosts (`text`, `fragment`, `container`, `row`, `column`, `stack`, `flex`, and `spacer`), and the Ebox mount bridge. Stateful `:setup`, slots, styles, Context, Behaviors, events, actions, data, and incremental Component reconciliation are subsequent implementation milestones; unsupported clauses fail explicitly.
|
||||
|
||||
Run the focused checks with:
|
||||
|
||||
```sh
|
||||
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
```
|
||||
|
||||
The target architecture and the sequenced implementation work are documented in [`docs/architecture.en.md`](docs/architecture.en.md) and [`docs/implementation-plan.en.md`](docs/implementation-plan.en.md). Chinese versions are kept beside them.
|
||||
64
README.zh-CN.md
Normal file
64
README.zh-CN.md
Normal file
@ -0,0 +1,64 @@
|
||||
# ETAF
|
||||
|
||||
ETAF 是构建在独立 Ebox 布局与渲染引擎之上的文本应用框架。
|
||||
|
||||
公共模型刻意保持很小:
|
||||
|
||||
- `View` 描述 Host 和 Component 调用。
|
||||
- `Component` 根据 props 产生 View。
|
||||
- `Ebox` 负责测量、布局、绘制和发布结果。
|
||||
|
||||
所有结构都使用同一种形状:
|
||||
|
||||
```elisp
|
||||
(name :property value ... child ...)
|
||||
```
|
||||
|
||||
属性必须全部位于前面,子节点必须全部位于后面。属性值就是普通 Elisp 表达式;子节点中唯一的可执行桥接是 `expr`:
|
||||
|
||||
```elisp
|
||||
(etaf-view
|
||||
(column
|
||||
(text :face 'bold "Hello")
|
||||
(text (expr :value (if ready "Ready" "Waiting")))))
|
||||
```
|
||||
|
||||
使用 `:view` 定义无状态 Component:
|
||||
|
||||
```elisp
|
||||
(etaf-define-component status-label (&key label)
|
||||
"Render a status label."
|
||||
:view
|
||||
(text :face 'bold (expr :value label)))
|
||||
|
||||
(etaf-mount
|
||||
"*etaf-demo*"
|
||||
(etaf-view
|
||||
(status-label :label "Connected")))
|
||||
```
|
||||
|
||||
`etaf-view` 是唯一公共 View 构造入口。View 结构和 Component 的 `:view` 都不需要 quote;quote 仍然是普通 Elisp 数据语法,例如用 `'bold` 表示 face 符号。普通 Elisp 表达式返回 Renderable 时,必须在 `expr` 中使用未 quote 的 `(etaf-view ...)` 构造它。
|
||||
|
||||
## 开发检出
|
||||
|
||||
开发时先把同级的 Ebox 检出目录加入 `load-path`:
|
||||
|
||||
```elisp
|
||||
(add-to-list 'load-path "/path/to/github/emacs-box")
|
||||
(add-to-list 'load-path "/path/to/github/etaf")
|
||||
(require 'etaf)
|
||||
```
|
||||
|
||||
包元数据声明 Ebox `1.0.1` 为运行时依赖。ETAF 不调用 Ebox 私有函数。
|
||||
|
||||
## 第一阶段范围
|
||||
|
||||
当前仓库建立并测试 P0 语法、`etaf-view`、`expr`、无状态 `:view` Component、核心 Host(`text`、`fragment`、`container`、`row`、`column`、`stack`、`flex`、`spacer`)以及 Ebox 挂载桥接。状态型 `:setup`、slot、styles、Context、Behavior、事件、Action、数据和增量 Component 协调将在后续里程碑实现;暂未实现的子句会显式报错。
|
||||
|
||||
运行聚焦检查:
|
||||
|
||||
```sh
|
||||
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
```
|
||||
|
||||
目标架构和分阶段开发工作分别见 [`docs/architecture.zh.md`](docs/architecture.zh.md) 与 [`docs/implementation-plan.zh.md`](docs/implementation-plan.zh.md),英文版本与中文版本并列维护。
|
||||
160
docs/architecture.en.md
Normal file
160
docs/architecture.en.md
Normal file
@ -0,0 +1,160 @@
|
||||
# ETAF Architecture
|
||||
|
||||
This document defines the target architecture. It is a contract for public concepts and ownership, not a changelog.
|
||||
|
||||
## 1. The one lowering path
|
||||
|
||||
```text
|
||||
Application
|
||||
→ Runtime / State / Action / Data
|
||||
→ Component(props, local scope)
|
||||
→ View
|
||||
→ ETAF Renderer
|
||||
→ Ebox Node
|
||||
→ measure → layout → paint → commit
|
||||
→ Emacs buffer
|
||||
```
|
||||
|
||||
ETAF describes application structure and behavior. Ebox is the lower-level engine that turns a measurable Node tree into text surfaces and atomically publishes those surfaces. A new ETAF feature must have one owner in this path.
|
||||
|
||||
## 2. Four concepts users need
|
||||
|
||||
### View
|
||||
|
||||
View is the structural description of a result. A View can be a Host call, a Component call, a string text leaf, `nil`, or a sequence of those values.
|
||||
|
||||
### Component
|
||||
|
||||
Component is the reusable boundary. It receives props, owns optional local scope, and produces a View. A Component is not a second kind of Ebox node and does not require a new renderer branch merely because it is reusable.
|
||||
|
||||
### Node
|
||||
|
||||
Node is Ebox's measurable and renderable model. Ebox owns geometry, box model, layout, surface properties, scrolling, runtime identity, and buffer publication. ETAF does not put Component semantics, slots, actions, or events into Ebox properties.
|
||||
|
||||
### Runtime
|
||||
|
||||
Runtime owns mounting, scheduling, state invalidation, event boundaries, commit coordination, rollback, and disposal. Runtime is an application boundary, not another View node.
|
||||
|
||||
## 3. One View grammar
|
||||
|
||||
Every Host and Component call has one shape:
|
||||
|
||||
```elisp
|
||||
(NAME ATTRIBUTE* CHILD*)
|
||||
```
|
||||
|
||||
An attribute is `:KEY VALUE`; a child is a View, string, `nil`, or a sequence produced through `expr`. The attribute region must be complete before the child region begins:
|
||||
|
||||
```elisp
|
||||
(text :face 'bold :color "#F4F6FB" "Hello")
|
||||
```
|
||||
|
||||
This is invalid because the regions are interleaved:
|
||||
|
||||
```elisp
|
||||
(text "Hello" :face 'bold)
|
||||
```
|
||||
|
||||
The same rule applies to core Hosts and public Components. There is no separate positional-content convention for `text`.
|
||||
|
||||
## 4. Elisp evaluation boundary
|
||||
|
||||
The compiler reads structural View positions. It does not require quote around a View:
|
||||
|
||||
```elisp
|
||||
(etaf-view (text "Hello"))
|
||||
```
|
||||
|
||||
Attribute values are ordinary Elisp expression positions:
|
||||
|
||||
```elisp
|
||||
(etaf-view
|
||||
(text :face (if dark 'light 'dark) "Theme"))
|
||||
```
|
||||
|
||||
The child region has one explicit computation bridge:
|
||||
|
||||
```elisp
|
||||
(expr :value (if checked "☑" "☐"))
|
||||
```
|
||||
|
||||
`expr` accepts exactly `:value`. `if`, `when`, `cond`, `let`, `mapcar`, and other Elisp forms remain ordinary Elisp inside that value. ETAF does not add separate `if`, loop, or computation node categories.
|
||||
|
||||
An expression that returns a View constructs it explicitly:
|
||||
|
||||
```elisp
|
||||
(expr
|
||||
:value
|
||||
(when open
|
||||
(etaf-view (text :face 'bold "Details"))))
|
||||
```
|
||||
|
||||
`'(text "Details")` is data, not a View. Quote is therefore neither globally forbidden nor used as a second View compiler.
|
||||
|
||||
## 5. Components
|
||||
|
||||
The public definition model has one beginner form and one advanced form:
|
||||
|
||||
```elisp
|
||||
(etaf-define-component NAME (&key PROPS)
|
||||
:view VIEW)
|
||||
|
||||
(etaf-define-component NAME (&key PROPS)
|
||||
:setup SETUP)
|
||||
```
|
||||
|
||||
`:view` is structural syntax and needs no quote. `:setup` is ordinary Elisp; it establishes Component Scope and returns a zero-argument render function that uses `etaf-view` when ordinary Elisp produces a View. `:view` and `:setup` are mutually exclusive. Static component styles use the separate definition metadata entry `:styles (styles RULE...)`; styles are not a third rendering model.
|
||||
|
||||
Props and children have different owners. Props are named inputs declared by the Component. Trailing children and named slots form one slot collection; children are not a hidden second Component argument list. The Component boundary is the place where that collection is normalized and projected.
|
||||
|
||||
Within View syntax, public Component aliases may omit the `etaf-` package prefix. Ordinary Elisp APIs such as `etaf-value`, `etaf-ref`, and `etaf-mount` keep their prefix. Alias resolution is contextual; a collision receives a semantic alias such as `list-view` rather than shadowing Elisp's `list`.
|
||||
|
||||
## 6. Core Hosts and official Components
|
||||
|
||||
ETAF core contains only minimal, unstyled Hosts:
|
||||
|
||||
```text
|
||||
text · fragment · container · row · column · stack · flex · spacer
|
||||
```
|
||||
|
||||
These names describe structural layout and text surfaces. Product-ready controls such as Button, Checkbox, Input, Dialog, and DataGrid are ordinary Components supplied by the independent `etaf-ui` package. Core pressability uses semantic properties such as `:role` and `:on-press`; it does not create a competing core Button Host.
|
||||
|
||||
Strings are the smallest text View and lower to an Ebox box with content. `text` is the explicit text Host for styling and semantic properties; it is not a separate Ebox node family.
|
||||
|
||||
## 7. Non-visual capabilities
|
||||
|
||||
These mechanisms extend the same Component/Host boundary without creating parallel visual node categories:
|
||||
|
||||
| Mechanism | Owns | Entry |
|
||||
| --- | --- | --- |
|
||||
| `on-xx` | One local event callback | Host/Component attribute |
|
||||
| Action | Named, testable business mutation | Action definition and dispatch |
|
||||
| Effect | External synchronization, subscriptions, cleanup | Effect in Scope |
|
||||
| Behavior | Reusable non-visual capability bundle | `:use` attribute |
|
||||
| watch | Reactive invalidation primitive | Reactive API or Behavior |
|
||||
| Context | Inherited Scope value | Provide / Inject |
|
||||
| Data | Request, state, cancellation, and error ownership | Core data API |
|
||||
|
||||
An `expr` computes a child value. It does not own identity, lifecycle, subscriptions, buffer writes, or effects. A Behavior may use events, refs, Effects, and watches, but it never becomes a View node.
|
||||
|
||||
## 8. Package ownership
|
||||
|
||||
```text
|
||||
ebox
|
||||
└── optional ebox-playground
|
||||
|
||||
etaf → ebox
|
||||
├── reactive / state / action / effect / data / ECSS
|
||||
└── pure .etaf compiler
|
||||
|
||||
etaf-ui → etaf
|
||||
etaf-playground → etaf (+ etaf-ui for official Component examples)
|
||||
```
|
||||
|
||||
Ebox knows nothing about Components, slots, Actions, Context, or data. ETAF knows Ebox only through the renderer boundary and its public API. `etaf-data` is a core capability, not a user-required peer package. Database and external integrations use explicit data-source packages such as `etaf-sqlite`; no abstract public `etaf-adapters` layer is needed.
|
||||
|
||||
The two Playgrounds are independent optional tools. `ebox-playground` uses Ebox only; `etaf-playground` uses ETAF public APIs and never calls Ebox Playground or Ebox private functions.
|
||||
|
||||
## 9. Extension rule
|
||||
|
||||
Prefer a Component, Behavior, Action, Effect, Context value, or data source when the feature is application-level. Add an Ebox property or Node only when the feature changes measurement, layout, surface painting, scrolling, or publication and has a complete owner for signature, dirty classification, rendering, rollback, and tests. This keeps the user model small while preserving a Turing-complete Elisp escape through ordinary expression positions and runtime APIs.
|
||||
160
docs/architecture.zh.md
Normal file
160
docs/architecture.zh.md
Normal file
@ -0,0 +1,160 @@
|
||||
# ETAF 架构
|
||||
|
||||
本文定义目标架构,是公共概念和职责边界的契约,不是变更日志。
|
||||
|
||||
## 1. 唯一的降级路径
|
||||
|
||||
```text
|
||||
Application
|
||||
→ Runtime / State / Action / Data
|
||||
→ Component(props, local scope)
|
||||
→ View
|
||||
→ ETAF Renderer
|
||||
→ Ebox Node
|
||||
→ measure → layout → paint → commit
|
||||
→ Emacs buffer
|
||||
```
|
||||
|
||||
ETAF 描述应用结构和行为;Ebox 是底层引擎,负责把可测量的 Node 树转换为文本 surface,并原子地发布这些 surface。每个新的 ETAF 功能都必须在这条路径上拥有唯一职责归属。
|
||||
|
||||
## 2. 用户需要理解的四个概念
|
||||
|
||||
### View
|
||||
|
||||
View 是结果的结构描述。View 可以是 Host 调用、Component 调用、字符串文本叶子、`nil`,或者这些值组成的序列。
|
||||
|
||||
### Component
|
||||
|
||||
Component 是复用边界。它接收 props,可选地拥有局部 Scope,并产生 View。Component 不是另一种 Ebox Node,也不应因为“可复用”就增加新的 renderer 分支。
|
||||
|
||||
### Node
|
||||
|
||||
Node 是 Ebox 的可测量、可渲染模型。Ebox 负责几何、盒模型、布局、surface 属性、滚动、运行时身份和 buffer 发布。ETAF 不把 Component 语义、slot、Action 或事件塞进 Ebox 属性。
|
||||
|
||||
### Runtime
|
||||
|
||||
Runtime 负责挂载、调度、状态失效、事件边界、提交协调、回滚和销毁。Runtime 是应用边界,不是另一种 View 节点。
|
||||
|
||||
## 3. 统一的 View 语法
|
||||
|
||||
所有 Host 和 Component 调用都使用同一种形状:
|
||||
|
||||
```elisp
|
||||
(NAME ATTRIBUTE* CHILD*)
|
||||
```
|
||||
|
||||
属性是 `:KEY VALUE`;子节点是 View、字符串、`nil`,或通过 `expr` 产生的序列。属性区必须在子节点区之前完整结束:
|
||||
|
||||
```elisp
|
||||
(text :face 'bold :color "#F4F6FB" "Hello")
|
||||
```
|
||||
|
||||
下面的写法非法,因为属性区和子节点区交错:
|
||||
|
||||
```elisp
|
||||
(text "Hello" :face 'bold)
|
||||
```
|
||||
|
||||
核心 Host 和公共 Component 都遵循同一规则;`text` 没有另一套“位置参数作为内容”的特殊约定。
|
||||
|
||||
## 4. Elisp 求值边界
|
||||
|
||||
编译器读取结构位置中的 View,不要求对 View 使用 quote:
|
||||
|
||||
```elisp
|
||||
(etaf-view (text "Hello"))
|
||||
```
|
||||
|
||||
属性值是普通 Elisp 表达式位置:
|
||||
|
||||
```elisp
|
||||
(etaf-view
|
||||
(text :face (if dark 'light 'dark) "Theme"))
|
||||
```
|
||||
|
||||
子节点区只有一个明确的计算桥接:
|
||||
|
||||
```elisp
|
||||
(expr :value (if checked "☑" "☐"))
|
||||
```
|
||||
|
||||
`expr` 只接受 `:value`。`if`、`when`、`cond`、`let`、`mapcar` 及其他 Elisp 形式仍然是该 value 内的普通 Elisp;ETAF 不增加单独的 `if`、循环或计算节点类别。
|
||||
|
||||
表达式要返回 View 时,显式构造它:
|
||||
|
||||
```elisp
|
||||
(expr
|
||||
:value
|
||||
(when open
|
||||
(etaf-view (text :face 'bold "Details"))))
|
||||
```
|
||||
|
||||
`'(text "Details")` 是数据,不是 View。因此 quote 既不是全局禁止,也不是第二套 View 编译器。
|
||||
|
||||
## 5. Component
|
||||
|
||||
公共定义模型只有入门形式和高级形式:
|
||||
|
||||
```elisp
|
||||
(etaf-define-component NAME (&key PROPS)
|
||||
:view VIEW)
|
||||
|
||||
(etaf-define-component NAME (&key PROPS)
|
||||
:setup SETUP)
|
||||
```
|
||||
|
||||
`:view` 是结构语法,不需要 quote;`:setup` 是普通 Elisp,用于建立 Component Scope 并返回零参数 render 函数;普通 Elisp 产生 View 时使用 `etaf-view`。`:view` 与 `:setup` 互斥。组件静态样式使用独立的定义元数据入口 `:styles (styles RULE...)`;styles 不是第三种渲染模型。
|
||||
|
||||
Props 和 children 的职责不同。Props 是 Component 声明的命名输入;末尾 children 和具名 slot 组成同一个 slot collection,而不是隐藏的第二套 Component 参数表。该 collection 在 Component 边界统一归一化并投影。
|
||||
|
||||
在 View 语法中,公共 Component 可以省略 `etaf-` 前缀;普通 Elisp API,如 `etaf-value`、`etaf-ref`、`etaf-mount`,保留前缀。别名解析只发生在结构位置;发生冲突时使用 `list-view` 这样的语义别名,不覆盖 Elisp 的 `list`。
|
||||
|
||||
## 6. 核心 Host 与官方 Component
|
||||
|
||||
ETAF 核心只包含最小的、无样式 Host:
|
||||
|
||||
```text
|
||||
text · fragment · container · row · column · stack · flex · spacer
|
||||
```
|
||||
|
||||
这些名字表达结构布局和文本 surface。Button、Checkbox、Input、Dialog、DataGrid 等产品级控件都是独立 `etaf-ui` 包提供的普通 Component。核心的可按压能力通过 `:role`、`:on-press` 等语义属性表达,不建立与 `etaf-ui` Button 竞争的核心 Button Host。
|
||||
|
||||
字符串是最小文本 View,会降级为带 content 的 Ebox box;`text` 是用于样式和语义属性的显式文本 Host,不是另一种 Ebox Node 家族。
|
||||
|
||||
## 7. 非视觉能力
|
||||
|
||||
这些机制扩展同一个 Component/Host 边界,但不建立平行的视觉节点类别:
|
||||
|
||||
| 机制 | 职责 | 入口 |
|
||||
| --- | --- | --- |
|
||||
| `on-xx` | 一个局部事件回调 | Host/Component 属性 |
|
||||
| Action | 有名字、可测试的业务变更 | Action 定义和 dispatch |
|
||||
| Effect | 外部同步、订阅和清理 | Scope 中的 Effect |
|
||||
| Behavior | 可复用的非视觉能力集合 | `:use` 属性 |
|
||||
| watch | 响应式失效原语 | Reactive API 或 Behavior |
|
||||
| Context | 继承的 Scope 值 | Provide / Inject |
|
||||
| Data | 请求、状态、取消和错误归属 | Core data API |
|
||||
|
||||
`expr` 负责计算子节点值,不拥有身份、生命周期、订阅、buffer 写入或 Effect。Behavior 可以使用事件、ref、Effect 和 watch,但自身不会变成 View 节点。
|
||||
|
||||
## 8. 包职责
|
||||
|
||||
```text
|
||||
ebox
|
||||
└── 可选的 ebox-playground
|
||||
|
||||
etaf → ebox
|
||||
├── reactive / state / action / effect / data / ECSS
|
||||
└── 纯 .etaf compiler
|
||||
|
||||
etaf-ui → etaf
|
||||
etaf-playground → etaf(展示官方 Component 时再依赖 etaf-ui)
|
||||
```
|
||||
|
||||
Ebox 不理解 Component、slot、Action、Context 或 data。ETAF 只通过 renderer 边界和 Ebox 公共 API 使用 Ebox。`etaf-data` 是 core 能力,不是用户必须额外安装的平级包。数据库和外部集成使用明确的数据源包,例如 `etaf-sqlite`;不需要抽象的公共 `etaf-adapters` 层。
|
||||
|
||||
两个 Playground 都是独立的可选工具:`ebox-playground` 只使用 Ebox;`etaf-playground` 使用 ETAF 公共 API,绝不调用 Ebox Playground 或 Ebox 私有函数。
|
||||
|
||||
## 9. 扩展规则
|
||||
|
||||
应用层功能优先使用 Component、Behavior、Action、Effect、Context 值或 data source。只有当功能确实改变测量、布局、surface 绘制、滚动或发布,并且已经定义好 signature、dirty 分类、渲染、回滚和测试责任时,才增加 Ebox 属性或 Node。这样既保持用户模型小而统一,又通过普通表达式位置和 Runtime API 保留 Elisp 的图灵完备能力。
|
||||
63
docs/implementation-plan.en.md
Normal file
63
docs/implementation-plan.en.md
Normal file
@ -0,0 +1,63 @@
|
||||
# ETAF Implementation Plan
|
||||
|
||||
This document is the working plan for the independent `etaf` repository. The architecture contract lives in [`architecture.en.md`](architecture.en.md); this file records implementation order, verification, and explicit gaps.
|
||||
|
||||
## 1. Milestones
|
||||
|
||||
| Milestone | Scope | Exit evidence |
|
||||
| --- | --- | --- |
|
||||
| P0 | Unified grammar, `etaf-view`, `expr`, stateless `:view`, core Hosts, Ebox bridge | Macro-expansion failures, ERT contract tests, byte compilation, mount smoke test |
|
||||
| P1 | Slot collection, `:setup`, Component Scope, refs, lifecycle, raw Ebox escape | Public Component tests cover setup-once, props, slots, disposal, and rollback |
|
||||
| P2 | Context, Behavior, focus/event protocol, Actions, Effects, watches | Real mounted event paths, cleanup tests, identity and failure tests |
|
||||
| P3 | ECSS styles, themes, inline text runs, resource/error boundary | Style ordering, text-surface, async recovery, and buffer publication tests |
|
||||
| P4 | Data capabilities, `etaf-ui`, data sources, playgrounds, use-case-driven layout extensions | Independent-load scans, package tests, application examples, and CI |
|
||||
|
||||
## 2. P0 delivered slice
|
||||
|
||||
- The repository is an independent package at `/Users/geekinney/IPARA/3-RESOURCES/emacs/config/github/etaf`.
|
||||
- The only dependency is the sibling Ebox package during this checkout phase.
|
||||
- `etaf-view.el` owns structural parsing and the single `expr :value` bridge.
|
||||
- `etaf-component.el` owns stateless `:view` definitions and contextual aliases.
|
||||
- `etaf-renderer.el` is the only module that calls Ebox; it uses public Ebox constructors and publication APIs.
|
||||
- Core Hosts are minimal and unstyled: `text`, `fragment`, `container`, `row`, `column`, `stack`, `flex`, and `spacer`.
|
||||
- Attribute/child ordering, ordinary Elisp evaluation boundaries, expression results, Component props, alias collisions, and mounting are covered by `tests/etaf-tests.el`.
|
||||
|
||||
## 3. P1 sequence
|
||||
|
||||
1. Freeze the Component call and slot collection representation.
|
||||
2. Implement `(slot)` and `(slot FALLBACK...)` as default-slot projection, then named `(slot :name 'NAME CHILD...)`.
|
||||
3. Make trailing children and named slot declarations normalize into that same collection; reject dynamic, string, numeric, and keyword slot names.
|
||||
4. Add `:setup` as ordinary Elisp that runs once per retained Component Scope and returns a zero-argument render function.
|
||||
5. Add refs and computed values with stable identity, then connect props updates without rerunning setup.
|
||||
6. Add explicit lifecycle and failed-candidate disposal before any stateful application example.
|
||||
7. Add `raw-ebox :value` as the namespaced backend escape after the normal View path is proven.
|
||||
|
||||
P1 must not add a second Template entry, a Variant definition model, a positional `text` exception, or a quote-based View convention.
|
||||
|
||||
## 4. P2–P4 dependency order
|
||||
|
||||
- Context depends on retained Component Scope and nearest-ancestor ownership.
|
||||
- Behaviors depend on stable Host identity, events, refs, Effects, and disposal.
|
||||
- Actions are business mutations and must not become View nodes or presentation callbacks.
|
||||
- Effects own external synchronization and cleanup; `watch` remains a reactive primitive that a Behavior or Effect may use.
|
||||
- ECSS and state styles depend on a frozen Ebox property boundary and text-surface mapping.
|
||||
- Data remains an ETAF core capability. SQLite, PostgreSQL, MySQL, REST, file, and ORM implementations are optional concrete data-source packages.
|
||||
- `etaf-ui` is one official Component catalog. Source file splits are maintainer boundaries, not user concepts; DataGrid is an ordinary compound Component.
|
||||
- `ebox-playground` depends only on Ebox. `etaf-playground` depends on ETAF and optionally `etaf-ui`; neither depends on the other.
|
||||
|
||||
## 5. Verification gates
|
||||
|
||||
Every milestone must pass the smallest relevant gate before the next boundary is started:
|
||||
|
||||
```sh
|
||||
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make test EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
```
|
||||
|
||||
P0 additionally requires a clean macro-expansion failure for malformed grammar and a real `etaf-mount` path. P1 adds setup/slot lifecycle tests. P2 adds mounted event and cleanup paths. P3 adds text-surface and publication tests. P4 adds package boundary scans, independent loading, and application smoke tests.
|
||||
|
||||
No milestone is complete when only helper functions pass: public macro expansion, Component construction, renderer lowering, and buffer publication must all be exercised.
|
||||
|
||||
## 6. Stop conditions
|
||||
|
||||
Do not introduce `etaf-template`, `Variant`, public Control/Widget categories, `etaf-data` as a required peer package, abstract `etaf-adapters`, or a second public View construction entry. Do not implement Grid, Overlay, or a complete CSS clone before a real application use case supplies a measurement, layout, and publication contract.
|
||||
63
docs/implementation-plan.zh.md
Normal file
63
docs/implementation-plan.zh.md
Normal file
@ -0,0 +1,63 @@
|
||||
# ETAF 实施计划
|
||||
|
||||
本文是独立 `etaf` 仓库的开发计划。架构契约见 [`architecture.zh.md`](architecture.zh.md);本文记录实施顺序、验证方式和明确的缺口。
|
||||
|
||||
## 1. 里程碑
|
||||
|
||||
| 里程碑 | 范围 | 完成证据 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 统一语法、`etaf-view`、`expr`、无状态 `:view`、核心 Host、Ebox 桥接 | 宏展开失败测试、ERT 契约测试、字节编译、挂载 smoke test |
|
||||
| P1 | slot collection、`:setup`、Component Scope、ref、生命周期、raw Ebox escape | 公共 Component 测试覆盖 setup-once、props、slot、销毁和回滚 |
|
||||
| P2 | Context、Behavior、focus/event 协议、Action、Effect、watch | 真实挂载事件路径、清理测试、身份和失败测试 |
|
||||
| P3 | ECSS 样式、主题、行内文本、resource/error boundary | 样式顺序、文本 surface、异步恢复和 buffer 发布测试 |
|
||||
| P4 | Data 能力、`etaf-ui`、数据源、Playground、按用例增加布局能力 | 独立加载扫描、包测试、应用示例和 CI |
|
||||
|
||||
## 2. P0 已交付切片
|
||||
|
||||
- 独立包仓库位于 `/Users/geekinney/IPARA/3-RESOURCES/emacs/config/github/etaf`。
|
||||
- 当前 checkout 阶段唯一依赖是同级 Ebox 包。
|
||||
- `etaf-view.el` 负责结构解析以及唯一的 `expr :value` 桥接。
|
||||
- `etaf-component.el` 负责无状态 `:view` 定义和上下文别名。
|
||||
- `etaf-renderer.el` 是唯一调用 Ebox 的模块,只使用 Ebox 公共构造和发布 API。
|
||||
- 核心 Host 保持最小且无样式:`text`、`fragment`、`container`、`row`、`column`、`stack`、`flex`、`spacer`。
|
||||
- 属性/子节点顺序、普通 Elisp 求值边界、表达式结果、Component props、别名冲突和挂载都由 `tests/etaf-tests.el` 覆盖。
|
||||
|
||||
## 3. P1 顺序
|
||||
|
||||
1. 固定 Component 调用和 slot collection 的内部表示。
|
||||
2. 实现 `(slot)`、`(slot FALLBACK...)` 默认 slot 投影,再实现 `(slot :name 'NAME CHILD...)` 具名 slot。
|
||||
3. 将末尾 children 和具名 slot 声明归一化为同一个 collection;拒绝动态、字符串、数字和 keyword slot 名称。
|
||||
4. 将 `:setup` 实现为只执行一次的普通 Elisp,建立 retained Component Scope 并返回零参数 render 函数。
|
||||
5. 增加具有稳定身份的 ref 和 computed,并支持 props 更新而不重新运行 setup。
|
||||
6. 在加入状态型应用示例之前,补齐明确的生命周期和失败候选销毁。
|
||||
7. 在普通 View 路径稳定后,加入带命名空间的 `raw-ebox :value` 后端逃生口。
|
||||
|
||||
P1 不得增加第二个 Template 入口、Variant 定义模型、`text` 的位置参数例外或基于 quote 的 View 约定。
|
||||
|
||||
## 4. P2–P4 依赖顺序
|
||||
|
||||
- Context 依赖 retained Component Scope 以及最近祖先的所有权规则。
|
||||
- Behavior 依赖稳定 Host 身份、事件、ref、Effect 和销毁流程。
|
||||
- Action 是业务变更,不得变成 View 节点或展示回调。
|
||||
- Effect 负责外部同步和清理;`watch` 仍是 Behavior 或 Effect 可以使用的响应式原语。
|
||||
- ECSS 和状态样式依赖冻结的 Ebox 属性边界以及文本 surface 映射。
|
||||
- Data 是 ETAF core 能力;SQLite、PostgreSQL、MySQL、REST、文件和 ORM 都是可选的具体数据源包。
|
||||
- `etaf-ui` 是一个官方 Component catalog;源码拆分只是维护边界,不是用户概念;DataGrid 是普通复合 Component。
|
||||
- `ebox-playground` 只依赖 Ebox;`etaf-playground` 依赖 ETAF,并可选依赖 `etaf-ui`;二者互不依赖。
|
||||
|
||||
## 5. 验证门禁
|
||||
|
||||
每个里程碑都必须先通过最小相关门禁,才能进入下一个边界:
|
||||
|
||||
```sh
|
||||
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make test EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
```
|
||||
|
||||
P0 还要求对错误语法产生干净的宏展开失败,并走真实的 `etaf-mount` 路径。P1 增加 setup/slot 生命周期测试;P2 增加挂载事件和清理路径;P3 增加文本 surface 和发布测试;P4 增加包边界扫描、独立加载和应用 smoke test。
|
||||
|
||||
只有辅助函数通过不算里程碑完成:必须同时验证公共宏展开、Component 构造、renderer 降级和 buffer 发布。
|
||||
|
||||
## 6. 停止条件
|
||||
|
||||
不得重新引入 `etaf-template`、Variant、公共 Control/Widget 分类、作为必需平级包的 `etaf-data`、抽象 `etaf-adapters` 或第二个公共 View 构造入口。在真实应用提供测量、布局和发布契约之前,不实现 Grid、Overlay 或完整 CSS 克隆。
|
||||
130
etaf-component.el
Normal file
130
etaf-component.el
Normal file
@ -0,0 +1,130 @@
|
||||
;;; etaf-component.el --- Stateless ETAF Component definitions -*- lexical-binding: t; -*-
|
||||
|
||||
;; SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; This first implementation slice owns only the beginner `:view' Component
|
||||
;; form. Stateful `:setup', Context, Behaviors, and lifecycle will be added
|
||||
;; behind the same Component boundary in later milestones; they are rejected
|
||||
;; here instead of silently receiving different semantics.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'etaf-view)
|
||||
|
||||
(define-error 'etaf-component-definition-error
|
||||
"Invalid ETAF Component definition"
|
||||
'etaf-view-error)
|
||||
|
||||
(defun etaf--component-definition-error (format-string &rest arguments)
|
||||
"Signal a Component definition error formatted from FORMAT-STRING."
|
||||
(signal 'etaf-component-definition-error
|
||||
(list (apply #'format format-string arguments))))
|
||||
|
||||
(defun etaf--parse-component-props (arguments)
|
||||
"Return prop names from Component argument declaration ARGUMENTS."
|
||||
(unless (proper-list-p arguments)
|
||||
(etaf--component-definition-error
|
||||
"Component prop declaration must be a proper list: %S" arguments))
|
||||
(let ((tail arguments)
|
||||
prop-names)
|
||||
(when tail
|
||||
(unless (eq (pop tail) '&key)
|
||||
(etaf--component-definition-error
|
||||
"Component props must use (&key NAME ...), got %S" arguments)))
|
||||
(dolist (entry tail)
|
||||
(unless (symbolp entry)
|
||||
(etaf--component-definition-error
|
||||
"Component props must be symbols without defaults: %S" entry))
|
||||
(when (keywordp entry)
|
||||
(etaf--component-definition-error
|
||||
"Component prop names must not be keywords: %S" entry))
|
||||
(when (memq entry '(nil t))
|
||||
(etaf--component-definition-error
|
||||
"Component prop name cannot be a constant: %S" entry))
|
||||
(when (memq entry prop-names)
|
||||
(etaf--component-definition-error
|
||||
"Duplicate Component prop: %S" entry))
|
||||
(push entry prop-names))
|
||||
(nreverse prop-names)))
|
||||
|
||||
(defun etaf--component-view-variable-bindings (props)
|
||||
"Return lexical bindings for Component PROP names."
|
||||
(mapcar (lambda (prop)
|
||||
`(,prop
|
||||
(plist-get etaf--component-props
|
||||
,(etaf--component-prop-key prop))))
|
||||
props))
|
||||
|
||||
;;;###autoload
|
||||
(defmacro etaf-define-component (name arguments &rest clauses)
|
||||
"Define stateless Component NAME with ARGUMENTS and CLAUSES.
|
||||
|
||||
The implemented P0 form is:
|
||||
|
||||
(etaf-define-component NAME (&key PROPS)
|
||||
:view VIEW)
|
||||
|
||||
VIEW is already structural View syntax and does not need quote. Attribute
|
||||
values inside VIEW are ordinary Elisp expressions. `:setup', `:styles',
|
||||
slots, and lifecycle are intentionally rejected until their owning runtime
|
||||
contracts are implemented."
|
||||
(declare (indent 2) (debug defun))
|
||||
(unless (symbolp name)
|
||||
(etaf--component-definition-error
|
||||
"Component name must be a symbol: %S" name))
|
||||
(let ((docstring (when (stringp (car clauses)) (pop clauses)))
|
||||
view-form
|
||||
saw-view)
|
||||
(while clauses
|
||||
(let ((keyword (pop clauses)))
|
||||
(unless (keywordp keyword)
|
||||
(etaf--component-definition-error
|
||||
"Expected a Component definition keyword, got %S" keyword))
|
||||
(pcase keyword
|
||||
(:view
|
||||
(when saw-view
|
||||
(etaf--component-definition-error
|
||||
"Component %S has duplicate :view" name))
|
||||
(unless clauses
|
||||
(etaf--component-definition-error
|
||||
"Component %S :view has no View" name))
|
||||
(setq view-form (pop clauses)
|
||||
saw-view t))
|
||||
(:setup
|
||||
(etaf--component-definition-error
|
||||
"Component %S :setup is reserved for the stateful milestone"
|
||||
name))
|
||||
(:styles
|
||||
(etaf--component-definition-error
|
||||
"Component %S :styles is reserved for the style milestone"
|
||||
name))
|
||||
(_
|
||||
(etaf--component-definition-error
|
||||
"Unknown Component definition keyword %S" keyword)))))
|
||||
(unless saw-view
|
||||
(etaf--component-definition-error
|
||||
"Component %S requires exactly one :view clause" name))
|
||||
(let* ((props (etaf--parse-component-props arguments))
|
||||
(definition-symbol
|
||||
(intern (format "%s--etaf-component-definition" name)))
|
||||
(render-lambda
|
||||
`(lambda (etaf--component-props _etaf--children)
|
||||
(let ,(etaf--component-view-variable-bindings props)
|
||||
,(etaf--compile-view-form view-form)))))
|
||||
`(progn
|
||||
(defconst ,definition-symbol
|
||||
(etaf--component-spec-create
|
||||
:name ',name
|
||||
:props ',props
|
||||
:render ,render-lambda)
|
||||
,(or docstring
|
||||
(format "Definition of ETAF Component `%s'." name)))
|
||||
(etaf--register-component ',name ,definition-symbol)
|
||||
',name))))
|
||||
|
||||
(provide 'etaf-component)
|
||||
|
||||
;;; etaf-component.el ends here
|
||||
138
etaf-renderer.el
Normal file
138
etaf-renderer.el
Normal file
@ -0,0 +1,138 @@
|
||||
;;; etaf-renderer.el --- ETAF to Ebox rendering bridge -*- lexical-binding: t; -*-
|
||||
|
||||
;; SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; The renderer is the only first-slice module that knows Ebox. It accepts
|
||||
;; normalized View values, resolves `expr', turns semantic ETAF properties into
|
||||
;; Ebox properties, and delegates measurement, layout, painting, and buffer
|
||||
;; publication to Ebox's public API.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'ebox)
|
||||
(require 'etaf-view)
|
||||
|
||||
(define-error 'etaf-renderer-error "ETAF rendering error")
|
||||
|
||||
(defconst etaf--semantic-props
|
||||
'(:class :id :role :disabled :tab-index :ref :use
|
||||
:aria-label :aria-description :on-press :on-key-down :on-mouse-down
|
||||
:on-mouse-drag :on-wheel :on-input)
|
||||
"ETAF semantic properties that are not Ebox box properties.")
|
||||
|
||||
(defun etaf--event-property-p (property)
|
||||
"Return non-nil when PROPERTY is an ETAF event callback property."
|
||||
(and (keywordp property)
|
||||
(string-prefix-p "on-" (substring (symbol-name property) 1))))
|
||||
|
||||
(defun etaf--ebox-properties (props)
|
||||
"Translate ETAF PROPS into a property list accepted by Ebox."
|
||||
(let (ebox-props surface-properties)
|
||||
(while props
|
||||
(let ((key (pop props))
|
||||
(value (pop props)))
|
||||
(cond
|
||||
((eq key :face)
|
||||
(setq surface-properties
|
||||
(append surface-properties (list 'face value))))
|
||||
((eq key :surface-properties)
|
||||
(setq surface-properties
|
||||
(append surface-properties value)))
|
||||
((eq key :content)
|
||||
(signal 'etaf-renderer-error
|
||||
(list "Use View children for content, not :content")))
|
||||
((or (memq key etaf--semantic-props)
|
||||
(etaf--event-property-p key)
|
||||
(eq key :styles))
|
||||
nil)
|
||||
(t
|
||||
(setq ebox-props (append ebox-props (list key value)))))))
|
||||
(when surface-properties
|
||||
(setq ebox-props
|
||||
(append ebox-props
|
||||
(list :surface-properties surface-properties))))
|
||||
ebox-props))
|
||||
|
||||
(defun etaf--render-resolved-value (value)
|
||||
"Render one resolved string or View VALUE to a list of Ebox nodes."
|
||||
(cond
|
||||
((stringp value)
|
||||
(list (ebox-create :content value)))
|
||||
((etaf--view-node-p value)
|
||||
(etaf--render-node value))
|
||||
(t
|
||||
(signal 'etaf-renderer-error
|
||||
(list (format "Unresolved View value reached renderer: %S"
|
||||
value))))))
|
||||
|
||||
(defun etaf--render-values (value)
|
||||
"Resolve and render VALUE to a flat list of Ebox nodes."
|
||||
(cl-mapcan #'etaf--render-resolved-value
|
||||
(etaf--resolve-value value)))
|
||||
|
||||
(defun etaf--layout-node (name props nodes)
|
||||
"Build layout NAME with Ebox PROPS around child NODES."
|
||||
(cond
|
||||
((eq name 'row)
|
||||
(if (null props)
|
||||
(apply #'ebox-row nodes)
|
||||
(ebox-build (append (list 'row) props nodes))))
|
||||
((memq name '(column container stack))
|
||||
(if (null props)
|
||||
(apply #'ebox-column nodes)
|
||||
(ebox-build (append (list 'column) props nodes))))
|
||||
((eq name 'flex)
|
||||
(apply #'ebox-flex (append props nodes)))
|
||||
(t
|
||||
(signal 'etaf-renderer-error
|
||||
(list (format "Not a layout Host: %S" name))))))
|
||||
|
||||
(defun etaf--render-node (node)
|
||||
"Render normalized Host NODE to a list of Ebox nodes."
|
||||
(let* ((name (etaf--view-node-name node))
|
||||
(props (etaf--ebox-properties (etaf--view-node-props node)))
|
||||
(children (etaf--resolve-value (etaf--view-node-children node))))
|
||||
(pcase name
|
||||
('text
|
||||
(unless (cl-every #'stringp children)
|
||||
(signal 'etaf-renderer-error
|
||||
(list "text children must resolve to strings")))
|
||||
(list (apply #'ebox-create
|
||||
:content (apply #'concat children)
|
||||
props)))
|
||||
('spacer
|
||||
(when children
|
||||
(signal 'etaf-renderer-error
|
||||
(list "spacer cannot have children")))
|
||||
(list (apply #'ebox-spacer props)))
|
||||
('fragment
|
||||
(etaf--render-values children))
|
||||
((or 'row 'column 'container 'stack 'flex)
|
||||
(list (etaf--layout-node name props (etaf--render-values children))))
|
||||
(_
|
||||
(signal 'etaf-renderer-error
|
||||
(list (format "Unknown Host reached renderer: %S" name)))))))
|
||||
|
||||
;;;###autoload
|
||||
(defun etaf-render (view)
|
||||
"Lower normalized VIEW to one Ebox node.
|
||||
|
||||
Multiple root values are placed in a vertical column. Ebox remains the owner
|
||||
of all measurement, layout, painting, and identity details."
|
||||
(let ((nodes (etaf--render-values view)))
|
||||
(cond
|
||||
((null nodes) (ebox-spacer))
|
||||
((null (cdr nodes)) (car nodes))
|
||||
(t (apply #'ebox-column nodes)))))
|
||||
|
||||
;;;###autoload
|
||||
(defun etaf-mount (buffer-or-name view)
|
||||
"Render VIEW into BUFFER-OR-NAME and return the live buffer."
|
||||
(ebox-render-to-buffer buffer-or-name (etaf-render view)))
|
||||
|
||||
(provide 'etaf-renderer)
|
||||
|
||||
;;; etaf-renderer.el ends here
|
||||
349
etaf-view.el
Normal file
349
etaf-view.el
Normal file
@ -0,0 +1,349 @@
|
||||
;;; etaf-view.el --- Unified ETAF View grammar -*- lexical-binding: t; -*-
|
||||
|
||||
;; SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; This file owns the small structural language shared by Hosts and
|
||||
;; Components. It deliberately does not render, mutate buffers, or implement
|
||||
;; lifecycle. `etaf-view' compiles one structural form into a short-lived
|
||||
;; View value; the renderer lowers that value to Ebox later.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
|
||||
(define-error 'etaf-view-error "Invalid ETAF View")
|
||||
(define-error 'etaf-view-syntax-error "Invalid ETAF View syntax"
|
||||
'etaf-view-error)
|
||||
(define-error 'etaf-component-call-error "Invalid ETAF Component call"
|
||||
'etaf-view-error)
|
||||
|
||||
(cl-defstruct (etaf--view-node
|
||||
(:constructor etaf--view-node-create))
|
||||
"Internal normalized description of one Host View."
|
||||
name
|
||||
props
|
||||
children)
|
||||
|
||||
(cl-defstruct (etaf--expr
|
||||
(:constructor etaf--expr-create))
|
||||
"Internal executable child expression."
|
||||
thunk)
|
||||
|
||||
(cl-defstruct (etaf--component-spec
|
||||
(:constructor etaf--component-spec-create))
|
||||
"Internal definition of one stateless Component."
|
||||
name
|
||||
props
|
||||
render)
|
||||
|
||||
(cl-defstruct (etaf--component-call
|
||||
(:constructor etaf--component-call-create))
|
||||
"Internal Component invocation retained until rendering."
|
||||
spec
|
||||
props
|
||||
children)
|
||||
|
||||
(defconst etaf--host-names
|
||||
'(text fragment container row column stack flex spacer)
|
||||
"Minimal unstyled Hosts implemented by ETAF core.
|
||||
|
||||
Product Components such as Button belong to `etaf-ui'; they are not added to
|
||||
this list merely to make a demo convenient.")
|
||||
|
||||
(defconst etaf--host-marker 'etaf--host
|
||||
"Value stored in the View registry for a built-in Host.")
|
||||
|
||||
(defconst etaf--ordinary-elisp-heads
|
||||
'(and or not if when unless cond case pcase
|
||||
let let* letrec letrec* prog prog1 prog2 progn
|
||||
while dolist dotimes cl-loop mapcar mapc mapcan
|
||||
save-excursion save-restriction save-window-excursion
|
||||
condition-case condition-case-unless-debug unwind-protect
|
||||
catch throw signal error user-error quote function lambda
|
||||
setq setq-default setf psetf psetq incf decf push pop
|
||||
funcall apply apply-partially progn eval macroexpand)
|
||||
"Elisp heads that must appear inside `expr', not as View children.")
|
||||
|
||||
(defvar etaf--view-registry (make-hash-table :test #'eq)
|
||||
"Registry of Hosts and Components used by structural View calls.")
|
||||
|
||||
(defun etaf--syntax-error (format-string &rest arguments)
|
||||
"Signal a View syntax error formatted from FORMAT-STRING and ARGUMENTS."
|
||||
(signal 'etaf-view-syntax-error
|
||||
(list (apply #'format format-string arguments))))
|
||||
|
||||
(defun etaf--component-error (format-string &rest arguments)
|
||||
"Signal a Component call error formatted from FORMAT-STRING and ARGUMENTS."
|
||||
(signal 'etaf-component-call-error
|
||||
(list (apply #'format format-string arguments))))
|
||||
|
||||
(defun etaf--keyword-for-name (name)
|
||||
"Return the property keyword corresponding to symbol NAME."
|
||||
(intern (concat ":" (symbol-name name))))
|
||||
|
||||
(defun etaf--component-alias (name)
|
||||
"Return the public View alias for canonical Component NAME, or nil.
|
||||
|
||||
Canonical names may carry the `etaf-' package prefix. The prefix is omitted
|
||||
in View syntax unless doing so would collide with an Elisp function, special
|
||||
form, or core Host. A collision receives a semantic `-view' alias."
|
||||
(when (and (symbolp name)
|
||||
(string-prefix-p "etaf-" (symbol-name name)))
|
||||
(let* ((suffix (substring (symbol-name name) (length "etaf-")))
|
||||
(candidate (intern suffix)))
|
||||
(cond
|
||||
((or (memq candidate etaf--host-names)
|
||||
(special-form-p candidate)
|
||||
(fboundp candidate))
|
||||
(intern (concat suffix "-view")))
|
||||
(t candidate)))))
|
||||
|
||||
(defun etaf--register-component (name spec)
|
||||
"Register Component SPEC under canonical NAME and its public alias."
|
||||
(unless (and (symbolp name) (etaf--component-spec-p spec))
|
||||
(signal 'wrong-type-argument (list 'etaf--component-spec-p spec)))
|
||||
(let ((existing (gethash name etaf--view-registry)))
|
||||
(when (eq existing etaf--host-marker)
|
||||
(etaf--component-error
|
||||
"Component %S conflicts with a core Host" name)))
|
||||
(puthash name spec etaf--view-registry)
|
||||
(when-let ((alias (etaf--component-alias name)))
|
||||
(let ((existing (gethash alias etaf--view-registry)))
|
||||
(when (and existing (not (eq existing spec)))
|
||||
(etaf--component-error
|
||||
"Component alias %S is already registered" alias)))
|
||||
(puthash alias spec etaf--view-registry))
|
||||
spec)
|
||||
|
||||
(defun etaf--register-core-hosts ()
|
||||
"Register the core Host names and explicit prefixed spellings."
|
||||
(dolist (name etaf--host-names)
|
||||
(puthash name etaf--host-marker etaf--view-registry)
|
||||
(puthash (intern (concat "etaf-" (symbol-name name)))
|
||||
etaf--host-marker
|
||||
etaf--view-registry)))
|
||||
|
||||
(defun etaf--canonical-host-name (name)
|
||||
"Return the unprefixed renderer name for Host NAME."
|
||||
(if (and (symbolp name)
|
||||
(string-prefix-p "etaf-" (symbol-name name)))
|
||||
(let ((short-name (intern (substring (symbol-name name) 5))))
|
||||
(if (memq short-name etaf--host-names)
|
||||
short-name
|
||||
name))
|
||||
name))
|
||||
|
||||
(etaf--register-core-hosts)
|
||||
|
||||
(defun etaf--validate-property-plist (props)
|
||||
"Validate evaluated View PROPS and return a defensive copy."
|
||||
(unless (and (proper-list-p props) (zerop (% (length props) 2)))
|
||||
(etaf--component-error "View properties must be keyword/value pairs: %S"
|
||||
props))
|
||||
(let ((copy nil)
|
||||
(seen nil)
|
||||
(tail props))
|
||||
(while tail
|
||||
(let ((key (pop tail))
|
||||
(value (pop tail)))
|
||||
(unless (keywordp key)
|
||||
(etaf--component-error "View property name must be a keyword: %S"
|
||||
key))
|
||||
(when (memq key seen)
|
||||
(etaf--component-error "Duplicate View property: %S" key))
|
||||
(push key seen)
|
||||
(setq copy (append copy (list key value)))))
|
||||
copy))
|
||||
|
||||
(defun etaf--validate-key (key)
|
||||
"Validate a Host identity KEY and return it."
|
||||
(unless (or (null key) (symbolp key) (stringp key)
|
||||
(integerp key) (floatp key))
|
||||
(etaf--component-error
|
||||
"View keys must be immutable scalar values: %S" key))
|
||||
key)
|
||||
|
||||
(defun etaf--parse-attributes-and-children (items)
|
||||
"Split structural ITEMS into `(PROPS . CHILDREN)'.
|
||||
|
||||
All keyword attributes must precede the first non-keyword child. Values are
|
||||
returned as unevaluated forms because they are ordinary Elisp expressions in
|
||||
the generated code."
|
||||
(let (props children seen children-started)
|
||||
(while items
|
||||
(let ((item (pop items)))
|
||||
(if (keywordp item)
|
||||
(progn
|
||||
(when children-started
|
||||
(etaf--syntax-error
|
||||
"Attributes must precede children; found %S after a child"
|
||||
item))
|
||||
(unless items
|
||||
(etaf--syntax-error "Missing value for View property %S"
|
||||
item))
|
||||
(when (memq item seen)
|
||||
(etaf--syntax-error "Duplicate View property %S" item))
|
||||
(push item seen)
|
||||
(let ((value (pop items)))
|
||||
(push item props)
|
||||
(push value props)))
|
||||
(setq children-started t)
|
||||
(push item children))))
|
||||
(cons (nreverse props) (nreverse children))))
|
||||
|
||||
(defun etaf--parse-expr-form (items)
|
||||
"Return the value form from an `expr' child with ITEMS.
|
||||
|
||||
`expr' intentionally has one property, `:value', and no children."
|
||||
(let ((parts (etaf--parse-attributes-and-children items)))
|
||||
(when (cdr parts)
|
||||
(etaf--syntax-error "expr accepts :value and no children"))
|
||||
(let ((props (car parts)))
|
||||
(unless (and (= (length props) 2)
|
||||
(eq (car props) :value))
|
||||
(etaf--syntax-error
|
||||
"expr accepts exactly one attribute: :value"))
|
||||
(cadr props))))
|
||||
|
||||
(defun etaf--ordinary-expression-head-p (head)
|
||||
"Return non-nil when HEAD denotes ordinary Elisp computation."
|
||||
(or (memq head etaf--ordinary-elisp-heads)
|
||||
(special-form-p head)
|
||||
(and (symbolp head) (fboundp head))))
|
||||
|
||||
(defun etaf--compile-expr-form (items)
|
||||
"Compile an `expr' form with ITEMS into an executable View value."
|
||||
`(etaf--expr-create
|
||||
:thunk (lambda () ,(etaf--parse-expr-form items))))
|
||||
|
||||
(defun etaf--compile-child-form (form)
|
||||
"Compile structural child FORM into code returning a View value."
|
||||
(cond
|
||||
((null form) nil)
|
||||
((stringp form) `(quote ,form))
|
||||
((and (consp form) (eq (car form) 'expr))
|
||||
(etaf--compile-expr-form (cdr form)))
|
||||
((and (consp form) (symbolp (car form)))
|
||||
(when (etaf--ordinary-expression-head-p (car form))
|
||||
(etaf--syntax-error
|
||||
"Elisp expression %S must be inside (expr :value ...)" (car form)))
|
||||
(etaf--compile-view-form form))
|
||||
((consp form)
|
||||
(etaf--syntax-error "Invalid View child form: %S" form))
|
||||
(t
|
||||
(etaf--syntax-error
|
||||
"View children must be strings, nil, View forms, or expr results: %S"
|
||||
form))))
|
||||
|
||||
(defun etaf--compile-view-form (form)
|
||||
"Compile one structural View FORM into runtime construction code."
|
||||
(cond
|
||||
((null form) nil)
|
||||
((stringp form) `(quote ,form))
|
||||
((not (and (consp form) (symbolp (car form))))
|
||||
(etaf--syntax-error "View form must start with a symbol: %S" form))
|
||||
((eq (car form) 'expr)
|
||||
(etaf--compile-expr-form (cdr form)))
|
||||
((eq (car form) 'slot)
|
||||
(etaf--syntax-error
|
||||
"slot is not implemented in the P0 package; use ordinary children for now"))
|
||||
((eq (car form) 'raw-ebox)
|
||||
(etaf--syntax-error
|
||||
"raw-ebox is not implemented in the P0 package"))
|
||||
(t
|
||||
(let* ((parts (etaf--parse-attributes-and-children (cdr form)))
|
||||
(props (car parts))
|
||||
(children (cdr parts)))
|
||||
`(etaf--view-call ',(car form)
|
||||
(list ,@props)
|
||||
(list ,@(mapcar #'etaf--compile-child-form children)))))))
|
||||
|
||||
;;;###autoload
|
||||
(defmacro etaf-view (form)
|
||||
"Construct a normalized ETAF View from structural FORM.
|
||||
|
||||
FORM uses one grammar for Hosts and Component calls:
|
||||
|
||||
(NAME :PROPERTY VALUE ... CHILD ...)
|
||||
|
||||
Properties must come first and children must come last. Property values are
|
||||
ordinary Elisp expressions. `expr' is the only computation bridge in the
|
||||
child region and accepts only `:value'."
|
||||
(declare (indent 1) (debug (form)))
|
||||
(etaf--compile-view-form form))
|
||||
|
||||
(defun etaf--component-prop-key (name)
|
||||
"Return the keyword used to pass Component prop NAME."
|
||||
(if (keywordp name)
|
||||
name
|
||||
(etaf--keyword-for-name name)))
|
||||
|
||||
(defun etaf--validate-component-props (spec props)
|
||||
"Validate Component SPEC against evaluated property PLIST PROPS."
|
||||
(let ((allowed (mapcar #'etaf--component-prop-key
|
||||
(etaf--component-spec-props spec)))
|
||||
(tail (etaf--validate-property-plist props)))
|
||||
(while tail
|
||||
(let ((key (pop tail)))
|
||||
(pop tail)
|
||||
(unless (memq key allowed)
|
||||
(etaf--component-error
|
||||
"Unknown prop %S for Component %S"
|
||||
key (etaf--component-spec-name spec)))))
|
||||
props))
|
||||
|
||||
(defun etaf--view-call (name props children)
|
||||
"Construct a Host or Component named NAME from PROPS and CHILDREN."
|
||||
(unless (symbolp name)
|
||||
(etaf--syntax-error "View name must be a symbol: %S" name))
|
||||
(setq props (etaf--validate-property-plist props))
|
||||
(let ((entry (gethash name etaf--view-registry)))
|
||||
(cond
|
||||
((eq entry etaf--host-marker)
|
||||
(when (plist-member props :key)
|
||||
(etaf--validate-key (plist-get props :key)))
|
||||
(etaf--view-node-create
|
||||
:name (etaf--canonical-host-name name)
|
||||
:props props
|
||||
:children children))
|
||||
((etaf--component-spec-p entry)
|
||||
(etaf--validate-component-props entry props)
|
||||
(etaf--component-call-create
|
||||
:spec entry
|
||||
:props props
|
||||
:children children))
|
||||
(t
|
||||
(etaf--component-error "Unknown ETAF Host or Component: %S" name)))))
|
||||
|
||||
(defun etaf--resolve-value (value)
|
||||
"Resolve VALUE to a flat list of string or View leaves.
|
||||
|
||||
This is deliberately a value normalizer, not an evaluator. Only `expr'
|
||||
thunks are executed; a quoted list returned by an expression is treated as a
|
||||
sequence and each member must already be a valid View value."
|
||||
(cond
|
||||
((null value) nil)
|
||||
((or (stringp value)
|
||||
(etaf--view-node-p value))
|
||||
(list value))
|
||||
((etaf--expr-p value)
|
||||
(etaf--resolve-value (funcall (etaf--expr-thunk value))))
|
||||
((etaf--component-call-p value)
|
||||
(etaf--resolve-value
|
||||
(funcall (etaf--component-spec-render
|
||||
(etaf--component-call-spec value))
|
||||
(etaf--component-call-props value)
|
||||
(etaf--component-call-children value))))
|
||||
((proper-list-p value)
|
||||
(cl-mapcan #'etaf--resolve-value value))
|
||||
(t
|
||||
(signal 'etaf-view-error
|
||||
(list (format
|
||||
"View values must be strings, nil, Views, or sequences: %S"
|
||||
value))))))
|
||||
|
||||
(provide 'etaf-view)
|
||||
|
||||
;;; etaf-view.el ends here
|
||||
25
etaf.el
Normal file
25
etaf.el
Normal file
@ -0,0 +1,25 @@
|
||||
;;; etaf.el --- Text application framework facade -*- lexical-binding: t; -*-
|
||||
|
||||
;; SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
;; Author: ETAF contributors
|
||||
;; Version: 0.1.0
|
||||
;; Package-Requires: ((emacs "29.1") (ebox "1.0.1"))
|
||||
;; Keywords: ui, tools, convenience
|
||||
;; URL: https://github.com/ginqi7/etaf
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; ETAF is a Component and View layer above the independent Ebox renderer.
|
||||
;; This package is intentionally starting with the grammar and stateless View
|
||||
;; path before adding retained state, events, styles, and data capabilities.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'etaf-view)
|
||||
(require 'etaf-component)
|
||||
(require 'etaf-renderer)
|
||||
|
||||
(provide 'etaf)
|
||||
|
||||
;;; etaf.el ends here
|
||||
162
tests/etaf-tests.el
Normal file
162
tests/etaf-tests.el
Normal file
@ -0,0 +1,162 @@
|
||||
;;; etaf-tests.el --- ETAF P0 contract tests -*- lexical-binding: t; -*-
|
||||
|
||||
;; SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'ert)
|
||||
(require 'etaf)
|
||||
|
||||
(defun etaf-test--render-text (view)
|
||||
"Return plain rendered text for VIEW."
|
||||
(substring-no-properties (ebox-render (etaf-render view))))
|
||||
|
||||
(etaf-define-component etaf-test-badge (&key label)
|
||||
"Render LABEL as a small semantic test Component."
|
||||
:view
|
||||
(text :face 'bold (expr :value label)))
|
||||
|
||||
(etaf-define-component etaf-list (&key label)
|
||||
"Render LABEL using the collision-safe `list-view' alias."
|
||||
:view
|
||||
(text (expr :value label)))
|
||||
|
||||
(ert-deftest etaf-view-property-region-precedes-children ()
|
||||
"Reject a property that appears after a structural child."
|
||||
(should-error
|
||||
(macroexpand '(etaf-view (text "Hello" :face 'bold)))
|
||||
:type 'etaf-view-syntax-error))
|
||||
|
||||
(ert-deftest etaf-view-ordinary-control-flow-belongs-in-expr ()
|
||||
"Reject ordinary Elisp control flow in the child region."
|
||||
(should-error
|
||||
(macroexpand '(etaf-view (text (if checked "yes" "no"))))
|
||||
:type 'etaf-view-syntax-error))
|
||||
|
||||
(ert-deftest etaf-view-quote-is-not-needed-for-structure ()
|
||||
"Construct an unquoted structural View and preserve quoted data values."
|
||||
(let ((view (etaf-view (text :face 'bold "Hello"))))
|
||||
(should (etaf--view-node-p view))
|
||||
(should (equal 'bold (plist-get (etaf--view-node-props view) :face)))
|
||||
(should (equal "Hello" (etaf-test--render-text view)))))
|
||||
|
||||
(ert-deftest etaf-view-attribute-values-are-ordinary-elisp ()
|
||||
"Evaluate an attribute expression without an extra evaluation wrapper."
|
||||
(let ((face 'bold)
|
||||
(label "Ready"))
|
||||
(let ((view (etaf-view (text :face face (expr :value label)))))
|
||||
(should (equal "Ready" (etaf-test--render-text view))))))
|
||||
|
||||
(ert-deftest etaf-view-expr-evaluates-control-flow ()
|
||||
"Use the sole child computation bridge for ordinary control flow."
|
||||
(let ((checked t))
|
||||
(should
|
||||
(equal "yes"
|
||||
(etaf-test--render-text
|
||||
(etaf-view
|
||||
(text (expr :value (if checked "yes" "no")))))))))
|
||||
|
||||
(ert-deftest etaf-view-expr-can-return-a-view ()
|
||||
"Allow an expression to return a dynamically constructed View."
|
||||
(let ((open t))
|
||||
(should
|
||||
(equal "Details"
|
||||
(etaf-test--render-text
|
||||
(etaf-view
|
||||
(column
|
||||
(expr
|
||||
:value
|
||||
(when open
|
||||
(etaf-view (text "Details")))))))))))
|
||||
|
||||
(ert-deftest etaf-view-expr-can-return-a-sequence ()
|
||||
"Flatten a sequence returned by `expr' into the surrounding Host."
|
||||
(should
|
||||
(equal "AB"
|
||||
(etaf-test--render-text
|
||||
(etaf-view
|
||||
(text (expr :value (list "A" "B"))))))))
|
||||
|
||||
(ert-deftest etaf-view-expr-rejects-extra-properties ()
|
||||
"Reject an expr property other than `:value'."
|
||||
(should-error
|
||||
(macroexpand '(etaf-view (text (expr :test checked :value "yes"))))
|
||||
:type 'etaf-view-syntax-error))
|
||||
|
||||
(ert-deftest etaf-view-quoted-view-data-is-not-executed ()
|
||||
"Reject quoted View data when it reaches the executable child boundary."
|
||||
(should-error
|
||||
(macroexpand '(etaf-view (text (quote (text "not-a-view")))))
|
||||
:type 'etaf-view-syntax-error))
|
||||
|
||||
(ert-deftest etaf-view-layouts-lower-to-ebox ()
|
||||
"Lower row and column Hosts through Ebox's public constructors."
|
||||
(should
|
||||
(equal "AB"
|
||||
(etaf-test--render-text
|
||||
(etaf-view (row (text "A") (text "B"))))))
|
||||
(should
|
||||
(equal "A\nB"
|
||||
(etaf-test--render-text
|
||||
(etaf-view (column (text "A") (text "B")))))))
|
||||
|
||||
(ert-deftest etaf-view-prefixed-host-alias-lowers-to-canonical-host ()
|
||||
"Resolve an explicit `etaf-' Host spelling to its core Host name."
|
||||
(should
|
||||
(equal "Hello"
|
||||
(etaf-test--render-text
|
||||
(etaf-view (etaf-text "Hello"))))))
|
||||
|
||||
(ert-deftest etaf-component-view-renders-props ()
|
||||
"Render a stateless Component from its declared props."
|
||||
(let ((view (etaf-view (etaf-test-badge :label "Ready"))))
|
||||
(should (etaf--component-call-p view))
|
||||
(should (equal "Ready" (etaf-test--render-text view)))))
|
||||
|
||||
(ert-deftest etaf-component-prefixed-name-has-short-alias ()
|
||||
"Resolve an `etaf-' Component through its public View alias."
|
||||
(should
|
||||
(equal "Ready"
|
||||
(etaf-test--render-text
|
||||
(etaf-view (test-badge :label "Ready"))))))
|
||||
|
||||
(ert-deftest etaf-component-alias-avoids-elisp-collision ()
|
||||
"Use a semantic alias when the unprefixed name is an Elisp function."
|
||||
(should
|
||||
(equal "Items"
|
||||
(etaf-test--render-text
|
||||
(etaf-view (list-view :label "Items"))))))
|
||||
|
||||
(ert-deftest etaf-component-rejects-unknown-props ()
|
||||
"Reject undeclared Component props at the Component boundary."
|
||||
(should-error
|
||||
(etaf-view (etaf-test-badge :unknown t))
|
||||
:type 'etaf-component-call-error))
|
||||
|
||||
(ert-deftest etaf-component-rejects-unimplemented-definition-forms ()
|
||||
"Reject future clauses instead of assigning them accidental semantics."
|
||||
(should-error
|
||||
(macroexpand
|
||||
'(etaf-define-component future-component (&key value)
|
||||
:setup value))
|
||||
:type 'etaf-component-definition-error)
|
||||
(should-error
|
||||
(macroexpand
|
||||
'(etaf-define-component styled-component ()
|
||||
:styles (styles ("&" :color "red"))
|
||||
:view (text "x")))
|
||||
:type 'etaf-component-definition-error))
|
||||
|
||||
(ert-deftest etaf-mount-publishes-through-ebox ()
|
||||
"Mount a View into a buffer using Ebox's buffer publication API."
|
||||
(let ((buffer-name " *etaf-test-mount*"))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(etaf-mount buffer-name
|
||||
(etaf-view (text :color "#F4F6FB" "Mounted")))
|
||||
(with-current-buffer buffer-name
|
||||
(should (equal "Mounted" (buffer-string)))))
|
||||
(when-let ((buffer (get-buffer buffer-name)))
|
||||
(kill-buffer buffer)))))
|
||||
|
||||
;;; etaf-tests.el ends here
|
||||
Loading…
Reference in New Issue
Block a user