Implement retained ETAF reactive runtime

This commit is contained in:
Kinneyzhang 2026-08-22 06:19:00 +08:00
parent 30aa141cca
commit abb947e0bc
15 changed files with 8157 additions and 280 deletions

View File

@ -1,5 +1,5 @@
EMACS ?= emacs
LOAD_PATH = -L . -L examples -L ../ebox
LOAD_PATH = -L . -L examples -L ../ebox -L ../tp -L ../ecss
SOURCES = etaf-view.el etaf-component.el etaf-reactive.el etaf-context.el etaf-resource.el etaf-data.el etaf-renderer.el etaf-runtime.el etaf-behavior.el etaf-actions.el etaf-events.el etaf.el
EXAMPLES = examples/etaf-counter-example.el examples/etaf-data-example.el examples/etaf-resource-example.el
TESTS = tests/etaf-tests.el tests/etaf-resource-tests.el tests/etaf-data-tests.el tests/etaf-examples-tests.el

View File

@ -109,6 +109,8 @@ Attribute values do not need an `expr` wrapper. `expr` exists only because the c
It evaluates the expression, then accepts a string, View, sequence, or `nil`. It creates no Ebox wrapper, identity, lifecycle, watcher, or effect. `if`, `when`, `cond`, `let`, `mapcar`, `cl-loop`, and other Elisp forms remain ordinary Elisp inside `:value`.
The current core has no file-facing `.etaf` pair loader. `etaf-define-component` is the structure/style/behavior unit: its View defines structure, `:styles` owns presentation rules, and `:setup` owns retained state, events, and lifecycle behavior. A future `.etaf` single-file component format belongs in a compiler layer that lowers into this same public View and Component contract; it is not a second runtime grammar.
## 4. Components
The public definition macro has exactly three keywords:
@ -127,6 +129,8 @@ The public definition macro has exactly three keywords:
`:view` and `:setup` are mutually exclusive. `:styles` is optional and may appear once. Props are the only declared business inputs; ordinary trailing children and named slots are normalized separately into the Component's slot collection.
The Component definition is the current structure/style/behavior boundary. Keep dynamic state, Action callbacks, and lifecycle work in `:setup`; keep static presentation in `:styles`. A future `.etaf` SFC compiler may produce these definitions, but the Runtime does not load `.etaf` files directly.
### 4.1 Stateless and stateful forms
```elisp
@ -342,6 +346,8 @@ Keys are stable ordinary symbols. The nearest ancestor wins; a missing required
Explicit Host props override Component styles, and Component styles override Theme defaults.
Style ownership follows the Component that authored each View node. A nested Component is a style boundary: parent rules do not penetrate its internals. Caller-provided slot content is rendered with the caller scope, while a child's own fallback content remains in the child scope.
### 9.2 Data
Data is a core ETAF capability, not a second framework package. A Data Source is a small capability plist:
@ -417,7 +423,30 @@ etaf-playground → etaf
Optional storage integrations should use explicit package names such as a concrete SQLite or PostgreSQL source. A generic adapter package would add a name without owning a stable behavior, so it is not part of the public model.
## 11. Extension rule
## 11. Retained publication and fixed-point safety
Mounted Component, expr, slot, fragment, raw, inline, and Root owners publish
through one outer reactive dispatch. A local owner evaluates into a candidate
generation and Ebox logical replacement; disjoint owners are coalesced into
one TP/Ebox publication. The Root owner is the only complete-root adapter.
Each Runtime flush records a candidate-aware effect tuple containing the
generation id, effect-to-source edges and source versions, plus an immutable
semantic-node stamp for candidate input/context/output facts. A repeated tuple
reports the ordered effect/edge path of the non-converging graph. The flush
bound is derived from candidate nodes, dependency edges, and source entries;
it is not a fixed multiplier of an arbitrary threshold. Rollback discards the
flush-local stamps, so the same failed state can be retried as a new
transaction.
Behavior installers use target-specific identity: reactive values and
functions are compared with `eq`, while scalar attributes use value equality.
Each installed Behavior also receives a stable `(mount-epoch resource-id)`
address. Generation membership and the Runtime resource registry determine
which installer is authoritative; a failed candidate removes only its staged
Behavior resource and runs its contained cleanup.
## 12. Extension rule
Before adding a new concept, choose the smallest existing owner:

View File

@ -108,6 +108,8 @@ ATTRIBUTE = :KEY VALUE
它执行表达式然后接受字符串、View、View 序列或 `nil`。它不创建 Ebox wrapper、identity、生命周期、watch 或 effect。`if`、`when`、`cond`、`let`、`mapcar`、`cl-loop` 等 Elisp 形式仍然只是 `:value` 中的普通 Elisp。
当前 core 没有面向文件的 `.etaf` pair loader。`etaf-define-component` 才是结构/样式/行为单元View 定义结构,`:styles` 负责 presentation`:setup` 负责 retained state、事件和生命周期行为。未来的 `.etaf` SFC 属于把结果 lowering 到同一套公共 View/Component 契约的 compiler layer而不是第二套 Runtime 语法。
## 4. Component
公共定义宏只有三个关键字:
@ -126,6 +128,8 @@ ATTRIBUTE = :KEY VALUE
`:view``:setup` 互斥;`:styles` 可选且最多出现一次。Props 是唯一需要声明的业务输入;普通尾部子节点和命名 slot 会被规范化为 Component 的 slot 集合。
Component definition 是当前结构/样式/行为边界动态状态、Action callback 和生命周期工作放进 `:setup`,静态 presentation 放进 `:styles`。未来 `.etaf` SFC compiler 可以生成这些 definition但 Runtime 不会直接加载 `.etaf` 文件。
### 4.1 无状态与状态型形式
```elisp
@ -341,6 +345,8 @@ key 使用稳定的普通 symbol。最近的祖先优先缺失的 required ke
显式 Host props 覆盖 Component stylesComponent styles 覆盖 Theme defaults。
样式所有权跟随创建每个 View node 的 Component。嵌套 Component 是样式边界:父规则不会穿透其内部。调用者提供的 slot 内容使用 caller scope子组件自有的 fallback 内容保留在 child scope。
### 9.2 Data
Data 是 ETAF core 能力不是需要用户额外学习的第二套框架。Data Source 是一个小的 capability plist
@ -416,7 +422,27 @@ etaf-playground → etaf
可选存储集成应使用具体名称,例如 SQLite 或 PostgreSQL source。通用 adapter 包无法拥有稳定的行为,只会增加用户需要记忆的名称,因此不属于公共模型。
## 11. 拓展规则
## 11. 保留式发布与 fixed-point 安全
mounted Component、expr、slot、fragment、raw、inline 和 Root owner 都经过同一
个外层 reactive dispatch。局部 owner 先生成 candidate generation 和 Ebox logical
replacement不相交 owner 会合并为一次 TP/Ebox publication。只有 Root owner
可以进入 complete-root adapter。
每次 Runtime flush 都记录 candidate-aware effect tuple其中包含 generation id、
effect→source 边和 source version以及 candidate input/context/output facts 的
immutable semantic-node stamp。重复 tuple 会报告有序的 effect/edge pathstep
bound 从 candidate nodes、dependency edges 和 source entries 推导,不再是任意
固定倍率阈值。rollback 会丢弃本次 flush 的 stamp因此同一个失败状态可以作为
新的 transaction 重试。
Behavior installer 使用 target-specific identityreactive value 和 function 用
`eq`,普通 scalar attribute 才使用 value equality。每个已安装 Behavior 都获得
稳定的 `(mount-epoch resource-id)` 地址generation membership 与 Runtime resource
registry 决定当前 authority。失败 candidate 只移除 staged Behavior resource
执行受 containment 保护的 cleanup。
## 12. 拓展规则
增加新能力前,优先选择最小的既有 owner

