refactor(ebox): delegate selector matching to TP

Compile CSS-like selectors to TP structured ASTs and make TP the sole final matcher while Ebox retains logical tree adaptation and candidate indexes. Separate logical selector types from raw runtime type counts so internal flex adapters still drive bounded scroll scheduling without leaking into selector semantics.\n\nVerified: make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
This commit is contained in:
Kinneyzhang 2026-08-06 14:28:29 +08:00
parent 654c824630
commit d279f94ee0
11 changed files with 533 additions and 400 deletions

View File

@ -23,6 +23,8 @@ Install TP first, then install Ebox through a package manager so its declared de
The public model is a tree of Ebox nodes. `ebox-create` builds a leaf or wrapper node; `ebox-column`, `ebox-row`, `ebox-flex`, and `ebox-grid` compose nodes; `ebox-render` materializes propertized text through an ephemeral TP surface; `ebox-render-to-buffer` mounts a retained TP surface; and `ebox-commit` atomically updates that surface from a fresh root tree. Ebox copies declarative input before assigning runtime identity, so one source tree may be mounted in multiple buffers without transferring ownership.
CSS-like selector strings are compiled to TP's structured selector AST. Ebox supplies logical node relations and indexed candidates; TP is the only selector matcher for tree queries, rendered-buffer queries, and later cascade rules.
## What belongs here
- box creation, padding, margins, borders, colors, faces, overflow, and text measurement;

View File

@ -17,7 +17,7 @@ This is the maintainer entry point for the standalone Ebox repository. It descri
| `ebox.el` | Public facade, construction helpers, rendering, buffer entry points, scrolling, commit, and byte compilation. |
| `ebox-cache.el` | Measurement/render cache records, invalidation, and cache reports. |
| `ebox-style.el` | Property registration, aliases, shorthand expansion, computed style, colors, borders, and dirty effects. |
| `ebox-tree.el` | Node traversal, child access, identity, parent paths, keys, and tree snapshots. |
| `ebox-tree.el` | Node traversal, logical child access, TP subject adaptation, identity, parent paths, keys, and tree snapshots. |
| `ebox-measure.el` | Display-sensitive character/face/pixel measurement and measurement caches. |
| `ebox-fragment.el` | Layout fragments, signatures, snapshots, spans, and dirty-kind facts. |
| `ebox-render-context.el` | Render-local context and publication inputs. |
@ -28,7 +28,7 @@ This is the maintainer entry point for the standalone Ebox repository. It descri
| `ebox-buffer-backend.el` | Propertized render-string construction, display spaces/borders, and existing-slot shaping. |
| `ebox-incremental.el` | Runtime state, snapshots, dirty planning, owner escalation, pure commit preparation, and reports. |
| `ebox-dsl.el` | Data-oriented `.ebox` forms and lowering to public nodes. |
| `ebox-selector.el` | CSS-like queries over trees and runtime handles. |
| `ebox-selector.el` | CSS-like string parsing to TP selector ASTs, indexed candidate lookup, and tree/runtime query handles. |
| `ebox-native-reflow.el` | Optional native module loading/build commands, ABI checks, bounded sessions, and Elisp fallback. |
The package intentionally does not include application Components, UI controls, reactive data, or a playground implementation. Those are sibling-package responsibilities.
@ -75,11 +75,12 @@ Caller-owned Source Tree
- Logical `:id` values resolve through `ebox-region-resolve` to opaque surface-scoped handles. Handles, not source-tree numeric ids, distinguish the same logical region mounted in different buffers.
- A failed candidate leaves the previous buffer, runtime identity, and report intact.
- Keys are local to siblings; visible strings are never used as identity.
- Ebox owns logical node-to-subject adaptation and id/class/type candidate indexes; TP's public structured matcher is the only selector truth source. Subjects expose built-in id/key plus explicit `:selector-attributes`, never visible content, layout state, or runtime containers.
- `owner-rerender` is broader than `span-patch`, which is broader than `paint-patch`.
- Buffer coordinates belong to the generation that produced them and must be refreshed after mutation.
- Grid uses the normal measurement and rendering pipeline. Native reflow may reject an ineligible tree and must fall back to Elisp without changing correctness.
- Loading Ebox never builds or installs the optional Rust module.
- Phase 9 is active: first mount, declarative commit, handle/selector update, viewport/theme update, batch flush, and scroll update all publish through TP surfaces. Ebox computes dirty/layout ownership and joins its opaque runtime state to the same rollback-capable transaction; it has no second live buffer executor.
- Phase 9 is complete: first mount, declarative commit, handle/selector update, viewport/theme update, batch flush, and scroll update all publish through TP surfaces. Phase 10 has unified selector matching under TP; Ebox computes dirty/layout ownership and joins its opaque runtime state to the same rollback-capable transaction, with no second live buffer executor.
- Successful reports preserve the Ebox semantic strategy and planned publication scope, then add `:publication-scope tp-surface`, TP physical operation counts, surface revision, scoped/full-root facts, and retained-object reconciliation counts.
## Grid contract

View File

@ -17,7 +17,7 @@
| `ebox.el` | 公共门面、构造辅助函数、渲染、buffer 入口、滚动、commit 与 byte compile。 |
| `ebox-cache.el` | 测量/渲染缓存记录、失效和缓存报告。 |
| `ebox-style.el` | 属性注册、别名、shorthand 展开、computed style、颜色、border 和 dirty effect。 |
| `ebox-tree.el` | 节点遍历、子节点访问、identity、父路径、key 和树 snapshot。 |
| `ebox-tree.el` | 节点遍历、逻辑子节点访问、TP subject 适配、identity、父路径、key 和树 snapshot。 |
| `ebox-measure.el` | display 敏感的字符、face、像素测量与测量缓存。 |
| `ebox-fragment.el` | 布局 fragment、signature、snapshot、span 和 dirty kind 事实。 |
| `ebox-render-context.el` | render-local context 与发布输入。 |
@ -28,7 +28,7 @@
| `ebox-buffer-backend.el` | 带文本属性的渲染字符串构造、display space/border 与既有 slot 整形。 |
| `ebox-incremental.el` | runtime、snapshot、dirty 规划、owner 提升、纯 commit 准备和报告。 |
| `ebox-dsl.el` | 数据型 `.ebox` form以及向公共节点的 lowering。 |
| `ebox-selector.el` | 对树和 runtime handle 的 CSS-like 查询。 |
| `ebox-selector.el` | 把 CSS-like 字符串解析为 TP selector AST利用索引缩小候选并返回 tree/runtime query handle。 |
| `ebox-native-reflow.el` | 可选 native 模块加载/构建、ABI 校验、受限 session 和 Elisp fallback。 |
本包有意不包含应用 Component、UI control、响应式 data 或 playground 实现;它们属于同级包。历史应用性能记录器和 native reflow 评估器也不属于独立 Ebox 的发布边界Ebox 只保留 native 模块本身、Rust 构建输入和可重复的构建检查。
@ -75,11 +75,12 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
- 逻辑 `:id` 通过 `ebox-region-resolve` 解析为不透明、surface-scoped 的 handle同一逻辑 region 挂载到多个 buffer 时,由 handle 而不是 source-tree 数字 id 区分。
- 候选失败时必须保留之前的 buffer、runtime identity 和报告。
- Key 只在兄弟节点中有效;不能用可见字符串作为 identity。
- Ebox 只拥有逻辑 node-to-subject 适配与 id/class/type 候选索引TP 的公共结构化 matcher 是唯一 selector 真相源。subject 只暴露内建 id/key 和显式 `:selector-attributes`,绝不暴露可见 content、布局状态或 runtime 容器。
- `owner-rerender` 范围大于 `span-patch``span-patch` 大于 `paint-patch`
- Buffer 坐标属于生成它的 generation变更后必须重新获取。
- Grid 使用普通测量与渲染流水线native reflow 可以拒绝不适合的树并回退到 Elisp正确性不变。
- 加载 Ebox 不会构建或安装可选 Rust 模块。
- 当前已进入 Phase 9首次 mount、声明式 commit、handle/selector 更新、viewport/theme 更新、batch flush 与 scroll 更新全部通过 TP surface 发布。Ebox 只计算 dirty/layout owner并让不透明 runtime state 参与同一个可 rollback transaction不存在第二个 live buffer executor。
- Phase 9 已完成:首次 mount、声明式 commit、handle/selector 更新、viewport/theme 更新、batch flush 与 scroll 更新全部通过 TP surface 发布。Phase 10 已把 selector 匹配统一到 TPEbox 只计算 dirty/layout owner并让不透明 runtime state 参与同一个可 rollback transaction不存在第二个 live buffer executor。
- 成功报告会保留 Ebox 语义 strategy 与 planned publication scope再加入 `:publication-scope tp-surface`、TP 物理 operation 数、surface revision、scoped/full-root 事实和 retained-object reconciliation 统计。
## Grid 合同

