diff --git a/README.md b/README.md index 7d6eefc..955528b 100644 --- a/README.md +++ b/README.md @@ -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; diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md index 487b34c..6525439 100644 --- a/docs/maintainer/ebox-current-implementation-reference.en.md +++ b/docs/maintainer/ebox-current-implementation-reference.en.md @@ -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 diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md index c20b55f..e4f9514 100644 --- a/docs/maintainer/ebox-current-implementation-reference.zh.md +++ b/docs/maintainer/ebox-current-implementation-reference.zh.md @@ -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 匹配统一到 TP;Ebox 只计算 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 合同 diff --git a/docs/user/ebox-user-guide.en.md b/docs/user/ebox-user-guide.en.md index 1c9d629..a766dee 100644 --- a/docs/user/ebox-user-guide.en.md +++ b/docs/user/ebox-user-guide.en.md @@ -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: diff --git a/docs/user/ebox-user-guide.zh.md b/docs/user/ebox-user-guide.zh.md index 1ab624d..75c5323 100644 --- a/docs/user/ebox-user-guide.zh.md +++ b/docs/user/ebox-user-guide.zh.md @@ -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: diff --git a/ebox-incremental.el b/ebox-incremental.el index 7328f7b..208fe8f 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -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 diff --git a/ebox-selector.el b/ebox-selector.el index e4e6e6c..d82ea70 100644 --- a/ebox-selector.el +++ b/ebox-selector.el @@ -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." diff --git a/ebox-tree.el b/ebox-tree.el index 7b8975c..361e9aa 100644 --- a/ebox-tree.el +++ b/ebox-tree.el @@ -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 diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index 3ed97d9..3749907 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -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 diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index 7179b0b..edf7b37 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -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 diff --git a/tests/ebox-selector-tests.el b/tests/ebox-selector-tests.el index f6bbd46..fee03d4 100644 --- a/tests/ebox-selector-tests.el +++ b/tests/ebox-selector-tests.el @@ -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)