View File

@ -58,6 +58,8 @@ Mount it into an Emacs buffer:
Use `etaf-mount` whenever a View contains a stateful Component, reactive data, events, or lifecycle.
The current core has no direct `.etaf` loader. `etaf-define-component` is the structure/style/behavior unit: use its View for structure, `:styles` for static presentation, and `:setup` for retained state, Actions, and lifecycle. A future `.etaf` SFC belongs to a compiler layer that emits this same Component contract; it is not a second Runtime entry point.
## 3. Properties and children
Attribute values are ordinary Elisp expressions. They do not need an extra `expr` wrapper:
@ -266,6 +268,14 @@ Precedence is fixed:
explicit Host properties > Component :styles > inherited Theme defaults
```
Within one Component style scope, the first matching declaration fills an
unspecified Host property; later rules do not overwrite that resolved default.
A non-nil Host property remains protected, and a nil Host property is treated
as omitted and may receive the Component or Theme default. Use explicit Host
props or distinct properties when a variant needs a deliberate override.
Styles are scoped to the Component that authored a View node. A parent rule does not enter a nested Component's internals; content supplied through a caller slot keeps the caller scope, while a Component's own slot fallback keeps the child scope.
Theme is a Context convenience, not another runtime object:
```elisp
@ -339,7 +349,7 @@ For a reusable installer, reserve `:install` for the cleanup-producing part of t
(message "Behavior removed")))))))
```
The installer can call `etaf-current-behavior-context` when it needs the current Runtime or Host path. Replacing the Behavior runs the old cleanup before the new state becomes current.
The installer can call `etaf-current-behavior-context` when it needs the current Runtime or Host path. Replacing the Behavior runs the old cleanup before the new state becomes current. Behavior equality keeps function and reactive-value identity with `eq`; a newly-created installer closure is therefore a deliberate replacement, not an accidental reuse. The replacement is staged under the mounted resource registry and becomes authoritative only when its generation commits.
Focus and hit testing are Runtime operations:
@ -351,6 +361,8 @@ Focus and hit testing are Runtime operations:
`etaf-host-ref-bounds` and `etaf-host-ref-position` expose the public Ebox hit-test boundary. `etaf-dispatch-event` accepts an optional payload flag when the callback needs one argument.
Mounted buffers enable `etaf-input-mode` automatically. `TAB` focuses the next Host, `Shift-TAB`/backtab focuses the previous Host, `RET` activates the focused Host, and `mouse-1` activates the Host at the click position. Focus ordering sorts numeric `:tab-index` first and uses live buffer position as the stable tie-breaker; moving focus also moves point to that Host. Unmounting disables the input mode.
## 9. Context / Provide / Inject
Use Context for a dependency shared across component depth, not for ordinary props:
@ -563,7 +575,7 @@ It depends only on Ebox. Use it when inspecting Ebox boxes and Grid layout; use
| Reactive effects | `etaf-watch`, `etaf-watch-effect`, `etaf-effect-scope`, `etaf-scope-run` | Observe state or synchronize external resources |
| Lifecycle | `etaf-on-mounted`, `etaf-on-updated`, `etaf-on-unmounted` | Attach Component lifecycle work |
| Context | `etaf-provide`, `etaf-inject`, `etaf-theme-provide` | Share ambient dependencies through depth |
| Events and focus | `etaf-dispatch-event`, `etaf-activate`, `etaf-focus`, `etaf-focus-next` | Enter interactive Runtime behavior |
| Events and focus | `etaf-dispatch-event`, `etaf-activate`, `etaf-focus`, `etaf-focus-next`, `etaf-focus-previous`, `etaf-input-mode` | Enter interactive Runtime behavior |
| Actions | `etaf-action-define`, `etaf-dispatch` | Name and reuse business mutations |
| Behaviors | `etaf-behavior-create`, `etaf-define-behavior`, `etaf-current-behavior-context`, `etaf-focusable`, `etaf-toggleable` | Reuse non-visual interaction bundles |
| Data | `etaf-data-source`, `etaf-data-controller`, `etaf-data-memory-source`, `etaf-data-*` | Query, paginate, mutate, select, and stop data |
@ -583,3 +595,9 @@ Most applications need only `etaf-view`, `etaf-mount`, `etaf-define-component`,
- Keep writes out of rendering; use an event, Action, watch callback, or Effect.
- Use `etaf-ui` Components for product controls; core Hosts are the structural foundation.
- Stop a Data Controller and unmount a Runtime when their owner is no longer needed.
For retained updates, keep the application pair declarative: `.etaf` contains
the static shell and the same-basename `.el` companion owns state, Components,
and actions. Reactive writes are batched into one generation publication;
failed publication is retryable, and a non-converging effect is reported rather
than allowed to keep the UI busy.

View File

@ -58,6 +58,8 @@ ETAF 依赖独立的 Ebox 包。开发时把核心检出目录放入 `load-path`
View 含有状态型 Component、响应式数据、事件或生命周期时使用 `etaf-mount`
当前 core 不直接加载 `.etaf`。`etaf-define-component` 是结构/样式/行为单元:用 View 定义结构,用 `:styles` 放静态 presentation`:setup` 管理 retained state、Action 和生命周期。未来 `.etaf` SFC 属于把结果编译成同一套 Component 契约的 compiler layer而不是第二个 Runtime 入口。
## 3. 属性与子节点
属性值就是普通 Elisp 表达式,不需要额外的 `expr`
@ -266,6 +268,13 @@ Setup 对 retained instance 只执行一次,返回的 render 函数在更新
显式 Host 属性 > Component :styles > 继承的 Theme 默认值
```
在同一个 Component 样式作用域内,第一条匹配声明会填充尚未指定的 Host
属性;后续规则不会覆盖已经解析出的默认值。非 nil 的 Host 属性始终受
保护nil Host 属性按省略处理,可以接收 Component 或 Theme 默认值。若
variant 需要明确覆盖,应使用显式 Host 属性或不同的属性键。
样式作用域归属于创建 View node 的 Component。父组件规则不会进入嵌套 Component 的内部;调用者通过 slot 提供的内容保留 caller scopeComponent 自己定义的 slot fallback 保留 child scope。
Theme 是 Context 的便捷形式,不是另一个 Runtime 对象:
```elisp
@ -339,7 +348,7 @@ Behavior 用来打包可复用的非视觉属性和 cleanup
(message "Behavior removed")))))))
```
Installer 需要 Runtime 或 Host path 时,可以调用 `etaf-current-behavior-context`。Behavior 被替换时,旧 cleanup 会在新状态成为当前状态前运行。
Installer 需要 Runtime 或 Host path 时,可以调用 `etaf-current-behavior-context`。Behavior 被替换时,旧 cleanup 会在新状态成为当前状态前运行。Behavior equality 对 function 和 reactive value 使用 `eq`;因此新建的 installer closure 会被视为有意替换,而不是错误复用。替换状态先以 mounted resource registry 的 staged resource 保存generation commit 后才成为 authority。
Focus 和 hit testing 是 Runtime 操作:
@ -351,6 +360,8 @@ Focus 和 hit testing 是 Runtime 操作:
`etaf-host-ref-bounds``etaf-host-ref-position` 暴露公共的 Ebox hit-test 边界。`etaf-dispatch-event` 支持可选 payload 标记,用于给 callback 传入一个参数。
挂载后的 buffer 会自动启用 `etaf-input-mode``TAB` 聚焦下一个 Host`Shift-TAB`/backtab 聚焦上一个 Host`RET` 激活当前焦点 Host`mouse-1` 在点击位置激活 Host。焦点顺序先按数字 `:tab-index`,相同 index 再按 live buffer position 稳定排序;移动焦点也会移动 point。卸载时会禁用该输入 mode。
## 9. Context / Provide / Inject
Context 适合跨多层共享依赖,不适合普通 label
@ -563,7 +574,7 @@ Loader 错误会保存在 `etaf-resource-error`cleanup/type 错误保持可
| 响应式 effect | `etaf-watch`、`etaf-watch-effect`、`etaf-effect-scope`、`etaf-scope-run` | 观察状态或同步外部资源 |
| 生命周期 | `etaf-on-mounted`、`etaf-on-updated`、`etaf-on-unmounted` | 绑定 Component 生命周期工作 |
| Context | `etaf-provide`、`etaf-inject`、`etaf-theme-provide` | 跨层级共享依赖 |
| 事件与 focus | `etaf-dispatch-event`、`etaf-activate`、`etaf-focus`、`etaf-focus-next` | 进入交互 Runtime |
| 事件与 focus | `etaf-dispatch-event`、`etaf-activate`、`etaf-focus`、`etaf-focus-next`、`etaf-focus-previous`、`etaf-input-mode` | 进入交互 Runtime |
| Action | `etaf-action-define`、`etaf-dispatch` | 命名和复用业务变更 |
| Behavior | `etaf-behavior-create`、`etaf-define-behavior`、`etaf-current-behavior-context`、`etaf-focusable`、`etaf-toggleable` | 复用非视觉交互 bundle |
| Data | `etaf-data-source`、`etaf-data-controller`、`etaf-data-memory-source`、`etaf-data-*` | 查询、分页、变更、选择和停止数据 |
@ -583,3 +594,8 @@ Loader 错误会保存在 `etaf-resource-error`cleanup/type 错误保持可
- 不要在 render 中写状态使用事件、Action、watch callback 或 Effect。
- 产品级控件使用 `etaf-ui` Componentcore Host 只是结构基础。
- owner 不再需要时,停止 Data Controller 并卸载 Runtime。
对于保留式更新,建议保持 pair 结构:`.etaf` 只放静态 shell同名 `.el` companion
负责 state、Component 和 action。响应式写入会合并为一次 generation publication
发布失败可以在同一旧状态上重试non-converging effect 会报告错误,不会让界面
持续占用事件循环。