View File

@ -110,6 +110,14 @@ Selectors query the rendered tree and return public match records. They do not o
The same logical id in two buffers resolves to two different handles, so updating one surface cannot accidentally mutate the other. A handle becomes stale when its retained object is removed or its buffer is killed. `ebox-region-update` accepts only a live handle; numeric region ids are internal render metadata and are not an update API.
`ebox-selector-parse` compiles CSS-like strings directly to TP's structured selector AST. Queries support type, `#id`, `.class`, `[key=value]`, descendant whitespace, child `>`, adjacent sibling `+`, and general sibling `~`. Ebox maps logical children and indexed candidates to generic TP subjects; `tp-selector-match-p` is the only final matcher, so query and cascade semantics cannot diverge. Attribute selectors see built-in `:id`/`:key` plus metadata explicitly supplied through `:selector-attributes`, for example `:selector-attributes '((role . button))`; visible content, layout properties, Ebox runtime containers, and internal `:ebox-*` slots never become selector attributes implicitly.
```elisp
(ebox-selector-query-buffer "*Ebox Demo*" ".toolbar > box.action")
(ebox-selector-query-buffer "*Ebox Demo*" "#first + .later")
(ebox-selector-query-buffer "*Ebox Demo*" "#first ~ [role=button]")
```
## 8. Standalone `.ebox` files
`ebox-build` reads one data-oriented Ebox form:

View File

@ -110,6 +110,14 @@ Selector 查询已渲染的树并返回公共匹配记录,但不拥有应用
两个 buffer 中相同的逻辑 id 会解析为两个不同 handle因此更新一个 surface 不会误改另一个。retained object 被删除或 buffer 被 kill 后,原 handle 会变 stale。`ebox-region-update` 只接受 live handle数字 region id 只是内部渲染元数据,不是更新 API。
`ebox-selector-parse` 会把 CSS-like 字符串直接编译成 TP 的结构化 selector AST。查询支持 type、`#id`、`.class`、`[key=value]`、空格 descendant、子节点 `>`、相邻兄弟 `+` 和一般兄弟 `~`。Ebox 只负责把逻辑子节点关系与索引候选映射成通用 TP subject最终匹配统一由 `tp-selector-match-p` 决定,因此 query 与 cascade 不会产生两套语义。属性 selector 只看内建的 `:id`/`:key`,以及通过 `:selector-attributes` 显式传入的元数据,例如 `:selector-attributes '((role . button))`;可见 content、布局属性、Ebox runtime 容器与内部 `:ebox-*` 槽位绝不会被隐式转换成 selector 属性。
```elisp
(ebox-selector-query-buffer "*Ebox Demo*" ".toolbar > box.action")
(ebox-selector-query-buffer "*Ebox Demo*" "#first + .later")
(ebox-selector-query-buffer "*Ebox Demo*" "#first ~ [role=button]")
```
## 8. 独立 `.ebox` 文件
`ebox-build` 读取一个面向数据的 Ebox form

View File