View File

@ -18,6 +18,13 @@
"Invalid ETAF Component definition"
'etaf-view-error)
(cl-defstruct (etaf--slot-content (:constructor etaf--slot-content-create))
"Internal caller ownership metadata for one slot entry."
owner-component-id children)
(defvar etaf--raw-slot-read-p nil
"Set while a Component render uses public raw slot accessors.")
(defun etaf--component-definition-error (format-string &rest arguments)
"Signal a Component definition error from FORMAT-STRING and ARGUMENTS."
(signal 'etaf-component-definition-error
@ -63,21 +70,23 @@ The function is also useful to code that deliberately avoids that shorthand."
(defun etaf-current-slots ()
"Return the current Component's normalized slot alist."
etaf--current-component-slots)
(setq etaf--raw-slot-read-p t)
(mapcar (lambda (entry)
(cons (car entry)
(if (etaf--slot-content-p (cdr entry))
(etaf--slot-content-children (cdr entry))
(cdr entry))))
etaf--current-component-slots))
(defun etaf-current-slot (name &optional fallback)
"Return the child list for slot NAME, or FALLBACK when it is absent."
(setq etaf--raw-slot-read-p t)
(let ((entry (assq name etaf--current-component-slots)))
(if entry (cdr entry) fallback)))
(defun etaf--component-prop-bindings (props)
"Return lexical bindings for stateless Component PROPS."
(mapcar (lambda (prop)
`(,prop
(etaf--resolve-property-value
(plist-get etaf--component-props
,(etaf--component-prop-key prop)))))
props))
(if entry
(if (etaf--slot-content-p (cdr entry))
(etaf--slot-content-children (cdr entry))
(cdr entry))
fallback)))
(defun etaf--component-prop-symbol-macros (props)
"Return symbol macros that read current Component PROPS."
@ -183,7 +192,8 @@ forms do not use quote; ordinary Elisp belongs in `expr :value'."
(etaf--current-component-slots etaf--component-slots)
(etaf--current-component-instance
etaf--current-component-instance))
(let ,(etaf--component-prop-bindings props)
(cl-symbol-macrolet
,(etaf--component-prop-symbol-macros props)
,(etaf--compile-view-form view-form :projection))))))
(setup-lambda
(when saw-setup

View File

@ -18,14 +18,28 @@
(:constructor etaf--context-create))
"One inherited Context frame owned by a Component instance."
parent
owner-id
(values (make-hash-table :test #'eq)))
(defvar etaf--current-context nil
"Context table of the Component currently being evaluated.")
(defvar etaf--context-inject-recorder nil
"Candidate-local function recording Context provider reads.")
(defconst etaf--context-missing (make-symbol "etaf-context-missing")
"Unique sentinel used to distinguish an absent Context value.")
(defun etaf-context-copy (context)
"Return an immutable candidate copy of CONTEXT's local frame."
(when context
(let ((values (make-hash-table :test #'eq)))
(maphash (lambda (key value) (puthash key value values))
(etaf-context-values context))
(etaf--context-create :parent (etaf-context-parent context)
:owner-id (etaf-context-owner-id context)
:values values))))
(defun etaf--context-key (key)
"Validate and normalize Context KEY."
(unless (and (symbolp key) (not (keywordp key)) (not (memq key '(nil t))))
@ -52,16 +66,21 @@ can be distinguished from a missing dependency."
(let ((key (etaf--context-key key))
(context etaf--current-context)
found
provider
value)
(while (and context (not found))
(let ((candidate (gethash key (etaf-context-values context)
etaf--context-missing)))
(unless (eq candidate etaf--context-missing)
(setq value candidate
found t)))
found t
provider context)))
(setq context (etaf-context-parent context)))
(if found
value
(progn
(when etaf--context-inject-recorder
(funcall etaf--context-inject-recorder provider key))
value)
(if (and required-p-supplied-p required-p)
(signal 'etaf-context-error
(list (format "Missing ETAF Context dependency: %S" key)))

View File

@ -4,9 +4,9 @@
;;; Commentary:
;; Events are Runtime-owned entry points into local `:on-*' callbacks. Ebox
;; supplies live Host reference bounds; ETAF decides which callback to invoke
;; and keeps focus state separate from the visual tree.
;; Events are Runtime-owned entry points into local `:on-*' callbacks. The
;; input minor mode is deliberately small: Runtime only toggles it at mount
;; boundaries, while this module owns key and mouse command definitions.
;;; Code:
@ -20,10 +20,14 @@
(declare-function etaf-runtime-p "etaf-runtime" (value))
(declare-function etaf-runtime-mounted-p "etaf-runtime" (runtime))
(declare-function etaf-runtime-buffer "etaf-runtime" (runtime))
(declare-function etaf-runtime-handlers "etaf-runtime" (runtime))
(declare-function etaf-runtime-host-props "etaf-runtime" (runtime))
(declare-function etaf-runtime-handler-for "etaf-runtime" (runtime host-ref))
(declare-function etaf-runtime-handler-entries "etaf-runtime" (runtime))
(declare-function etaf-runtime-host-props-for "etaf-runtime" (runtime host-ref))
(declare-function etaf-runtime-host-props-entries "etaf-runtime" (runtime))
(declare-function etaf-runtime-focus-ref "etaf-runtime" (runtime))
(declare-function etaf-runtime-set-focus-ref "etaf-runtime" (runtime host-ref))
(declare-function etaf-runtime-event-begin "etaf-runtime" (runtime))
(declare-function etaf-runtime-event-end "etaf-runtime" (runtime))
(define-error 'etaf-event-error "Invalid ETAF Event")
@ -37,7 +41,7 @@
((symbolp kind) (symbol-name kind))
((stringp kind) kind)
(t (signal 'etaf-event-error
(list (format "Invalid event kind: %S" kind)))))))
(list (format "Invalid event kind: %S" kind)))))))
(setq name (if (string-prefix-p "on-" name)
(substring name 3)
name))
@ -46,7 +50,7 @@
(defun etaf--event-handler (runtime host-ref kind)
"Return callback for HOST-REF and KIND in RUNTIME, or nil."
(cdr (assq (etaf-event-kind kind)
(gethash host-ref (etaf-runtime-handlers runtime)))))
(etaf-runtime-handler-for runtime host-ref))))
;;;###autoload
(cl-defun etaf-dispatch-event
@ -65,9 +69,12 @@ otherwise call the local callback with no arguments."
(etaf--current-event
(list :runtime runtime :host-ref host-ref
:kind (etaf-event-kind kind) :payload payload)))
(if payload-p
(funcall callback payload)
(funcall callback)))))
(etaf-runtime-event-begin runtime)
(unwind-protect
(if payload-p
(funcall callback payload)
(funcall callback))
(etaf-runtime-event-end runtime)))))
;;;###autoload
(defun etaf-host-ref-bounds (runtime host-ref)
@ -81,71 +88,184 @@ otherwise call the local callback with no arguments."
(setq runtime (etaf-runtime-require-mounted runtime))
(ebox-host-ref-position (etaf-runtime-buffer runtime) host-ref))
(defun etaf--position-number (position)
"Return integer POSITION, or nil when POSITION is not buffer-local."
(cond
((markerp position) (marker-position position))
((integerp position) position)
(t nil)))
(defun etaf--activation-candidate-before-p (left right)
"Return non-nil when activation candidate LEFT precedes RIGHT."
(let ((left-length (nth 2 left))
(right-length (nth 2 right))
(left-start (nth 1 left))
(right-start (nth 1 right)))
(or (< left-length right-length)
(and (= left-length right-length)
(or (< left-start right-start)
(and (= left-start right-start)
(string< (prin1-to-string (car left))
(prin1-to-string (car right)))))))))
(defun etaf--activation-at-position (runtime position &optional quiet)
"Activate the smallest enabled Host at POSITION in RUNTIME.
When QUIET is non-nil, return nil instead of signaling when no callback owns
the position."
(let (candidates)
(dolist (entry (etaf-runtime-handler-entries runtime))
(let ((host-ref (car entry)) (handlers (cdr entry)))
(let* ((press (assq 'press handlers))
(props (etaf-runtime-host-props-for runtime host-ref))
(bounds (ebox-host-ref-bounds
(etaf-runtime-buffer runtime) host-ref))
(start (and bounds (car bounds)))
(end (and bounds (cdr bounds))))
(when (and press (not (plist-get props :disabled))
start end (<= start position) (< position end))
(push (list host-ref start (- end start)) candidates)))))
(setq candidates (sort candidates #'etaf--activation-candidate-before-p))
(if-let ((candidate (car candidates)))
(etaf-dispatch-event runtime (car candidate) 'press)
(unless quiet
(user-error "No interactive ETAF Host at point")))))
;;;###autoload
(defun etaf-activate (&optional runtime)
"Dispatch `press' for the smallest active Host containing point.
"Dispatch `press' for the smallest enabled Host containing point.
RUNTIME is the mounted Runtime to activate, or nil for the current Runtime.
Return the callback result. This command is intentionally a thin adapter;
hit testing remains Ebox's public Host-reference bounds operation."
RUNTIME is the mounted Runtime to activate, or nil for the current buffer."
(interactive)
(setq runtime (etaf-runtime-require-mounted runtime))
(let ((position (point)) candidates)
(maphash
(lambda (host-ref _handlers)
(when-let ((bounds (ebox-host-ref-bounds
(etaf-runtime-buffer runtime) host-ref)))
(when (and (<= (car bounds) position) (< position (cdr bounds)))
(push (cons host-ref (- (cdr bounds) (car bounds))) candidates))))
(etaf-runtime-handlers runtime))
(unless candidates
(user-error "No interactive ETAF Host at point"))
(let* ((ordered (sort candidates
(lambda (left right) (< (cdr left) (cdr right)))))
(host-ref (car (car ordered))))
(etaf-dispatch-event runtime host-ref 'press))))
(let ((position (with-current-buffer (etaf-runtime-buffer runtime)
(point))))
(etaf--activation-at-position runtime position)))
(defun etaf--focus-candidate-before-p (left right)
"Return non-nil when focus candidate LEFT precedes RIGHT."
(or (< (nth 0 left) (nth 0 right))
(and (= (nth 0 left) (nth 0 right))
(or (< (nth 1 left) (nth 1 right))
(and (= (nth 1 left) (nth 1 right))
(or (< (nth 2 left) (nth 2 right))
(and (= (nth 2 left) (nth 2 right))
(string< (prin1-to-string (nth 3 left))
(prin1-to-string (nth 3 right))))))))))
(defun etaf--focus-candidates (runtime)
"Return RUNTIME's visible focus candidates in stable buffer order."
(let (candidates)
(dolist (entry (etaf-runtime-host-props-entries runtime))
(let ((host-ref (car entry)) (props (cdr entry)))
(let ((tab-index (plist-get props :tab-index))
(bounds (ebox-host-ref-bounds
(etaf-runtime-buffer runtime) host-ref)))
(when (and (numberp tab-index) (>= tab-index 0)
(not (plist-get props :disabled)) bounds)
(push (list tab-index (car bounds) (cdr bounds) host-ref)
candidates)))))
(sort candidates #'etaf--focus-candidate-before-p)))
;;;###autoload
(defun etaf-focus (runtime host-ref)
"Move focus to HOST-REF in mounted RUNTIME and return HOST-REF."
(defun etaf-focus (&optional runtime host-ref)
"Move focus to HOST-REF in mounted RUNTIME and move point to its position.
When called interactively without arguments, focus the first visible Host."
(interactive)
(setq runtime (etaf-runtime-require-mounted runtime))
(unless (etaf-host-ref-bounds runtime host-ref)
(signal 'etaf-event-error
(list (format "Cannot focus an invisible Host reference: %S"
host-ref))))
(etaf-runtime-set-focus-ref runtime host-ref))
(unless host-ref
(setq host-ref (nth 3 (car (etaf--focus-candidates runtime))))
(unless host-ref
(user-error "No focusable ETAF Host")))
(let ((position (etaf-host-ref-position runtime host-ref)))
(unless position
(signal 'etaf-event-error
(list (format "Cannot focus an invisible Host reference: %S"
host-ref))))
(etaf-runtime-set-focus-ref runtime host-ref)
(with-current-buffer (etaf-runtime-buffer runtime)
(goto-char position))
host-ref))
;;;###autoload
(defun etaf-focused-host-ref (&optional runtime)
"Return the currently focused Host reference in mounted RUNTIME."
(etaf-runtime-focus-ref (etaf-runtime-require-mounted runtime)))
(defun etaf--focus-cycle (runtime step)
"Move focus by STEP positions through RUNTIME's ordered candidates."
(setq runtime (etaf-runtime-require-mounted runtime))
(let* ((candidates (etaf--focus-candidates runtime))
(ordered (mapcar (lambda (entry) (nth 3 entry)) candidates))
(current (etaf-runtime-focus-ref runtime))
(position (cl-position current ordered :test #'equal)))
(unless ordered
(user-error "No focusable ETAF Host"))
(etaf-focus runtime
(if position
(nth (mod (+ position step) (length ordered)) ordered)
(if (> step 0) (car ordered) (car (last ordered)))))))
;;;###autoload
(defun etaf-focus-next (&optional runtime)
"Focus the next visible Host in mounted RUNTIME.
"Focus the next visible Host in mounted RUNTIME."
(interactive)
(etaf--focus-cycle runtime 1))
RUNTIME may be nil to use the current Runtime. Hosts need a numeric,
non-negative `:tab-index'."
(setq runtime (etaf-runtime-require-mounted runtime))
(let (candidates)
(maphash
(lambda (host-ref props)
(let ((tab-index (plist-get props :tab-index)))
(when (and (numberp tab-index) (>= tab-index 0)
(etaf-host-ref-bounds runtime host-ref))
(push (cons tab-index host-ref) candidates))))
(etaf-runtime-host-props runtime))
(setq candidates (sort candidates
(lambda (left right)
(< (car left) (car right)))))
(unless candidates
(user-error "No focusable ETAF Host"))
(let* ((current (etaf-runtime-focus-ref runtime))
(ordered (mapcar #'cdr candidates))
(position (cl-position current ordered :test #'equal))
(next (if position
(or (nth (1+ position) ordered) (car ordered))
(car ordered))))
(etaf-focus runtime next))))
;;;###autoload
(defun etaf-focus-previous (&optional runtime)
"Focus the previous visible Host in mounted RUNTIME."
(interactive)
(etaf--focus-cycle runtime -1))
(defun etaf-activate-mouse (event)
"Activate the enabled Host at the real mouse EVENT position."
(interactive "e")
(let* ((start (event-start event))
(window (posn-window start))
(position (etaf--position-number (posn-point start))))
(when (and (windowp window) position)
(let ((buffer (window-buffer window)))
(when (buffer-live-p buffer)
(with-current-buffer buffer
(when-let ((runtime (etaf-runtime-for-buffer buffer)))
(goto-char position)
(etaf--activation-at-position runtime position t))))))))
(defvar etaf-input-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "TAB") #'etaf-focus-next)
(define-key map [tab] #'etaf-focus-next)
(define-key map (kbd "<backtab>") #'etaf-focus-previous)
(define-key map [backtab] #'etaf-focus-previous)
(define-key map (kbd "S-TAB") #'etaf-focus-previous)
(define-key map (kbd "RET") #'etaf-activate)
(define-key map (kbd "<return>") #'etaf-activate)
(define-key map [mouse-1] #'etaf-activate-mouse)
map)
"Keymap for mounted ETAF Runtime input.")
;;;###autoload
(define-minor-mode etaf-input-mode
"Enable keyboard and mouse input for a mounted ETAF Runtime."
:init-value nil
:lighter " ETAF"
:keymap etaf-input-mode-map)
;;;###autoload
(defun etaf-events-enable-input (buffer)
"Enable `etaf-input-mode' in mounted BUFFER."
(with-current-buffer buffer
(etaf-input-mode 1)))
;;;###autoload
(defun etaf-events-disable-input (buffer)
"Disable `etaf-input-mode' in BUFFER after Runtime unmount."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(when (bound-and-true-p etaf-input-mode)
(etaf-input-mode -1)))))
(provide 'etaf-events)

View File

@ -25,6 +25,7 @@
test
(version 0)
subscribers
source-id
name)
(cl-defstruct (etaf-computed
@ -35,6 +36,7 @@
test
(version 0)
subscribers
source-id
effect
(dirty-p t)
evaluated-p
@ -63,15 +65,31 @@
name
(active-p t))
(cl-defstruct (etaf-runtime-route
(:constructor etaf-runtime-route-create))
"Opaque Runtime route stored by reactive sources."
runtime-id mount-epoch scheduler)
(defvar etaf--active-effect nil
"The effect currently collecting dependencies.")
(defvar etaf--source-id-counter 0)
(defun etaf-reactive-source-id (source)
"Return stable integer id of reactive SOURCE."
(cond ((etaf-ref-p source) (etaf-ref-source-id source))
((etaf-computed-p source) (etaf-computed-source-id source))
(t (signal 'wrong-type-argument (list 'etaf-reactive-source-p source)))))
(defvar etaf--active-scope nil
"The Scope that owns newly created effects and cleanups.")
(defvar etaf--tracking-enabled-p t
"Whether reactive reads may collect dependencies.")
(defvar etaf--runtime-dependency-collector nil
"Runtime-local collector called for each reactive source read.")
(defvar etaf--render-phase-p nil
"Whether the current call is producing a View tree.")
@ -81,6 +99,53 @@
The function receives a zero-argument job and a phase symbol. Outside a
Runtime, watchers run synchronously.")
(defvar etaf--dispatch-depth 0)
(defvar etaf--dispatch-source-queue nil)
(defvar etaf--dispatch-source-set (make-hash-table :test #'eq))
(defvar etaf--dispatch-runtime-queue nil)
(defvar etaf--dispatch-runtime-set (make-hash-table :test #'eq))
(defvar etaf--dispatch-effect-set (make-hash-table :test #'eq))
(defun etaf-reactive-enqueue-runtime-flush (runtime function)
"Queue FUNCTION once for RUNTIME after the outer reactive dispatch settles."
(unless (gethash runtime etaf--dispatch-runtime-set)
(puthash runtime function etaf--dispatch-runtime-set)
(setq etaf--dispatch-runtime-queue
(nconc etaf--dispatch-runtime-queue (list runtime)))))
(defun etaf--dispatch-source-now (source)
"Notify SOURCE subscribers without opening another dispatch boundary."
(let ((subscribers (copy-hash-table (etaf--source-subscribers source))))
(maphash
(lambda (subscriber _)
(cond
((etaf-runtime-route-p subscriber)
(funcall (etaf-runtime-route-scheduler subscriber)
subscriber source))
((etaf-effect-active-p subscriber)
(unless (gethash subscriber etaf--dispatch-effect-set)
(puthash subscriber t etaf--dispatch-effect-set)
(if-let ((scheduler (etaf-effect-scheduler subscriber)))
(funcall scheduler subscriber)
(etaf-reactive-effect-run subscriber))))))
subscribers)))
(defun etaf--drain-dispatch ()
"Drain reactive sources and Runtime work to a stable outer fixed point."
(while (or etaf--dispatch-source-queue etaf--dispatch-runtime-queue)
(while etaf--dispatch-source-queue
(let ((source (pop etaf--dispatch-source-queue)))
(remhash source etaf--dispatch-source-set)
(etaf--dispatch-source-now source)))
;; Detach this turn. A lifecycle write may enqueue a source and the same
;; Runtime again for the following turn without merging it into this one.
(let ((turn etaf--dispatch-runtime-queue))
(setq etaf--dispatch-runtime-queue nil)
(dolist (runtime turn)
(let ((function (gethash runtime etaf--dispatch-runtime-set)))
(remhash runtime etaf--dispatch-runtime-set)
(funcall function))))))
(defun etaf--reactive-same-p (left right)
"Return whether LEFT and RIGHT are equal under ETAF's shallow rule."
(if (or (consp left) (vectorp left) (hash-table-p left)
@ -104,6 +169,9 @@ Runtime, watchers run synchronously.")
(defun etaf--track-source (source)
"Record SOURCE as a dependency of the active effect."
(when (and etaf--tracking-enabled-p etaf--runtime-dependency-collector
(null etaf--active-effect))
(funcall etaf--runtime-dependency-collector source))
(when (and etaf--tracking-enabled-p etaf--active-effect
(etaf-effect-active-p etaf--active-effect))
(let ((subscribers (etaf--source-subscribers source)))
@ -169,6 +237,7 @@ of the run."
(etaf--clear-effect-deps effect)
(let ((etaf--active-effect effect)
(etaf--active-scope (etaf-effect-owner-scope effect))
(etaf--runtime-dependency-collector nil)
(etaf--tracking-enabled-p t)
(etaf--render-phase-p (or rendering etaf--render-phase-p)))
(setf (etaf-effect-running-p effect) t)
@ -186,14 +255,18 @@ of the run."
(defun etaf--dispatch-source (source)
"Notify every current subscriber of SOURCE once."
(let ((subscribers (copy-hash-table (etaf--source-subscribers source))))
(maphash
(lambda (effect _)
(when (etaf-effect-active-p effect)
(if-let ((scheduler (etaf-effect-scheduler effect)))
(funcall scheduler effect)
(etaf-reactive-effect-run effect))))
subscribers)))
(unless (gethash source etaf--dispatch-source-set)
(puthash source t etaf--dispatch-source-set)
(setq etaf--dispatch-source-queue
(nconc etaf--dispatch-source-queue (list source))))
(when (zerop etaf--dispatch-depth)
(let ((etaf--dispatch-depth 1))
(unwind-protect (etaf--drain-dispatch)
(setq etaf--dispatch-source-queue nil
etaf--dispatch-runtime-queue nil)
(clrhash etaf--dispatch-source-set)
(clrhash etaf--dispatch-runtime-set)
(clrhash etaf--dispatch-effect-set)))))
;;;###autoload
(cl-defun etaf-ref (initial-value &key test name)
@ -205,6 +278,7 @@ TEST optionally compares old and new values. NAME is used in diagnostics."
(etaf--ref-create :value initial-value
:test test
:subscribers (make-hash-table :test #'eq)
:source-id (cl-incf etaf--source-id-counter)
:name name))
(defun etaf--computed-value (computed)
@ -241,7 +315,8 @@ computed value."
(signal 'wrong-type-argument (list 'functionp test)))
(let ((computed (etaf--computed-create
:getter getter :test test :name name
:subscribers (make-hash-table :test #'eq))))
:subscribers (make-hash-table :test #'eq)
:source-id (cl-incf etaf--source-id-counter))))
(setf (etaf-computed-effect computed)
(etaf-reactive-effect-create
getter

View File

@ -14,9 +14,23 @@
(require 'cl-lib)
(require 'ebox)
(require 'etaf-view)
(require 'etaf-component)
(require 'etaf-context)
(declare-function etaf--runtime-render-component "etaf-runtime" (runtime call path))
(defvar etaf--rendered-range-container-nodes nil
"Candidate-local Ebox nodes whose layout directly contains a Range.")
(declare-function etaf--runtime-render-child-range "etaf-runtime" (runtime expr path))
(declare-function etaf--runtime-render-slot-range
"etaf-runtime" (runtime projection path))
(declare-function etaf--runtime-register-semantic-host "etaf-runtime" (runtime name props backend-props path))
(declare-function etaf--runtime-finish-semantic-host
"etaf-runtime" (runtime semantic-id &optional content content-parts))
(declare-function etaf--runtime-render-inline-content
"etaf-runtime" (runtime host-id values path &optional surface))
(declare-function etaf--runtime-call-with-component-env
"etaf-runtime" (runtime component-id function))
(declare-function etaf--runtime-behavior-node "etaf-runtime" (runtime node path))
(declare-function etaf--runtime-register-host "etaf-runtime" (runtime props path))
(declare-function etaf-theme-defaults "etaf-context" (&optional default))
@ -35,6 +49,15 @@
(defvar etaf--render-style-stack nil
"Component style scopes active during View lowering.")
(defvar etaf--render-parent-style-stack nil
"Style scopes belonging to the caller of the current Component.")
(defvar etaf--current-semantic-parent-id 0
"Semantic parent receiving Hosts during mounted lowering.")
(defvar etaf--rendering-range-p nil
"Non-nil while eagerly lowering descendants of one Range item Host.")
(defun etaf--event-property-p (property)
"Return non-nil when PROPERTY is an ETAF event callback property."
(and (keywordp property)
@ -94,7 +117,9 @@ represented as ordinary static data until this final lowering step; they do
not create another visual node category."
(if (null styles)
node
(let ((props (copy-sequence (etaf--view-node-props node))))
(let ((props (copy-sequence
(etaf--resolve-property-plist
(etaf--view-node-props node)))))
(dolist (rule (cdr styles))
(when (and (consp rule) (stringp (car rule))
(etaf--style-selector-match-p (car rule) node root-p))
@ -109,29 +134,44 @@ not create another visual node category."
(setq value (if (and (consp value) (eq (car value) 'quote))
(cadr value)
value))
(unless (plist-member props key)
(setq props (append props (list key value)))))))))
(unless (plist-get props key)
(setq props (etaf--merge-property props key value))))))))
(etaf--view-node-create
:name (etaf--view-node-name node)
:token (etaf--view-node-token node)
:props props
:children (etaf--view-node-children node)))))
(defun etaf--apply-theme-defaults (node)
"Apply inherited Theme defaults to NODE without overriding props."
"Apply known inherited Theme defaults to NODE without overriding props.
Unknown Theme keys remain readable through `etaf-theme-value' but are design
tokens, not Ebox Host properties, and therefore are not materialized here."
(let ((defaults (etaf-theme-defaults)))
(if (null defaults)
node
(let ((props (copy-sequence (etaf--view-node-props node))))
(let ((props (copy-sequence
(etaf--resolve-property-plist
(etaf--view-node-props node)))))
(while defaults
(let ((key (pop defaults))
(value (pop defaults)))
(unless (plist-member props key)
(setq props (append props (list key value))))))
(when (and (etaf--theme-ebox-property-p key)
(not (plist-get props key)))
(setq props (etaf--merge-property props key value)))))
(etaf--view-node-create
:name (etaf--view-node-name node)
:token (etaf--view-node-token node)
:props props
:children (etaf--view-node-children node))))))
(defun etaf--theme-ebox-property-p (key)
"Return non-nil when Theme KEY is a known Ebox property.
The public `ebox-property-rules' table is the shared property vocabulary;
custom Theme tokens remain Context data and are never copied onto Hosts."
(and (keywordp key)
(boundp 'ebox-property-rules)
(assq key ebox-property-rules)))
(defun etaf--inline-text-surface-properties (props)
"Return text properties represented by inline text PROPS."
(let ((surface (copy-sequence (or (plist-get props :surface-properties) nil))))
@ -145,6 +185,13 @@ not create another visual node category."
(list 'background (plist-get props :bgcolor)))))
surface))
(defun etaf--apply-inline-surface-properties (content surface)
"Return CONTENT with resolved inline SURFACE properties applied."
(let ((result (copy-sequence content)))
(when (and surface (not (zerop (length result))))
(add-text-properties 0 (length result) surface result))
result))
(defun etaf--inline-text-content (value)
"Return `(SUCCESS . STRING)' for inline-compatible text VALUE.
@ -186,6 +233,18 @@ the ordinary layout lowering path instead."
(cons valid-p (and valid-p result))))
(t (cons nil nil))))
(defun etaf--inline-text-structural-p (value)
"Return non-nil when VALUE can be owned by mounted inline effects."
(cond
((or (null value) (stringp value) (etaf--expr-p value)) t)
((etaf--view-node-p value)
(and (eq (etaf--view-node-name value) 'text)
(cl-every #'etaf--inline-text-structural-p
(etaf--view-node-children value))))
((proper-list-p value)
(cl-every #'etaf--inline-text-structural-p value))
(t nil)))
(defun etaf--ebox-properties (props path)
"Translate ETAF PROPS at PATH into Ebox's public property list."
(let* ((source-props (etaf--resolve-property-plist props))
@ -237,13 +296,50 @@ the ordinary layout lowering path instead."
(defun etaf--render-slot-projection (projection path)
"Render PROJECTION at PATH using the active Component slot collection."
(setq etaf--raw-slot-read-p t)
(let* ((name (etaf--slot-projection-name projection))
(slots etaf--current-component-slots)
(entry (assq name slots))
(content (and entry (cdr entry)))
(children (if (etaf--slot-content-p content)
(etaf--slot-content-children content)
content))
(owner-id (and (etaf--slot-content-p content)
(etaf--slot-content-owner-component-id content)))
(fallback (etaf--slot-projection-fallback projection)))
(etaf--render-value-list
(if entry (cdr entry) fallback)
(append path (list :slot name)))))
(let ((etaf--render-style-stack
(if entry
etaf--render-parent-style-stack
etaf--render-style-stack)))
(let ((render (lambda ()
(etaf--render-value-list
(if entry children fallback)
(append path (list :slot name))))))
(if (and entry etaf--render-runtime)
(etaf--runtime-call-with-component-env
etaf--render-runtime owner-id render)
(funcall render))))))
(defun etaf--slot-projection-range-compatible-p (projection)
"Return whether mounted PROJECTION can use the current slot Range slice."
(let* ((entry (assq (etaf--slot-projection-name projection)
etaf--current-component-slots))
(content (and entry (cdr entry)))
(values (if entry
(if (etaf--slot-content-p content)
(etaf--slot-content-children content)
content)
(etaf--slot-projection-fallback projection))))
(cl-labels
((valid
(value)
(cond ((or (null value) (stringp value) (etaf--expr-p value)) t)
((etaf--view-node-p value)
(and (not (eq (etaf--view-node-name value) 'fragment))
(cl-every #'valid (etaf--view-node-children value))))
((proper-list-p value) (cl-every #'valid value))
(t nil))))
(valid values))))
(defun etaf--render-component-call-pure (call path)
"Render stateless CALL at PATH without a retained Runtime."
@ -256,11 +352,11 @@ the ordinary layout lowering path instead."
(etaf--component-call-props call))
(etaf--current-component-slots
(etaf--component-call-slots call)))
(let ((etaf--render-style-stack
(cons (cons (etaf--component-spec-styles spec)
(let ((etaf--render-parent-style-stack etaf--render-style-stack)
(etaf--render-style-stack
(list (cons (etaf--component-spec-styles spec)
(append path (list :component
(etaf--component-spec-name spec))))
etaf--render-style-stack)))
(etaf--component-spec-name spec)))))))
(etaf--render-value-list
(funcall render
(etaf--component-call-props call)
@ -278,13 +374,24 @@ the ordinary layout lowering path instead."
path)
append
(cond
((stringp item) (list (ebox-create :content item)))
((stringp item)
(when etaf--render-runtime
(let ((semantic-id
(etaf--runtime-register-semantic-host
etaf--render-runtime 'text nil nil item-path)))
(etaf--runtime-finish-semantic-host
etaf--render-runtime semantic-id item)))
(list (ebox-create :content item)))
((etaf--view-node-p item)
(etaf--render-node item item-path))
((etaf--component-call-p item)
(if etaf--render-runtime
(list (etaf--runtime-render-component
etaf--render-runtime item item-path))
(let ((result (etaf--runtime-render-component
etaf--render-runtime item item-path)))
(if (and (consp result)
(eq (car result) 'component-output-range))
(cdr result)
(list result)))
(etaf--render-component-call-pure item item-path)))
((etaf--slot-projection-p item)
(etaf--render-slot-projection item item-path))
@ -328,41 +435,115 @@ the ordinary layout lowering path instead."
(etaf--runtime-register-host etaf--render-runtime raw-props path))
(pcase name
('text
(let* ((flat (cl-mapcan #'etaf--flatten-view-value children))
(let* ((semantic-id
(and etaf--render-runtime
(etaf--runtime-register-semantic-host
etaf--render-runtime name raw-props props path)))
(child-path (append path (list :text)))
(inline (etaf--inline-text-content flat))
(runtime-inline
(and semantic-id
(etaf--inline-text-structural-p children)
(etaf--runtime-render-inline-content
etaf--render-runtime semantic-id children child-path)))
(flat (unless runtime-inline
(cl-mapcan #'etaf--flatten-view-value children)))
(inline (if runtime-inline
(cons t (car runtime-inline))
(etaf--inline-text-content flat)))
(rendered (unless (car inline)
(etaf--render-value-list flat child-path))))
(let ((etaf--current-semantic-parent-id
(or semantic-id
etaf--current-semantic-parent-id)))
(etaf--render-value-list flat child-path)))))
(when semantic-id
(etaf--runtime-finish-semantic-host
etaf--render-runtime semantic-id
(and (car inline) (cdr inline))
(and runtime-inline (cadr runtime-inline))))
(if (car inline)
(list (apply #'ebox-create
:content (cdr inline)
props))
(list (ebox-build
(append (list 'row) props rendered))))))
(list (apply #'ebox-create :content (cdr inline) props))
(list (ebox-build (append (list 'row) props rendered))))))
('spacer
(when children
(signal 'etaf-renderer-error
(list "spacer cannot have children")))
(when etaf--render-runtime
(let ((semantic-id (etaf--runtime-register-semantic-host
etaf--render-runtime name raw-props props path)))
(etaf--runtime-finish-semantic-host
etaf--render-runtime semantic-id)))
(list (apply #'ebox-spacer props)))
('fragment
(etaf--render-value-list children (append path (list :fragment))))
((or 'row 'column 'container 'stack 'flex 'grid)
(let ((nodes nil)
(index 0))
(index 0)
(range-child-p nil)
(semantic-id
(and etaf--render-runtime
(etaf--runtime-register-semantic-host
etaf--render-runtime name raw-props props path))))
(dolist (child children)
(setq nodes
(nconc nodes
(etaf--render-value-list
child (append path (list index)))))
(let ((etaf--current-semantic-parent-id
(or semantic-id
etaf--current-semantic-parent-id)))
(if (and semantic-id (etaf--expr-p child)
(not etaf--rendering-range-p))
(let ((result
(etaf--runtime-render-child-range
etaf--render-runtime child
(append path (list index)))))
(when (eq (car result) 'range)
(setq range-child-p t))
(cdr result))
(if (and semantic-id
(etaf--view-node-p child)
(eq (etaf--view-node-name child) 'fragment)
(not etaf--rendering-range-p))
(let ((result
(etaf--runtime-render-fragment-range
etaf--render-runtime child
(append path (list index)))))
(setq range-child-p t)
(cdr result))
(if (and semantic-id
(etaf--raw-ebox-p child)
(not etaf--rendering-range-p))
(let ((result
(etaf--runtime-render-raw-range
etaf--render-runtime child
(append path (list index)))))
(setq range-child-p t)
(cdr result))
(if (and semantic-id
(etaf--slot-projection-p child)
(etaf--slot-projection-range-compatible-p
child)
(not etaf--rendering-range-p))
(let ((result
(etaf--runtime-render-slot-range
etaf--render-runtime child
(append path (list index)))))
(setq range-child-p t)
(cdr result))
(etaf--render-value-list
child (append path (list index))))))))))
(cl-incf index))
(list (etaf--layout-node name props nodes))))
(when semantic-id
(etaf--runtime-finish-semantic-host
etaf--render-runtime semantic-id))
(list (etaf--layout-node name props nodes range-child-p))))
(_
(signal 'etaf-renderer-error
(list (format "Unknown Host reached renderer: %S" name)))))))
(defun etaf--layout-node (name props nodes)
"Build layout NAME with Ebox PROPS around child NODES."
(cond
(defun etaf--layout-node (name props nodes &optional range-child-p)
"Build layout NAME with Ebox PROPS around child NODES.
RANGE-CHILD-P records that NODES include nonvisual Range segments."
(let ((node
(cond
((eq name 'row)
(if (null props)
(apply #'ebox-row nodes)
@ -376,8 +557,27 @@ the ordinary layout lowering path instead."
((eq name 'grid)
(apply #'ebox-grid (append props nodes)))
(t
(signal 'etaf-renderer-error
(list (format "Not a layout Host: %S" name))))))
(signal 'etaf-renderer-error
(list (format "Not a layout Host: %S" name)))))))
(when range-child-p
(push node etaf--rendered-range-container-nodes))
node))
(defun etaf--lower-resolved-semantic-host
(name props content children range-child-p)
"Lower resolved NAME, PROPS, CONTENT, and CHILDREN through Renderer.
RANGE-CHILD-P preserves the direct material Range parent."
(pcase name
('text
(if children
(etaf--layout-node 'row props children range-child-p)
(apply #'ebox-create :content content props)))
('spacer (apply #'ebox-spacer props))
((or 'row 'column 'container 'stack 'flex 'grid)
(etaf--layout-node name props children range-child-p))
(_ (signal 'etaf-renderer-error
(list (format "Semantic Host requires Step4b lowering: %S"
name))))))
;;;###autoload
(defun etaf-render (view)

File diff suppressed because it is too large Load Diff

View File

@ -23,12 +23,14 @@
(:constructor etaf--view-node-create))
"Internal normalized description of one Host View."
name
token
props
children)
(cl-defstruct (etaf--expr
(:constructor etaf--expr-create))
"Internal executable child expression."
token
thunk)
(cl-defstruct (etaf--component-spec
@ -57,11 +59,13 @@
(:constructor etaf--slot-projection-create))
"A Component View request for one slot and its fallback children."
name
token
fallback)
(cl-defstruct (etaf--raw-ebox
(:constructor etaf--raw-ebox-create))
"Explicit escape carrying an already-built public Ebox node."
token
thunk
key-thunk)
@ -313,9 +317,11 @@ belong to the anonymous `default' slot."
(etaf--compile-child-form child slot-mode))
children)))
(if (eq slot-mode :projection)
`(etaf--slot-projection-create
:name ',name
:fallback (list ,@compiled))
(let ((token (gensym "etaf-slot-site-")))
`(etaf--slot-projection-create
:name ',name
:token ',token
:fallback (list ,@compiled)))
`(etaf--slot-input-create
:name ',name
:children (list ,@compiled))))))
@ -328,8 +334,10 @@ belong to the anonymous `default' slot."
(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))))
(let ((token (gensym "etaf-expr-site-")))
`(etaf--expr-create
:token ',token
:thunk (lambda () ,(etaf--parse-expr-form items)))))
(defun etaf--compile-child-form (form &optional slot-mode)
"Compile structural child FORM into code returning a View value.
@ -380,11 +388,13 @@ SLOT-MODE distinguishes Component-owned projections from call-site inputs."
(unless (and (plist-member props :value)
(cl-every #'keywordp (cl-loop for (key _value) on props by #'cddr collect key)))
(etaf--syntax-error "Raw-ebox requires :value"))
(let ((allowed '(:value :key)))
(let ((allowed '(:value :key))
(token (gensym "etaf-raw-site-")))
(dolist (key (cl-loop for (key _value) on props by #'cddr collect key))
(unless (memq key allowed)
(etaf--syntax-error "Raw-ebox does not accept %S" key)))
`(etaf--raw-ebox-create
:token ',token
:thunk (lambda () ,(plist-get props :value))
:key-thunk ,(when (plist-member props :key)
`(lambda () ,(plist-get props :key))))))))
@ -395,12 +405,15 @@ SLOT-MODE distinguishes Component-owned projections from call-site inputs."
(host-view-p (eq (gethash (car form) etaf--view-registry)
etaf--host-marker))
(child-slot-mode (if host-view-p slot-mode :input)))
`(etaf--view-call ',(car form)
(list ,@(etaf--compile-property-plist props))
(list ,@(mapcar (lambda (child)
(etaf--compile-child-form
child child-slot-mode))
children)))))))
(let ((token (and (memq (car form) '(fragment etaf-fragment))
(gensym "etaf-fragment-site-"))))
`(etaf--view-call ',(car form)
(list ,@(etaf--compile-property-plist props))
(list ,@(mapcar (lambda (child)
(etaf--compile-child-form
child child-slot-mode))
children))
,(and token `',token)))))))
;;;###autoload
(defmacro etaf-view (form)
@ -437,8 +450,8 @@ child region and accepts only `:value'."
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."
(defun etaf--view-call (name props children &optional token)
"Construct NAME from PROPS and CHILDREN, retaining optional site TOKEN."
(unless (symbolp name)
(etaf--syntax-error "View name must be a symbol: %S" name))
(setq props (etaf--validate-property-plist props))
@ -450,6 +463,7 @@ child region and accepts only `:value'."
(etaf--resolve-property-value (plist-get props :key))))
(etaf--view-node-create
:name (etaf--canonical-host-name name)
:token token
:props props
:children children))
((etaf--component-spec-p entry)

View File

@ -0,0 +1,7 @@
# Core boundaries after the `.etaf` pair experiment
The safe data-only `.etaf` pair path was removed from current core. It added a second file grammar, companion execution boundary, and registry validation surface before a compiler contract existed. The archived `emacs-box` DSL cannot be reused as a shortcut: it has different ownership and executable semantics. A future `.etaf` SFC belongs in a compiler layer that emits the existing `etaf-view` and `etaf-define-component` contracts.
The current Component definition remains the single structure/style/behavior unit. Its `:view` owns structure, `:styles` owns presentation, and `:setup` owns retained state, Actions, and lifecycle. Style scopes now stop at nested Component internals; caller-owned slot content keeps the caller scope, while child-authored slot fallbacks keep the child scope.
Mounted input stays in the small Runtime lifecycle path: the input minor mode is enabled after a successful mount and disabled before unmount. TAB/backtab, RET, and mouse-1 use the public focus and hit-test operations rather than a second event layer.

View File

@ -0,0 +1,7 @@
# 移除 `.etaf` pair 后的 core 边界
当前 core 移除了只读 data `.etaf`、同名 `.el` companion 的 pair 主路径。它在 compiler 契约尚未存在时增加了第二套文件语法、companion 执行边界和 registry 校验面。不能把 `emacs-box` 的归档 DSL 当捷径复用:它的所有权和可执行语义不同。未来 `.etaf` SFC 应属于 compiler layer并输出既有的 `etaf-view``etaf-define-component` 契约。
当前 Component definition 仍是唯一的结构/样式/行为单元:`:view` 负责结构,`:styles` 负责 presentation`:setup` 负责 retained state、Action 和生命周期。样式 scope 现在在嵌套 Component 内部边界停止;调用者提供的 slot 内容保留 caller scope子组件自有的 slot fallback 保留 child scope。
挂载后的输入仍走窄的 Runtime 生命周期路径:成功 mount 后启用 input minor modeunmount 前禁用。TAB/backtab、RET 和 mouse-1 复用公共 focus/hit-test 操作,不建立第二套事件层。

File diff suppressed because it is too large Load Diff