@ -262,14 +262,14 @@ unless root reflow and idle prefetch also avoid crossing several such rows.")
"Return non-nil when BUFFER's lazy scroll rows need atomic scheduling."
;; A logical candidate may deliberately defer rebuilding selector paths
;; until the first selector query. Scroll policy needs only the retained
;; type membership hint and must not turn proof rendering into a full-tree
;; selector-index rebuild.
;; runtime type membership and must not turn proof rendering into a
;; logical-tree selector-index rebuild.
(let* ((state (ebox--buffer-render-state buffer))
(type-table (plist-get state :selector-type-table))
(type-counts (plist-get state :runtime-type-count-table))
(node-table (plist-get state :node-table)))
(or (plist-get state :selector-index-stale-p)
(and (hash-table-p type-table)
(gethash 'item type-table))
(and (hash-table-p type-counts)
(> (or (gethash 'item type-counts) 0) 0))
(and (hash-table-p node-table)
(>= (hash-table-count node-table)
ebox--latency-sensitive-scroll-node-limit)))))
@ -940,6 +940,16 @@ Keys are region-id, values are plists with :scroll-offset, :content-lines, etc."
nil)))
(defun ebox-incremental--adjust-runtime-type-count (table node delta)
"Adjust NODE's raw selector-type count in TABLE by DELTA."
(when-let ((type (ebox-tree-node-selector-type node)))
(let ((count (+ (or (gethash type table) 0) delta)))
(cond
((< count 0)
(error "Ebox runtime type count became negative: %S" type))
((= count 0) (remhash type table))
(t (puthash type count table))))))
(defun ebox--runtime-index (root-node &optional collect-region-boxes)
"Return persistent runtime indexes for ROOT-NODE.
When COLLECT-REGION-BOXES is non-nil, collect `:region-box-table' during the
@ -952,29 +962,9 @@ same traversal in complete-render overwrite order."
(and collect-region-boxes (make-hash-table :test 'equal)))
(region-box-count-table (make-hash-table :test 'equal))
(host-ref-table (make-hash-table :test 'equal))
(selector-id-table (make-hash-table :test 'equal))
(selector-class-table (make-hash-table :test 'equal))
(selector-type-table (make-hash-table :test 'eq))
(runtime-type-count-table (make-hash-table :test 'eq))
native-node-postorder)
(cl-labels ((index-selector-id (node path)
(when-let ((id (ebox-tree-node-id node)))
(puthash id
(cons (cons node path)
(gethash id selector-id-table))
selector-id-table)))
(index-selector-classes (node path)
(dolist (class (ebox-tree-node-classes node))
(puthash class
(cons (cons node path)
(gethash class selector-class-table))
selector-class-table)))
(index-selector-type (node path)
(when-let ((type (ebox-tree-node-selector-type node)))
(puthash type
(cons (cons node path)
(gethash type selector-type-table))
selector-type-table)))
(index-host-ref (node node-id)
(cl-labels ((index-host-ref (node node-id)
(when-let ((host-ref (plist-get node :host-ref)))
(let ((count (hash-table-count host-ref-table)))
(puthash host-ref node-id host-ref-table)
@ -1020,102 +1010,57 @@ same traversal in complete-render overwrite order."
(when-let ((box (plist-get node :box)))
(puthash (ebox--ensure-region-id box)
box region-box-table))))))
(visit (node parent-id path native-layout-p)
(visit (node parent-id native-layout-p)
(when (and (listp node)
(not (stringp node)))
(let ((node-id (ebox--ensure-node-id node))
(type (plist-get node :ebox-type)))
(puthash node-id node node-table)
(ebox-incremental--adjust-runtime-type-count
runtime-type-count-table node 1)
(when parent-id
(puthash node-id parent-id parent-table))
(let ((current-path (append path (list node))))
(index-region-id node node-id)
(index-host-ref node node-id)
(index-selector-id node current-path)
(index-selector-classes node current-path)
(index-selector-type node current-path)
(dolist (child (ebox--node-children node))
(visit
child node-id current-path
(and native-layout-p
(not (and (eq type 'flex)
(eq child
(plist-get node :box)))))))
(index-region-box node)
;; This is the same child-before-parent order used by
;; the recursive Layout IR compiler. Flex wrapper
;; source boxes and `flex-item' metadata nodes are not
;; independent native nodes, so retain their runtime
;; indexes without adding duplicate compiler work.
(when (and native-layout-p
(not (eq type 'flex-item)))
(push node native-node-postorder)))))))
(visit root-node nil nil t))
(maphash (lambda (id entries)
(puthash id (nreverse entries) selector-id-table))
selector-id-table)
(maphash (lambda (class entries)
(puthash class (nreverse entries) selector-class-table))
selector-class-table)
(maphash (lambda (type entries)
(puthash type (nreverse entries) selector-type-table))
selector-type-table)
(list :node-table node-table
:parent-table parent-table
:region-id-set region-id-set
:region-node-table region-node-table
:region-box-table region-box-table
:region-box-count-table region-box-count-table
:host-ref-table host-ref-table
:selector-id-table selector-id-table
:selector-class-table selector-class-table
:selector-type-table selector-type-table
:native-node-postorder
(vconcat (nreverse native-node-postorder)))))
(defun ebox-incremental--selector-index (root-node)
"Return selector-only indexes for ROOT-NODE.
Logical candidates keep their core runtime indexes current immediately but
may defer selector path rebuilding. This focused traversal is therefore used
only by the first selector lookup after publication; it does not rebuild the
node, parent, region, Host, or native-layout indexes."
(let ((selector-id-table (make-hash-table :test 'equal))
(selector-class-table (make-hash-table :test 'equal))
(selector-type-table (make-hash-table :test 'eq)))
(cl-labels
((prepend (table key entry)
(puthash key (cons entry (gethash key table)) table))
(visit (node path)
(when (and (listp node) (not (stringp node)))
(let* ((current-path (append path (list node)))
(entry (cons node current-path)))
(when-let ((id (ebox-tree-node-id node)))
(prepend selector-id-table id entry))
(dolist (class (ebox-tree-node-classes node))
(prepend selector-class-table class entry))
(when-let ((type (ebox-tree-node-selector-type node)))
(prepend selector-type-table type entry))
(dolist (child (ebox--node-children node))
(visit child current-path))))))
(visit root-node nil))
(dolist (table (list selector-id-table
selector-class-table
selector-type-table))
(maphash (lambda (key entries)
(puthash key (nreverse entries) table))
table))
(list :selector-id-table selector-id-table
:selector-class-table selector-class-table
:selector-type-table selector-type-table)))
(index-region-id node node-id)
(index-host-ref node node-id)
(dolist (child (ebox--node-children node))
(visit
child node-id
(and native-layout-p
(not (and (eq type 'flex)
(eq child (plist-get node :box)))))))
(index-region-box node)
;; This is the same child-before-parent order used by
;; the recursive Layout IR compiler. Flex wrapper
;; source boxes and `flex-item' metadata nodes are not
;; independent native nodes, so retain their runtime
;; indexes without adding duplicate compiler work.
(when (and native-layout-p
(not (eq type 'flex-item)))
(push node native-node-postorder))))))
(visit root-node nil t))
(let ((selector-index (ebox-tree-selector-index root-node)))
(list :node-table node-table
:parent-table parent-table
:region-id-set region-id-set
:region-node-table region-node-table
:region-box-table region-box-table
:region-box-count-table region-box-count-table
:host-ref-table host-ref-table
:selector-id-table (plist-get selector-index :selector-id-table)
:selector-class-table
(plist-get selector-index :selector-class-table)
:selector-type-table
(plist-get selector-index :selector-type-table)
:runtime-type-count-table runtime-type-count-table
:native-node-postorder
(vconcat (nreverse native-node-postorder))))))
(defun ebox-incremental--ensure-buffer-selector-indexes (buffer)
"Return BUFFER's state after materializing deferred selector indexes."
(let ((state (ebox--buffer-render-state buffer)))
(when (and state (plist-get state :selector-index-stale-p))
(let ((index
(ebox-incremental--selector-index
(plist-get state :root-node))))
(let ((index (ebox-tree-selector-index
(plist-get state :root-node))))
(dolist (key '(:selector-id-table
:selector-class-table
:selector-type-table))
@ -1148,7 +1093,8 @@ node, parent, region, Host, or native-layout indexes."
(dolist (key '(:node-table :parent-table :region-id-set :region-node-table
:region-box-count-table :region-box-table :host-ref-table
:selector-id-table :selector-class-table
:selector-type-table :native-node-postorder))
:selector-type-table :runtime-type-count-table
:native-node-postorder))
(setq state (plist-put state key (plist-get index key))))
state)
@ -4591,6 +4537,8 @@ Return the same pure surface-input shape as
:selector-id-table (plist-get state :selector-id-table)
:selector-class-table (plist-get state :selector-class-table)
:selector-type-table (plist-get state :selector-type-table)
:runtime-type-count-table
(plist-get state :runtime-type-count-table)
:selector-index-stale-p
(plist-get state :selector-index-stale-p)
:native-node-postorder (plist-get state :native-node-postorder)))
@ -4940,10 +4888,15 @@ replace those O(n) table copies without changing this delta contract."
(host-ref-table
(ebox-incremental--candidate-copy-index-table
old-state :host-ref-table 'equal))
(runtime-type-count-table
(ebox-incremental--candidate-copy-index-table
old-state :runtime-type-count-table 'eq))
(affected-region-ids (make-hash-table :test 'equal))
(ambiguous-region-ids (make-hash-table :test 'equal)))
(dolist (node removed)
(let ((node-id (plist-get node :node-id)))
(ebox-incremental--adjust-runtime-type-count
runtime-type-count-table node -1)
(remhash node-id node-table)
(remhash node-id parent-table)
(when-let ((host-ref (plist-get node :host-ref)))
@ -4970,6 +4923,8 @@ replace those O(n) table copies without changing this delta contract."
entry))
(let ((node-id (plist-get new-node :node-id)))
(when old-node
(ebox-incremental--adjust-runtime-type-count
runtime-type-count-table old-node -1)
(when-let ((record
(ebox-incremental--candidate-node-region-record
old-node)))
@ -4978,6 +4933,8 @@ replace those O(n) table copies without changing this delta contract."
(ebox-incremental--candidate-adjust-region-box-count
region-box-count-table old-node -1)))
(puthash node-id new-node node-table)
(ebox-incremental--adjust-runtime-type-count
runtime-type-count-table new-node 1)
(if parent-id
(puthash node-id parent-id parent-table)
(remhash node-id parent-table))
@ -5056,6 +5013,7 @@ replace those O(n) table copies without changing this delta contract."
(plist-get old-state :selector-class-table)
:selector-type-table
(plist-get old-state :selector-type-table)
:runtime-type-count-table runtime-type-count-table
:selector-index-stale-p t
:native-node-postorder native-node-postorder)))
(list :index index
@ -5188,6 +5146,8 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT."
,(plist-get candidate-index :selector-class-table))
(:selector-type-table
,(plist-get candidate-index :selector-type-table))
(:runtime-type-count-table
,(plist-get candidate-index :runtime-type-count-table))
(:selector-index-stale-p
,(plist-get candidate-index :selector-index-stale-p))
(:native-node-postorder

View File

@ -1,8 +1,9 @@
;;; ebox-selector.el --- CSS-like runtime selectors for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns selector parsing and tree/runtime matching. Selectors are lookup
;; syntax only; stable identity remains node ids, region ids, and keys.
;; Compiles CSS-like lookup syntax to TP structured selectors and adapts Ebox
;; trees/runtime indexes to TP subjects. Stable identity remains node ids,
;; region ids, and keys.
;;; Code:
@ -10,6 +11,7 @@
(require 'subr-x)
(require 'ebox-buffer-backend)
(require 'ebox-tree)
(require 'tp-style)
(require 'tp-surface)
(declare-function ebox--ensure-node-id "ebox" (node))
@ -53,7 +55,7 @@
(user-error "Ebox region target is not a live buffer: %S"
buffer-or-name))
(let* ((state (ebox--buffer-render-state buffer))
(normalized (ebox-selector--metadata-string logical-id))
(normalized (ebox-tree-metadata-string logical-id))
(entries
(and state
(gethash normalized
@ -134,13 +136,19 @@ Return a cons of (VALUE . NEXT-POS)."
(cons (substring selector start pos) (1+ pos)))
(ebox-selector--read-identifier selector pos))))
(defun ebox-selector--simple-selector (type id classes attrs)
"Return a normalized simple selector plist."
(append
(when type (list :type type))
(when id (list :id id))
(when classes (list :classes (nreverse classes)))
(when attrs (list :attrs (nreverse attrs)))))
(defun ebox-selector--compound-selector (type id classes attrs)
"Return one TP selector combining TYPE, ID, CLASSES, and ATTRS."
(let ((parts
(append
(when type (list (list :type type)))
(when id (list (list :id id)))
(mapcar (lambda (class) (list :class class))
(nreverse classes))
(mapcar (lambda (attr) (list :attr (car attr) (cdr attr)))
(nreverse attrs)))))
(if (= (length parts) 1)
(car parts)
(cons :and parts))))
(defun ebox-selector--parse-attribute (selector pos attrs)
"Parse an attribute selector in SELECTOR at POS and push into ATTRS."
@ -189,11 +197,30 @@ Return a cons of (SIMPLE-SELECTOR . NEXT-POS)."
(unless (or type id classes attrs)
(user-error "ebox-selector: expected selector at %d in %S"
pos selector))
(cons (ebox-selector--simple-selector type id classes attrs) pos)))
(cons (ebox-selector--compound-selector type id classes attrs) pos)))
(defun ebox-selector--combinator (char)
"Return TP combinator for CHAR, or nil when CHAR is not a combinator."
(pcase char
(?> :child)
(?+ :adjacent)
(?~ :sibling)
(_ nil)))
(defun ebox-selector--combine-sequence (sequence)
"Fold parsed selector SEQUENCE into one nested TP selector."
(let ((result (pop sequence)))
(while sequence
(let ((combinator (pop sequence))
(target (pop sequence)))
(unless target
(user-error "ebox-selector: combinator %S has no target" combinator))
(setq result (list combinator result target))))
result))
;;;###autoload
(defun ebox-selector-parse (selector)
"Parse SELECTOR into a normalized selector AST."
"Compile CSS-like SELECTOR into TP's structured selector AST."
(unless (and (stringp selector)
(> (length (string-trim selector)) 0))
(user-error "ebox-selector: selector must be a non-empty string"))
@ -208,53 +235,18 @@ Return a cons of (SIMPLE-SELECTOR . NEXT-POS)."
(setq pos (ebox-selector--skip-space selector pos))
(cond
((>= pos len))
((= (aref selector pos) ?>)
(push :child sequence)
((ebox-selector--combinator (aref selector pos))
(push (ebox-selector--combinator (aref selector pos)) sequence)
(setq pos (ebox-selector--skip-space selector (1+ pos)))
(when (>= pos len)
(user-error "ebox-selector: child combinator has no target in %S"
(user-error "ebox-selector: combinator has no target in %S"
selector)))
((> pos before-space)
(push :descendant sequence))
(t
(user-error "ebox-selector: expected combinator at %d in %S"
pos selector)))))
(list :sequence (nreverse sequence))))
(defun ebox-selector--metadata-string (value)
"Return VALUE normalized to a selector metadata string."
(cond
((null value) nil)
((symbolp value) (symbol-name value))
((stringp value) value)
(t (format "%s" value))))
(defun ebox-selector--node-attr (node attr)
"Return NODE's selector metadata value for ATTR as a string."
(pcase attr
(:id (ebox-tree-node-id node))
(:key (ebox-tree-node-key node))
(:class nil)
(_ (and (listp node)
(ebox-selector--metadata-string (plist-get node attr))))))
;;;###autoload
(defun ebox-selector-match-node-p (node simple-selector)
"Return non-nil when NODE satisfies SIMPLE-SELECTOR."
(and (listp node)
(or (not (plist-member simple-selector :type))
(eq (plist-get simple-selector :type)
(ebox-tree-node-selector-type node)))
(or (not (plist-member simple-selector :id))
(equal (plist-get simple-selector :id)
(ebox-tree-node-id node)))
(cl-every (lambda (class)
(member class (ebox-tree-node-classes node)))
(plist-get simple-selector :classes))
(cl-every (lambda (attr)
(equal (cdr attr)
(ebox-selector--node-attr node (car attr))))
(plist-get simple-selector :attrs))))
(ebox-selector--combine-sequence (nreverse sequence))))
(defun ebox-selector--node-region-id (node)
"Return NODE's editable box region id, or nil."
@ -270,6 +262,11 @@ Return a cons of (SIMPLE-SELECTOR . NEXT-POS)."
(ebox-selector--node-region-id (plist-get node :node)))
(_ nil)))
;;;###autoload
(defun ebox-selector-match-node-p (node selector)
"Return non-nil when NODE matches structured TP SELECTOR."
(tp-selector-match-p selector (ebox-tree-node-subject node)))
(defun ebox-selector--match-handle (node path selector)
"Return a selector match handle for NODE on PATH."
(list :node node
@ -278,115 +275,25 @@ Return a cons of (SIMPLE-SELECTOR . NEXT-POS)."
:path path
:selector selector))
(defun ebox-selector--walk (node path)
"Return pre-order entries under NODE.
Each entry is a cons of (NODE . PATH), where PATH is root-to-node."
(when (and (listp node) (not (stringp node)))
(let ((current-path (append path (list node))))
(cons (cons node current-path)
(apply #'append
(mapcar (lambda (child)
(ebox-selector--walk child current-path))
(ebox-tree-children node)))))))
(defun ebox-selector--matching-subject-entries (root ast)
"Return semantic ROOT entries whose TP subjects match AST."
(cl-remove-if-not
(lambda (entry)
(tp-selector-match-p ast (plist-get entry :subject)))
(plist-get (ebox-tree-subject-index root) :entries)))
(defun ebox-selector--layout-leaves (node)
"Return semantic selector leaves for layout adapter NODE."
(cond
((or (not (listp node)) (stringp node)) nil)
(t
(pcase (plist-get node :ebox-type)
('concat
(apply #'append
(mapcar #'ebox-selector--layout-leaves
(ebox--layout-children node))))
('stack
(apply #'append
(mapcar #'ebox-selector--layout-leaves
(ebox--layout-children node))))
('flex-item
(ebox-selector--layout-leaves (plist-get node :node)))
(_ (list node))))))
(defun ebox-selector--semantic-children (node)
"Return NODE's selector child candidates.
This hides internal concat/stack adapter nodes behind wrapper boxes so child
selectors match the user's logical layout children."
(pcase (and (listp node) (plist-get node :ebox-type))
('box
(if-let ((content-node (plist-get node :ebox-content-node)))
(ebox-selector--layout-leaves content-node)
nil))
('concat
(ebox-selector--layout-leaves node))
('stack
(ebox-selector--layout-leaves node))
('flex
(mapcar #'ebox-tree-flex-item-source-node
(plist-get node :children)))
('grid
(plist-get node :children))
('flex-item
(delq nil (list (plist-get node :node))))
(_ (ebox-tree-children node))))
(defun ebox-selector--child-entries (entry)
"Return semantic child entries for ENTRY."
(let ((path (cdr entry)))
(mapcar (lambda (child)
(cons child (append path (list child))))
(ebox-selector--semantic-children (car entry)))))
(defun ebox-selector--descendant-entries (entry)
"Return descendant entries below ENTRY, excluding ENTRY itself."
(apply #'append
(mapcar (lambda (child)
(ebox-selector--walk child (cdr entry)))
(ebox-tree-children (car entry)))))
(defun ebox-selector--filter-entries (entries simple-selector)
"Return ENTRIES whose nodes match SIMPLE-SELECTOR."
(cl-remove-if-not (lambda (entry)
(ebox-selector-match-node-p
(car entry) simple-selector))
entries))
(defun ebox-selector--run-sequence (root sequence)
"Return matched entries for selector SEQUENCE under ROOT."
(let* ((first (car sequence))
(entries (ebox-selector--filter-entries
(ebox-selector--walk root nil)
first))
(rest (cdr sequence)))
(while rest
(let ((combinator (pop rest))
(simple (pop rest)))
(unless simple
(user-error "ebox-selector: combinator %S has no target"
combinator))
(setq entries
(ebox-selector--filter-entries
(pcase combinator
(:child
(apply #'append
(mapcar #'ebox-selector--child-entries entries)))
(:descendant
(apply #'append
(mapcar #'ebox-selector--descendant-entries
entries)))
(_
(user-error "ebox-selector: unsupported combinator %S"
combinator)))
simple))))
entries))
(defun ebox-selector--subject-entry-handle (entry selector)
"Return a match handle for semantic subject ENTRY and SELECTOR."
(ebox-selector--match-handle
(plist-get entry :node) (plist-get entry :path) selector))
;;;###autoload
(defun ebox-selector-query-all (root selector)
"Return document-ordered selector match handles under ROOT."
(let* ((ast (ebox-selector-parse selector))
(sequence (plist-get ast :sequence)))
(let ((ast (ebox-selector-parse selector)))
(mapcar (lambda (entry)
(ebox-selector--match-handle (car entry) (cdr entry) selector))
(ebox-selector--run-sequence root sequence))))
(ebox-selector--subject-entry-handle entry selector))
(ebox-selector--matching-subject-entries root ast))))
(defun ebox-selector--resolve-buffer (buffer)
"Return live buffer object for BUFFER, or signal a user error."
@ -411,80 +318,74 @@ selectors match the user's logical layout children."
(append handle
(list :buffer buffer :region-handle region-handle))))
(defun ebox-selector--single-id-simple (sequence)
"Return the simple selector when SEQUENCE is a single #id candidate."
(when (and (= (length sequence) 1)
(plist-member (car sequence) :id))
(car sequence)))
(defun ebox-selector--relational-p (ast)
"Return non-nil when AST contains a selector relationship."
(pcase (car-safe ast)
((or :descendant :child :adjacent :sibling) t)
((or :and :is :where :not)
(cl-some #'ebox-selector--relational-p (cdr ast)))
(_ nil)))
(defun ebox-selector--candidate-entries-for-simple (buffer simple)
"Return indexed candidate entries for SIMPLE in BUFFER, or nil."
(let (candidates)
(when-let* ((id (plist-get simple :id))
(table (ebox--buffer-selector-id-table buffer)))
(push (gethash id table) candidates))
(when-let ((table (ebox--buffer-selector-class-table buffer)))
(dolist (class (plist-get simple :classes))
(push (gethash class table) candidates)))
(when-let* ((type (plist-get simple :type))
(table (ebox--buffer-selector-type-table buffer)))
(push (gethash type table) candidates))
(when candidates
(car (sort (cl-remove-if-not #'identity candidates)
(lambda (left right)
(< (length left) (length right))))))))
(defun ebox-selector--descendant-indexable-p (ast)
"Return non-nil when AST uses no relationship except descendants."
(pcase (car-safe ast)
(:descendant
(and (ebox-selector--descendant-indexable-p (nth 1 ast))
(ebox-selector--descendant-indexable-p (nth 2 ast))))
((or :child :adjacent :sibling) nil)
((or :and :is :where :not)
(cl-every #'ebox-selector--descendant-indexable-p (cdr ast)))
(_ t)))
(defun ebox-selector--indexed-simple-entries (buffer simple)
"Return document-ordered indexed entries matching SIMPLE in BUFFER."
(when-let ((entries (ebox-selector--candidate-entries-for-simple
buffer simple)))
(cl-remove-if-not
(lambda (entry)
(ebox-selector-match-node-p (car entry) simple))
entries)))
(defun ebox-selector--target-selector (ast)
"Return the rightmost target selector from relational AST."
(if (memq (car-safe ast) '(:descendant :child :adjacent :sibling))
(ebox-selector--target-selector (nth 2 ast))
ast))
(defun ebox-selector--single-simple-sequence-p (sequence)
"Return non-nil when SEQUENCE is exactly one simple selector."
(and (= (length sequence) 1)
(listp (car sequence))))
(defun ebox-selector--index-hints (ast)
"Return indexable id, class, and type hints directly contained by AST."
(let (id classes type)
(cl-labels ((visit (selector)
(pcase (car-safe selector)
(:id (setq id (nth 1 selector)))
(:class (push (nth 1 selector) classes))
(:type (setq type (nth 1 selector)))
(:and (mapc #'visit (cdr selector))))))
(visit ast))
(list :id id :classes (nreverse classes) :type type)))
(defun ebox-selector--descendant-only-sequence-p (sequence)
"Return non-nil when SEQUENCE only uses descendant combinators."
(let ((rest (cdr sequence))
(ok t))
(while rest
(unless (and (eq (car rest) :descendant)
(listp (cadr rest)))
(setq ok nil
rest nil))
(setq rest (cddr rest)))
ok))
(defun ebox-selector--shortest-candidates (candidate-lists)
"Return the shortest non-empty list from CANDIDATE-LISTS."
(car (sort (delq nil candidate-lists)
(lambda (left right) (< (length left) (length right))))))
(defun ebox-selector--sequence-simples (sequence)
"Return simple selectors from SEQUENCE."
(cl-remove-if #'keywordp sequence))
(defun ebox-selector--indexed-candidates (buffer ast)
"Return `(SUPPORTED . ENTRIES)' for AST's indexed target in BUFFER."
(let* ((hints (ebox-selector--index-hints
(ebox-selector--target-selector ast)))
(id (plist-get hints :id))
(classes (plist-get hints :classes))
(type (plist-get hints :type))
candidates)
(when id
(push (gethash id (ebox--buffer-selector-id-table buffer)) candidates))
(dolist (class classes)
(push (gethash class (ebox--buffer-selector-class-table buffer))
candidates))
(when type
(push (gethash type (ebox--buffer-selector-type-table buffer))
candidates))
(when (or id classes type)
(cons t (ebox-selector--shortest-candidates candidates)))))
(defun ebox-selector--path-matches-descendant-sequence-p (path sequence)
"Return non-nil when PATH matches descendant-only SEQUENCE."
(let ((simples (reverse (ebox-selector--sequence-simples sequence)))
(nodes (reverse path))
ok)
(when (and simples nodes
(ebox-selector-match-node-p (car nodes) (car simples)))
(setq ok t
nodes (cdr nodes)
simples (cdr simples))
(while (and ok simples)
(let (found)
(while (and nodes (not found))
(if (ebox-selector-match-node-p (car nodes) (car simples))
(setq found t)
(setq nodes (cdr nodes))))
(if found
(setq nodes (cdr nodes)
simples (cdr simples))
(setq ok nil)))))
(and ok (null simples))))
(defun ebox-selector--indexed-entry-matches-p (entry ast)
"Return non-nil when indexed ENTRY matches TP selector AST."
(let ((subject
(if (ebox-selector--relational-p ast)
(ebox-tree-subject-for-path (cdr entry))
(ebox-tree-node-subject (car entry)))))
(tp-selector-match-p ast subject)))
(defun ebox-selector--entries-to-buffer-handles (entries selector buffer)
"Return selector match handles for ENTRIES annotated with BUFFER."
@ -495,27 +396,25 @@ selectors match the user's logical layout children."
buffer))
entries))
(defun ebox-selector--query-buffer-index
(buffer selector sequence)
"Return indexed matches for SEQUENCE in BUFFER, or nil when unsupported."
(cond
((ebox-selector--single-simple-sequence-p sequence)
(when-let ((entries (ebox-selector--indexed-simple-entries
buffer (car sequence))))
(ebox-selector--entries-to-buffer-handles entries selector buffer)))
((ebox-selector--descendant-only-sequence-p sequence)
(let* ((last-simple (car (last (ebox-selector--sequence-simples
sequence))))
(entries (ebox-selector--indexed-simple-entries
buffer last-simple)))
(when entries
(ebox-selector--entries-to-buffer-handles
(cl-remove-if-not
(lambda (entry)
(ebox-selector--path-matches-descendant-sequence-p
(cdr entry) sequence))
entries)
selector buffer))))))
(defun ebox-selector--query-buffer-index (buffer selector ast)
"Return `(SUPPORTED . HANDLES)' for an indexed AST query in BUFFER."
(when (ebox-selector--descendant-indexable-p ast)
(when-let ((indexed (ebox-selector--indexed-candidates buffer ast)))
(cons
t
(ebox-selector--entries-to-buffer-handles
(cl-remove-if-not
(lambda (entry) (ebox-selector--indexed-entry-matches-p entry ast))
(cdr indexed))
selector buffer)))))
(defun ebox-selector--query-buffer-tree (root selector ast buffer)
"Return full semantic tree matches for ROOT, SELECTOR, AST, and BUFFER."
(mapcar
(lambda (entry)
(ebox-selector--handle-with-buffer
(ebox-selector--subject-entry-handle entry selector) buffer))
(ebox-selector--matching-subject-entries root ast)))
;;;###autoload
(defun ebox-selector-query-buffer (buffer selector)
@ -529,17 +428,12 @@ tree-query handle fields."
(user-error "ebox-selector: buffer has no Ebox runtime state: %S"
resolved-buffer))
(let* ((ast (ebox-selector-parse selector))
(sequence (plist-get ast :sequence)))
(or (ebox-selector--query-buffer-index
resolved-buffer selector sequence)
(mapcar (lambda (handle)
(ebox-selector--handle-with-buffer
handle resolved-buffer))
(mapcar (lambda (entry)
(ebox-selector--match-handle
(car entry) (cdr entry) selector))
(ebox-selector--run-sequence
root sequence)))))))
(indexed (ebox-selector--query-buffer-index
resolved-buffer selector ast)))
(if indexed
(cdr indexed)
(ebox-selector--query-buffer-tree
root selector ast resolved-buffer)))))
(defun ebox-selector--skip-handle (handle reason)
"Return a structured skip entry for HANDLE and REASON."

View File

@ -11,6 +11,7 @@
(require 'subr-x)
(require 'ebox-style)
(require 'tp-style)
(defconst ebox--default-display '(block flow)
"Default CSS-like display for a plain Ebox box.")
@ -54,7 +55,7 @@ This is an optimization for snapshot capture, not buffer runtime state.")
('flex-item 'item)
(_ nil)))
(defun ebox-tree--metadata-string (value)
(defun ebox-tree-metadata-string (value)
"Return VALUE normalized to a selector metadata string."
(cond
((null value) nil)
@ -65,22 +66,65 @@ This is an optimization for snapshot capture, not buffer runtime state.")
(defun ebox-tree-node-id (node)
"Return NODE's selector id string from `:id', or nil."
(and (listp node)
(ebox-tree--metadata-string (plist-get node :id))))
(ebox-tree-metadata-string (plist-get node :id))))
(defun ebox-tree--metadata-tokens (value)
"Return VALUE as a list of normalized selector metadata tokens."
(cond
((null value) nil)
((listp value)
(delq nil (mapcar #'ebox-tree-metadata-string value)))
(t (list (ebox-tree-metadata-string value)))))
(defun ebox-tree-node-classes (node)
"Return NODE's selector class strings from `:class'."
(when (listp node)
(let ((class (plist-get node :class)))
(cond
((null class) nil)
((listp class)
(delq nil (mapcar #'ebox-tree--metadata-string class)))
(t (list (ebox-tree--metadata-string class)))))))
(and (listp node)
(ebox-tree--metadata-tokens (plist-get node :class))))
(defun ebox-tree-node-state (node)
"Return NODE's selector state tokens from `:selector-state'."
(and (listp node)
(ebox-tree--metadata-tokens (plist-get node :selector-state))))
(defun ebox-tree-node-key (node)
"Return NODE's selector key string from `:key', or nil."
(and (listp node)
(ebox-tree--metadata-string (plist-get node :key))))
(ebox-tree-metadata-string (plist-get node :key))))
(defun ebox-tree--selector-attribute-key (key)
"Return canonical keyword selector attribute KEY."
(cond
((keywordp key) key)
((symbolp key) (intern (concat ":" (symbol-name key))))
((stringp key) (intern (if (string-prefix-p ":" key)
key
(concat ":" key))))
(t (error "Ebox selector attribute name is invalid: %S" key))))
(defun ebox-tree--selector-attribute-value (value)
"Return canonical selector attribute VALUE."
(cond
((symbolp value) (symbol-name value))
((numberp value) (number-to-string value))
(t (copy-tree value))))
(defun ebox-tree-node-attributes (node)
"Return NODE's explicit selector attributes plus built-in id and key."
(when (listp node)
(let ((attributes
(delq nil
(list (when-let ((id (ebox-tree-node-id node)))
(cons :id id))
(when-let ((key (ebox-tree-node-key node)))
(cons :key key))))))
(dolist (attribute (plist-get node :selector-attributes))
(unless (consp attribute)
(error "Ebox selector attributes must be an alist: %S"
(plist-get node :selector-attributes)))
(push (cons (ebox-tree--selector-attribute-key (car attribute))
(ebox-tree--selector-attribute-value (cdr attribute)))
attributes))
(nreverse attributes))))
(defun ebox-tree--children-raw (node)
"Return NODE's direct child nodes without assigning runtime ids."
@ -545,6 +589,103 @@ candidate runtime index agree."
(plist-get node :node)
node))
(defun ebox-tree--semantic-layout-leaves (node)
"Return semantic leaves below internal layout adapter NODE."
(pcase (and (listp node) (plist-get node :ebox-type))
((or 'concat 'stack)
(apply #'append
(mapcar #'ebox-tree--semantic-layout-leaves
(ebox-tree-layout-children node))))
('flex-item
(ebox-tree--semantic-layout-leaves (plist-get node :node)))
(_ (and (listp node) (list node)))))
(defun ebox-tree-semantic-children (node)
"Return NODE's logical children without internal layout adapters."
(pcase (and (listp node) (plist-get node :ebox-type))
('box
(when-let ((content-node (plist-get node :ebox-content-node)))
(ebox-tree--semantic-layout-leaves content-node)))
((or 'concat 'stack)
(ebox-tree--semantic-layout-leaves node))
('flex
(mapcar #'ebox-tree-flex-item-source-node (plist-get node :children)))
('grid (plist-get node :children))
('flex-item (delq nil (list (plist-get node :node))))
(_ (ebox-tree-children node))))
(defun ebox-tree-selector-index (root)
"Return id, class, and type indexes over ROOT's logical selector tree."
(let ((id-table (make-hash-table :test #'equal))
(class-table (make-hash-table :test #'equal))
(type-table (make-hash-table :test #'eq)))
(cl-labels
((prepend (table key entry)
(puthash key (cons entry (gethash key table)) table))
(visit (node path)
(when (and (listp node) (not (stringp node)))
(let* ((current-path (append path (list node)))
(entry (cons node current-path)))
(when-let ((id (ebox-tree-node-id node)))
(prepend id-table id entry))
(dolist (class (ebox-tree-node-classes node))
(prepend class-table class entry))
(when-let ((type (ebox-tree-node-selector-type node)))
(prepend type-table type entry))
(dolist (child (ebox-tree-semantic-children node))
(visit child current-path))))))
(visit root nil))
(dolist (table (list id-table class-table type-table))
(maphash (lambda (key entries)
(puthash key (nreverse entries) table))
table))
(list :selector-id-table id-table
:selector-class-table class-table
:selector-type-table type-table)))
(defun ebox-tree-node-subject (node)
"Return a detached TP selector subject representing NODE."
(tp-subject-create
:type (ebox-tree-node-selector-type node)
:id (ebox-tree-node-id node)
:classes (ebox-tree-node-classes node)
:attributes (ebox-tree-node-attributes node)
:state (ebox-tree-node-state node)))
(defun ebox-tree-subject-index (root)
"Return TP subjects and semantic node paths below ROOT in document order."
(let ((node-subject-table (make-hash-table :test #'eq))
entries)
(cl-labels
((visit (node path)
(when (and (listp node) (not (stringp node)))
(let* ((subject (ebox-tree-node-subject node))
(current-path (append path (list node))))
(puthash node subject node-subject-table)
(push (list :node node :path current-path :subject subject)
entries)
(tp-subject-set-children
subject
(delq nil
(mapcar (lambda (child) (visit child current-path))
(ebox-tree-semantic-children node))))
subject))))
(let ((root-subject (visit root nil)))
(list :root-subject root-subject
:entries (nreverse entries)
:node-subject-table node-subject-table)))))
(defun ebox-tree-subject-for-path (path)
"Return a TP subject chain for the root-to-node Ebox PATH."
(let (parent subject)
(dolist (node path)
(when (and (listp node) (not (stringp node)))
(setq subject (ebox-tree-node-subject node))
(when parent
(tp-subject-set-children parent (list subject)))
(setq parent subject)))
subject))
(defun ebox-tree-flex-participation-props (node)
"Return raw flex item participation props attached to NODE."
(cond

View File

@ -55,6 +55,31 @@
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-commit-updates-raw-runtime-type-counts ()
"A structural candidate should publish exact raw runtime type counts."
(let* ((buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-commit-types*")
(ebox-build
'(flex :key root :width (80)
(item (box :key child :content "A" :width (40)))))))
(before (gethash buffer ebox--buffer-render-state-table)))
(unwind-protect
(progn
(should (= (gethash
'item (plist-get before :runtime-type-count-table))
1))
(ebox-commit
buffer (ebox-create :key 'root :content "B" :width '(80)))
(let ((after (gethash buffer ebox--buffer-render-state-table)))
(should-not
(gethash 'item (plist-get after :runtime-type-count-table)))
(should (= (gethash
'box (plist-get after :runtime-type-count-table))
1))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-commit-rolls-back-on-invalid-root ()
"A failed candidate must leave the previously published buffer intact."
(let ((buffer (ebox-render-to-buffer

View File

@ -165,14 +165,14 @@
(ebox-test--reset-runtime-state)
(let ((buffer (generate-new-buffer " *ebox-large-lookahead*"))
(node-table (make-hash-table :test 'equal))
(selector-type-table (make-hash-table :test 'eq)))
(runtime-type-count-table (make-hash-table :test 'eq)))
(unwind-protect
(progn
(dotimes (index 512)
(puthash index t node-table))
(puthash buffer
(list :node-table node-table
:selector-type-table selector-type-table)
:runtime-type-count-table runtime-type-count-table)
ebox--buffer-render-state-table)
(should
(= (ebox--root-reflow-scroll-lookahead-lines-for-buffer buffer)
@ -9400,7 +9400,7 @@
(let ((region-id "scroll")
(buffer (generate-new-buffer " *ebox-large-prefetch*"))
(node-table (make-hash-table :test 'equal))
(selector-type-table (make-hash-table :test 'eq))
(runtime-type-count-table (make-hash-table :test 'eq))
requested-lines)
(unwind-protect
(progn
@ -9408,7 +9408,7 @@
(puthash index t node-table))
(puthash buffer
(list :node-table node-table
:selector-type-table selector-type-table)
:runtime-type-count-table runtime-type-count-table)
ebox--buffer-render-state-table)
(puthash
region-id

View File

@ -43,21 +43,26 @@
(mapcar #'ebox--string-pixel-width (ebox-string-lines rendered)))
(ert-deftest ebox-selector-parses-type-id-class-and-attributes ()
"Selectors should parse type, id, class, and attribute predicates."
"Selectors should compile predicates into TP's structured selector AST."
(should (equal (ebox-selector-parse "box.warning#summary[key=main]")
'(:sequence
((:type box :id "summary" :classes ("warning")
:attrs ((:key . "main"))))))))
'(:and (:type box)
(:id "summary")
(:class "warning")
(:attr :key "main")))))
(ert-deftest ebox-selector-parses-child-and-descendant-combinators ()
"Selectors should preserve child and descendant combinator order."
"Selectors should compile relations into nested TP combinators."
(should (equal (ebox-selector-parse ".panel > box .label")
'(:sequence
((:classes ("panel"))
:child
(:type box)
:descendant
(:classes ("label")))))))
'(:descendant
(:child (:class "panel") (:type box))
(:class "label")))))
(ert-deftest ebox-selector-parses-adjacent-and-general-sibling-combinators ()
"Selectors should expose TP's adjacent and general sibling relations."
(should (equal (ebox-selector-parse "#first + box ~ .later")
'(:sibling
(:adjacent (:id "first") (:type box))
(:class "later")))))
(ert-deftest ebox-selector-queries-tree-by-id-class-and-type ()
"Selector query should match metadata on the element tree."
@ -83,6 +88,68 @@
(should (= (length (ebox-selector-query-all node ".toolbar > box")) 1))
(should (= (length (ebox-selector-query-all node ".page .action")) 2))))
(ert-deftest ebox-selector-queries-adjacent-and-general-siblings ()
"Selector query should use TP sibling semantics on logical children."
(ebox-selector-test--reset-runtime-state)
(let ((node (ebox-build
'(column
(box :id first :content "A")
(box :id second :class later :content "B")
(box :id third :class later :content "C")))))
(should (equal
(mapcar (lambda (match)
(ebox-tree-node-id (plist-get match :node)))
(ebox-selector-query-all node "#first + #second"))
'("second")))
(should (equal
(mapcar (lambda (match)
(ebox-tree-node-id (plist-get match :node)))
(ebox-selector-query-all node "#first ~ .later"))
'("second" "third")))))
(ert-deftest ebox-selector-query-delegates-matching-to-tp ()
"Ebox should adapt nodes while TP remains the only matcher."
(ebox-selector-test--reset-runtime-state)
(let ((node (ebox-build
'(column
(box :class card :content "A")
(box :content "B"))))
(calls 0)
(matcher (symbol-function 'tp-selector-match-p)))
(cl-letf (((symbol-function 'tp-selector-match-p)
(lambda (selector subject)
(cl-incf calls)
(funcall matcher selector subject))))
(should (= (length (ebox-selector-query-all node ".card")) 1)))
(should (> calls 0))))
(ert-deftest ebox-selector-subject-exposes-only-explicit-metadata ()
"Selector adaptation should not serialize Ebox runtime containers."
(let* ((opaque (vector (make-hash-table :test #'eq)))
(node (list :ebox-type 'box :id 'card :class '(panel active)
:selector-state '(focused) :state opaque
:content "visible" :width 12 :render-cache opaque
:selector-attributes '((role . button) (rank . 3))))
(subject (ebox-tree-node-subject node))
(attributes (tp-subject-attributes subject)))
(should (equal (tp-subject-classes subject) '("panel" "active")))
(should (equal (tp-subject-state subject) '("focused")))
(should (equal (cdr (assq :role attributes)) "button"))
(should (equal (cdr (assq :rank attributes)) "3"))
(should-not (assq :content attributes))
(should-not (assq :width attributes))
(should-not (assq :state attributes))
(should-not (assq :render-cache attributes))
(should-not (assq :ebox-type attributes))))
(ert-deftest ebox-selector-queries-explicit-attributes ()
"Attribute queries should read only explicit selector metadata."
(let ((node (ebox-build
'(box :content "Run"
:selector-attributes '((role . button))))))
(should (= (length (ebox-selector-query-all node "[role=button]")) 1))
(should-not (ebox-selector-query-all node "[content=Run]"))))
(ert-deftest ebox-selector-queries-rendered-buffer-runtime ()
"Selector query should return handles owned by a rendered buffer."
(ebox-selector-test--reset-runtime-state)
@ -119,7 +186,7 @@
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(cl-letf (((symbol-function 'ebox-selector--walk)
(cl-letf (((symbol-function 'ebox-tree-subject-index)
(lambda (&rest _)
(error "id query should use runtime index"))))
(let ((matches (ebox-selector-query-buffer buffer "#summary")))
@ -141,7 +208,7 @@
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(cl-letf (((symbol-function 'ebox-selector--walk)
(cl-letf (((symbol-function 'ebox-tree-subject-index)
(lambda (&rest _)
(error "class query should use runtime index"))))
(let ((matches (ebox-selector-query-buffer buffer ".card")))
@ -166,7 +233,7 @@
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(cl-letf (((symbol-function 'ebox-selector--walk)
(cl-letf (((symbol-function 'ebox-tree-subject-index)
(lambda (&rest _)
(error "descendant query should use runtime index"))))
(let ((matches (ebox-selector-query-buffer buffer ".page .action")))
@ -177,6 +244,32 @@
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-selector-buffer-index-uses-logical-not-adapter-paths ()
"Indexed descendant queries should agree with the logical tree query."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column :id shell
(box :class target :content "A")
(box :content "B"))))
buffer)
(unwind-protect
(progn
(should-not (ebox-selector-query-all node "column .target"))
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(let ((state (gethash buffer ebox--buffer-render-state-table)))
(should-not
(gethash 'column (plist-get state :selector-type-table)))
(should (= (gethash
'column
(plist-get state :runtime-type-count-table))
1)))
(should-not (ebox-selector-query-buffer
buffer "column .target")))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-selector-buffer-handles-survive-viewport-rerender ()
"Selector query should resolve current handles after viewport rerender."
(ebox-selector-test--reset-runtime-state)