From 0c7e7b974af69a0ae90823cc4b55260ddc3858e9 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Thu, 27 Aug 2026 12:14:51 +0800 Subject: [PATCH] refactor: enforce canonical Text and Box runtime boundaries --- Makefile | 17 +- docs/user/ebox-api-reference.en.md | 2 +- docs/user/ebox-api-reference.zh.md | 2 +- ebox-canonical.el | 266 +++- ebox-child-range.el | 22 +- ebox-dsl.el | 418 +++---- ebox-flex.el | 247 ++-- ebox-fragment.el | 34 +- ebox-grid.el | 49 +- ebox-incremental.el | 1621 ++++++++++++++++++------- ebox-layout-config.el | 30 + ebox-layout.el | 350 ++++-- ebox-measure.el | 15 +- ebox-native-commit.el | 90 +- ebox-native-reflow.el | 463 +++++-- ebox-node-factory.el | 64 +- ebox-render-context.el | 2 + ebox-selector.el | 2 - ebox-style.el | 269 +++- ebox-surface.el | 1035 +++++++++++++--- ebox-tree.el | 569 +++++---- ebox.el | 102 +- native/core/src/lib.rs | 28 +- native/src/layout.rs | 259 +++- scripts/ebox-performance-evaluator.el | 23 +- scripts/ebox-visual-check.el | 12 +- tests/ebox-child-range-tests.el | 471 ++++--- tests/ebox-commit-tests.el | 1239 +++++-------------- tests/ebox-core-render-tests.el | 1357 +++++++++++---------- tests/ebox-dsl-tests.el | 729 ++++------- tests/ebox-fixtures.el | 253 ++++ tests/ebox-flex-tests.el | 823 +++++-------- tests/ebox-grid-tests.el | 159 ++- tests/ebox-package-tests.el | 28 +- tests/ebox-selector-tests.el | 94 +- tests/ebox-surface-tests.el | 647 +++++----- 36 files changed, 6808 insertions(+), 4983 deletions(-) create mode 100644 tests/ebox-fixtures.el diff --git a/Makefile b/Makefile index 1833662..e225d56 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ ECSS_DIR ?= ../ecss LOAD_EXTRA ?= EMACS_BATCH = $(EMACS) -Q --batch -L . -L $(ECSS_DIR) -L $(TP_DIR) $(LOAD_EXTRA) --eval '(setq load-prefer-newer t)' +EMACS_TEST = $(EMACS_BATCH) -L tests -l tests/ebox-fixtures.el NATIVE_MANIFEST = native/Cargo.toml NATIVE_TARGET ?= $(shell $(EMACS_BATCH) -l ebox-native-reflow.el --eval '(princ (ebox-native-reflow--rust-target))') NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release @@ -27,34 +28,34 @@ compile: test: core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests core-tests: - $(EMACS_BATCH) -l tests/ebox-core-render-tests.el -f ert-run-tests-batch-and-exit + $(EMACS_TEST) -l tests/ebox-core-render-tests.el -f ert-run-tests-batch-and-exit child-range-tests: - $(EMACS_BATCH) -l tests/ebox-child-range-tests.el -f ert-run-tests-batch-and-exit + $(EMACS_TEST) -l tests/ebox-child-range-tests.el -f ert-run-tests-batch-and-exit grid-tests: - $(EMACS_BATCH) -l tests/ebox-grid-tests.el -f ert-run-tests-batch-and-exit + $(EMACS_TEST) -l tests/ebox-grid-tests.el -f ert-run-tests-batch-and-exit ebox-commit-tests: - $(EMACS_BATCH) -l tests/ebox-commit-tests.el -f ert-run-tests-batch-and-exit + $(EMACS_TEST) -l tests/ebox-commit-tests.el -f ert-run-tests-batch-and-exit surface-tests: - $(EMACS_BATCH) -l tests/ebox-surface-tests.el -f ert-run-tests-batch-and-exit + $(EMACS_TEST) -l tests/ebox-surface-tests.el -f ert-run-tests-batch-and-exit visual-check-tests: - $(EMACS_BATCH) -l tests/ebox-visual-check-tests.el -f ert-run-tests-batch-and-exit + $(EMACS_TEST) -l tests/ebox-visual-check-tests.el -f ert-run-tests-batch-and-exit package-tests: $(EMACS_BATCH) -l tests/ebox-package-tests.el -f ert-run-tests-batch-and-exit selector-tests: - $(EMACS_BATCH) -l tests/ebox-selector-tests.el -f ert-run-tests-batch-and-exit + $(EMACS_TEST) -l tests/ebox-selector-tests.el -f ert-run-tests-batch-and-exit dsl-tests: $(EMACS_BATCH) -l tests/ebox-dsl-tests.el -f ert-run-tests-batch-and-exit flex-tests: - $(EMACS_BATCH) -l tests/ebox-flex-tests.el -f ert-run-tests-batch-and-exit + $(EMACS_TEST) -l tests/ebox-flex-tests.el -f ert-run-tests-batch-and-exit performance-evaluator: surface-tests ebox-commit-tests $(EMACS_BATCH) -l scripts/ebox-performance-evaluator.el -f ebox-performance-evaluator-batch diff --git a/docs/user/ebox-api-reference.en.md b/docs/user/ebox-api-reference.en.md index f3c3a33..a57ac93 100644 --- a/docs/user/ebox-api-reference.en.md +++ b/docs/user/ebox-api-reference.en.md @@ -191,7 +191,7 @@ together. | Border | `:border`, `:border-top`, `:border-right`, `:border-bottom`, `:border-left`, `:border-width`, `:border-style`, `:border-color`; side longhands `:border-*-width`, `:border-*-style`, `:border-*-color`; Ebox boolean shorthands `:border-top-p`, `:border-bottom-p`; compatibility aliases `:border-left-pixel`, `:border-right-pixel` | | Paint | `:color`, `:background-color`, and alias `:bgcolor` | | Typography | `:font`, `:font-family`, `:font-height` and alias `:font-size`, `:font-weight`, `:font-slant` | -| Text/layout | `:text-align` (`left`, `center`, `right`), `:vertical-align` (`top`, `center`/`middle`, `bottom`), `:overflow` (`scroll`, `hidden`, `visible`), `:wrap-mode` (`word`, `char`, `kp`, or nil), `:visibility` (`visible` or `hidden`) | +| Box content | `:text-align` (`left`, `center`, `right`), `:vertical-align` (`top`, `center`/`middle`, `bottom`), `:overflow` (`scroll`, `hidden`, `visible`), `:wrap-mode` (`word`, `char`, `kp`, or `none`; default `word`), `:visibility` (`visible` or `hidden`) | | Structural style | `:display` is computed for node/layout contexts; the layout constructors normally choose the valid display tuple. | | Flex container/item | `:flex-direction`, `:flex-wrap`, `:flex-flow`, `:justify-content`, `:align-items`, `:align-content`, `:gap`, `:row-gap`, `:column-gap` (aliases `:grid-row-gap`, `:grid-column-gap`); item names `:order`, `:flex`, `:flex-grow`, `:flex-shrink`, `:flex-basis`, `:align-self` | | Grid container/item | `:grid-template-columns`, `:grid-template-rows`, `:grid-auto-columns`, `:grid-auto-rows`, `:grid-auto-flow`, `:justify-items`; item placement `:grid-column`, `:grid-row`, `:grid-column-span`, `:grid-row-span` | diff --git a/docs/user/ebox-api-reference.zh.md b/docs/user/ebox-api-reference.zh.md index 12ec304..80dfa82 100644 --- a/docs/user/ebox-api-reference.zh.md +++ b/docs/user/ebox-api-reference.zh.md @@ -174,7 +174,7 @@ property;`ebox-region-update` 接受其目标支持的可变 content/style/scr | Border | `:border`、`:border-top`、`:border-right`、`:border-bottom`、`:border-left`、`:border-width`、`:border-style`、`:border-color`;side longhand `:border-*-width`、`:border-*-style`、`:border-*-color`;Ebox boolean shorthand `:border-top-p`、`:border-bottom-p`;兼容别名 `:border-left-pixel`、`:border-right-pixel` | | Paint | `:color`、`:background-color`、别名 `:bgcolor` | | Typography | `:font`、`:font-family`、`:font-height` 和别名 `:font-size`、`:font-weight`、`:font-slant` | -| Text/layout | `:text-align`(`left`、`center`、`right`)、`:vertical-align`(`top`、`center`/`middle`、`bottom`)、`:overflow`(`scroll`、`hidden`、`visible`)、`:wrap-mode`(`word`、`char`、`kp` 或 nil)、`:visibility`(`visible` 或 `hidden`) | +| Box 内容 | `:text-align`(`left`、`center`、`right`)、`:vertical-align`(`top`、`center`/`middle`、`bottom`)、`:overflow`(`scroll`、`hidden`、`visible`)、`:wrap-mode`(`word`、`char`、`kp`、`none`,默认 `word`)、`:visibility`(`visible` 或 `hidden`) | | Structural style | `:display` 会在 node/layout context 中计算;有效的 display tuple 通常由布局构造器选择。 | | Flex container/item | `:flex-direction`、`:flex-wrap`、`:flex-flow`、`:justify-content`、`:align-items`、`:align-content`、`:gap`、`:row-gap`、`:column-gap`(别名 `:grid-row-gap`、`:grid-column-gap`);item 名称 `:order`、`:flex`、`:flex-grow`、`:flex-shrink`、`:flex-basis`、`:align-self` | | Grid container/item | `:grid-template-columns`、`:grid-template-rows`、`:grid-auto-columns`、`:grid-auto-rows`、`:grid-auto-flow`、`:justify-items`;item placement `:grid-column`、`:grid-row`、`:grid-column-span`、`:grid-row-span` | diff --git a/ebox-canonical.el b/ebox-canonical.el index 1a2aeff..2e12034 100644 --- a/ebox-canonical.el +++ b/ebox-canonical.el @@ -14,6 +14,7 @@ (require 'ebox-layout-config) (require 'ebox-style) (require 'ebox-node-factory) +(require 'ebox-child-range) (defun ebox-canonical--validate-plist (plist context) "Validate PLIST shape for error CONTEXT and return PLIST." @@ -52,16 +53,40 @@ (error "%s accepts %S at most once" context field)) (and values (cons t (car values))))) -(defun ebox-canonical--compiled-property-definitions (plist context) - "Return canonical property definitions compiled from PLIST for CONTEXT." - (condition-case err - (cl-loop for (property _value) - on (ebox-style-compile-declarations plist t) by #'cddr - collect (or (ebox-style-property property) - (error "Missing canonical Ebox property: %S" - property))) - (error - (error "%s rejected properties: %s" context (error-message-string err))))) +(defconst ebox-canonical--source-metadata-fields + '(:source-handle :key :class :id) + "Source facts accepted by both canonical Text and Box constructors.") + +(defvar ebox-canonical--declaration-fact-cache + (make-hash-table :test #'eq :weakness 'key) + "Declaration-identity keyed validated canonical node facts.") + +(defun ebox-canonical--source-metadata (plist context) + "Return detached source metadata from PLIST for CONTEXT. +Every metadata field is optional but may occur at most once." + (let (metadata) + (dolist (field ebox-canonical--source-metadata-fields) + (when-let* ((entry (ebox-canonical--optional-field + plist field context))) + (setq metadata + (append metadata (list field (cdr entry)))))) + metadata)) + +(defun ebox-canonical--apply-source-metadata (node metadata) + "Attach canonical source METADATA to runtime NODE and return NODE." + (while metadata + (let ((field (pop metadata)) + (value (pop metadata))) + (plist-put node + (pcase field + (:source-handle :ebox-source-handle) + (_ field)) + value) + ;; `:host-ref' is the private retained-geometry projection of the + ;; source-owned handle. It is not a second public identity field. + (when (and (eq field :source-handle) value) + (plist-put node :host-ref value)))) + node) (defun ebox-canonical--text-measurement-property-p (property) "Return non-nil when PROPERTY belongs to canonical Text measurement." @@ -70,20 +95,81 @@ (defun ebox-canonical--box-frame-property-p (property) "Return non-nil when PROPERTY belongs to Box geometry or participation." (let ((contexts (plist-get property :contexts)) - (group (plist-get property :group)) - (name (plist-get property :name))) + (group (plist-get property :group))) (or (memq 'item contexts) (and (memq 'box contexts) - (not (memq group '(paint typography))) - (not (eq name :wrap-mode)))))) + (not (memq group '(paint typography))))))) -(defun ebox-canonical--assert-property-role (plist predicate context) - "Require every compiled PLIST property to satisfy PREDICATE for CONTEXT." - (dolist (property - (ebox-canonical--compiled-property-definitions plist context)) - (unless (funcall predicate property) - (error "%s does not accept %S" - context (plist-get property :name))))) +(defun ebox-canonical--node-properties + (props declarations-field predicate context tag) + "Return declarations, engine props, and projection need for one node. +PROPS are direct typed inputs. DECLARATIONS-FIELD is an optional normalized +author fact set. PREDICATE selects canonical node-owned values for CONTEXT +and author TAG." + (if declarations-field + (progn + (when props + (error "%s cannot mix normalized declarations with direct properties" + context)) + (let* ((declarations (cdr declarations-field)) + (by-tag + (or (gethash declarations + ebox-canonical--declaration-fact-cache) + (let ((table (make-hash-table :test #'eq))) + (puthash declarations table + ebox-canonical--declaration-fact-cache) + table))) + (missing (make-symbol "ebox-canonical-facts-missing")) + (cached (gethash tag by-tag missing))) + (if (not (eq cached missing)) + cached + (ebox-style-validate-form-declarations tag declarations) + (let ((facts + (list declarations + (ebox-style-declaration-properties + declarations + (lambda (property) + (or (funcall predicate property) + (memq (plist-get property :group) + '(paint typography))))) + (ebox-style-declarations-require-projection-p + declarations + (lambda (property) + (or (funcall predicate property) + (memq (plist-get property :group) + '(paint typography)) + (and (memq tag '(flex grid)) + (memq tag + (plist-get property + :contexts))))))))) + (setf (nth 1 facts) + (ebox-style-expand-ebox-plist (nth 1 facts))) + (puthash tag facts by-tag) + facts)))) + (let ((declarations + (if (null props) + nil + (condition-case err + (ebox-style-compile-declarations props t) + (error + (error "%s rejected properties: %s" + context (error-message-string err))))))) + (cl-loop for (id _value) on declarations by #'cddr + for property = (or (ebox-style-property id) + (error "Missing canonical Ebox property: %S" + id)) + unless (funcall predicate property) + do (error "%s does not accept %S" + context (plist-get property :name))) + (list declarations + (ebox-style-expand-ebox-plist + (ebox-style-declaration-properties + declarations + (lambda (property) + (or (funcall predicate property) + (memq (plist-get property :group) + '(paint typography)))))) + nil)))) (defun ebox-node-kind (node) "Return canonical NODE kind, or nil for a legacy runtime node." @@ -114,34 +200,93 @@ (ebox-layout-config--copy (plist-get node :ebox-layout-config))) (defun ebox-box-node-children (node) - "Return canonical BoxNode NODE's retained canonical children." + "Return canonical BoxNode NODE's material Text/Box children." (unless (ebox-box-node-p node) (error "Expected canonical BoxNode, got %S" node)) - (plist-get node :children)) + (if (plist-member node :children) + (plist-get node :children) + (when-let* ((sequence (plist-get node :ebox-child-sequence))) + (ebox-child-range--flatten sequence)))) + +(defun ebox-box-node-range-anchors (node) + "Return transparent Range anchors retained by canonical BoxNode NODE." + (unless (ebox-box-node-p node) + (error "Expected canonical BoxNode, got %S" node)) + (let ((sequence (plist-get node :ebox-child-sequence)) + (offset 0) + anchors) + (when sequence + (dotimes (index (ebox-child-range--sequence-count sequence)) + (let* ((segment (ebox-child-range--segment-at sequence index)) + (payload (ebox-child-range--segment-payload segment)) + (after (+ offset (length payload)))) + (when-let* ((ref (ebox-child-range--segment-ref segment))) + (push (list :ref ref :before offset :after after) anchors)) + (setq offset after)))) + (nreverse anchors))) + +(defun ebox-canonical--normalize-children (children) + "Return material children and optional Range sequence for CHILDREN." + (let (material segments range-p) + (dolist (entry children) + (if (ebox-child-range--descriptor-p entry) + (let ((items (ebox-child-range--descriptor-items entry))) + (unless (cl-every (lambda (item) + (or (ebox-text-node-p item) + (ebox-box-node-p item))) + items) + (error "Ebox Box Range items must be canonical Text/Box nodes: %S" + items)) + (setq range-p t) + (push (cons (ebox-child-range--descriptor-ref entry) items) + segments) + (dolist (item items) + (push item material))) + (unless (or (ebox-text-node-p entry) (ebox-box-node-p entry)) + (error "Ebox Box children must be canonical Text/Box nodes or Ranges: %S" + children)) + (push (cons nil (list entry)) segments) + (push entry material))) + (list (nreverse material) + (and range-p + (ebox-child-range--build (nreverse segments) nil t))))) ;;;###autoload (defun ebox-text-create (&rest plist) "Create a canonical TextNode from evaluated PLIST. -`:value' is required and must be one string. `:source-handle' is optional. - Only text measurement properties are accepted; paint is projected separately." +`:value' is required and must be one string. `:source-handle', `:key', +`:class', and `:id' are optional source facts. Direct properties are limited +to text measurement. `:declarations' may carry one already-normalized Text +author fact set so paint is projected without reparsing." (ebox-canonical--validate-plist plist "ebox-text-create") (let* ((value (ebox-canonical--required-field plist :value "Ebox Text")) - (source-field (ebox-canonical--optional-field - plist :source-handle "Ebox Text")) - (source-handle (cdr source-field)) + (declarations-field (ebox-canonical--optional-field + plist :declarations "Ebox Text")) + (source-metadata (ebox-canonical--source-metadata plist "Ebox Text")) (props (ebox-canonical--without-keys - plist '(:value :source-handle)))) + plist (append '(:value :declarations) + ebox-canonical--source-metadata-fields)))) (unless (stringp value) (error "Ebox Text :value must be a string: %S" value)) - (ebox-canonical--assert-property-role - props #'ebox-canonical--text-measurement-property-p "Ebox Text") - (let ((node (apply #'ebox-create (append props (list :content value))))) + (pcase-let* ((`(,declarations ,engine-props ,style-required-p) + (ebox-canonical--node-properties + props declarations-field + #'ebox-canonical--text-measurement-property-p + "Ebox Text" 'text)) + (node + (ebox-node-factory--create-text-expanded + (append engine-props (list :content value)) + declarations))) (plist-put node :ebox-kind 'text) (plist-put node :ebox-text-value value) - (plist-put node :ebox-source-handle source-handle) + (ebox-canonical--apply-source-metadata node source-metadata) (plist-put node :display '(inline flow)) + (plist-put node :ebox-author-style-count + (if style-required-p 1 0)) + (when style-required-p + (plist-put node :ebox-author-style-required-p t)) node))) ;;;###autoload @@ -150,21 +295,24 @@ `:layout' must occur exactly once as an `ebox-layout-config'. `:children' is a list of canonical nodes. Every Layout accepts zero or more -children according to its own formatting algorithm." +children according to its own formatting algorithm. `:declarations' may +carry one already-normalized Box author fact set." (ebox-canonical--validate-plist plist "ebox-box-create") (let* ((layout (ebox-canonical--required-field plist :layout "Ebox Box")) (children-field (ebox-canonical--optional-field plist :children "Ebox Box")) + (declarations-field (ebox-canonical--optional-field + plist :declarations "Ebox Box")) (outer-field (ebox-canonical--optional-field plist :outer "Ebox Box")) - (source-field (ebox-canonical--optional-field - plist :source-handle "Ebox Box")) + (source-metadata (ebox-canonical--source-metadata plist "Ebox Box")) (children (if children-field (cdr children-field) nil)) + child-sequence (outer (if outer-field (cdr outer-field) 'block)) - (source-handle (cdr source-field)) (props (ebox-canonical--without-keys - plist '(:layout :children :outer :source-handle)))) + plist (append '(:layout :children :outer :declarations) + ebox-canonical--source-metadata-fields)))) (unless (ebox-layout-config-p layout) (error "Ebox Box :layout must be a typed layout config: %S" layout)) @@ -173,31 +321,47 @@ children according to its own formatting algorithm." (error "Ebox Box :outer must be inline or block: %S" outer)) (unless (proper-list-p children) (error "Ebox Box :children must be a proper list: %S" children)) - (unless (cl-every (lambda (child) - (or (ebox-text-node-p child) - (ebox-box-node-p child))) - children) - (error "Ebox Box children must be canonical Text/Box nodes: %S" - children)) + (pcase-let ((`(,material-children ,normalized-sequence) + (ebox-canonical--normalize-children children))) + (setq children material-children) + (setq child-sequence normalized-sequence)) (unless (memq (ebox-layout-config-kind layout) '(normal row column flex grid)) (error "Ebox Box Layout is not implemented: %S" (ebox-layout-config-kind layout))) - (ebox-canonical--assert-property-role - props #'ebox-canonical--box-frame-property-p "Ebox Box") - (let ((node (apply #'ebox-create props))) + (pcase-let* ((tag (if (eq (ebox-layout-config-kind layout) 'normal) + 'box + (ebox-layout-config-kind layout))) + (`(,declarations ,engine-props ,style-required-p) + (ebox-canonical--node-properties + props declarations-field + #'ebox-canonical--box-frame-property-p + "Ebox Box" tag)) + (node (ebox-node-factory--create-expanded + engine-props declarations))) (plist-put node :ebox-kind 'box) (plist-put node :ebox-layout-config layout) (plist-put node :children children) - (plist-put node :ebox-source-handle source-handle) - ;; Children own their text formatting. The Box frame consumes their - ;; already-laid-out output and must not wrap or justify it a second time. - (plist-put node :wrap-mode nil) + (when child-sequence + (plist-put node :ebox-child-sequence child-sequence)) + (ebox-canonical--apply-source-metadata node source-metadata) (plist-put node :display (list outer (pcase (ebox-layout-config-kind layout) ('normal 'flow) (kind kind)))) + (let ((style-count + (+ (if style-required-p 1 0) + (cl-loop for child in children + sum (or (plist-get child + :ebox-author-style-count) + (if (plist-get + child :ebox-author-style-required-p) + 1 + 0)))))) + (plist-put node :ebox-author-style-count style-count) + (when (> style-count 0) + (plist-put node :ebox-author-style-required-p t))) node))) (provide 'ebox-canonical) diff --git a/ebox-child-range.el b/ebox-child-range.el index ccded2a..a6c5c29 100644 --- a/ebox-child-range.el +++ b/ebox-child-range.el @@ -185,16 +185,20 @@ VALUE is the replacement segment and METRICS records all path work." (cdr (assoc key (and node (ebox-child-range--hash-node-bucket node)) #'equal)))) -(defun ebox-child-range--item-copy (item metrics) - "Validate and copy declarative ITEM while recording METRICS." +(defun ebox-child-range--item-copy (item metrics &optional retain-identity-p) + "Validate declarative ITEM while recording METRICS. +Return ITEM itself when RETAIN-IDENTITY-P is non-nil; otherwise return a +shallow copy owned by the persistent sequence." (when (ebox-child-range--descriptor-p item) (error "Nested child Range descriptors are reserved")) (unless (and (listp item) (not (stringp item))) (error "Ebox child Range item must be one declarative node")) (ebox-child-range--metric-add metrics 'new-payload-validations) (ebox-child-range--metric-add metrics 'new-payload-visits) - (ebox-child-range--metric-add metrics 'new-payload-copies) - (copy-sequence item)) + (if retain-identity-p + item + (ebox-child-range--metric-add metrics 'new-payload-copies) + (copy-sequence item))) (defun ebox-child-range--descriptor-create (ref items) "Return a reserved Range descriptor for non-nil REF and list ITEMS." @@ -203,10 +207,13 @@ VALUE is the replacement segment and METRICS records all path work." (signal 'wrong-type-argument (list 'proper-list-p items))) (ebox-child-range--make-descriptor :ref ref :items items)) -(defun ebox-child-range--build (segments &optional hash-function) +(defun ebox-child-range--build + (segments &optional hash-function retain-item-identities-p) "Build an immutable child sequence from SEGMENTS. Each entry is `(REF . ITEMS)'; nil REF denotes one static child item. -HASH-FUNCTION optionally supplies the stable key hash." +HASH-FUNCTION optionally supplies the stable key hash. When +RETAIN-ITEM-IDENTITIES-P is non-nil, ITEMS are already-owned canonical nodes +and the sequence retains those exact objects instead of copying them." (unless (proper-list-p segments) (signal 'wrong-type-argument (list 'proper-list-p segments))) (let* ((count (length segments)) @@ -227,7 +234,8 @@ HASH-FUNCTION optionally supplies the stable key hash." (when ref (puthash ref index refs)) (let ((payload (vconcat (mapcar (lambda (item) - (ebox-child-range--item-copy item metrics)) + (ebox-child-range--item-copy + item metrics retain-item-identities-p)) items)))) (setq root (ebox-child-range--segment-insert root height index diff --git a/ebox-dsl.el b/ebox-dsl.el index 3b784c7..7c699b2 100644 --- a/ebox-dsl.el +++ b/ebox-dsl.el @@ -1,27 +1,166 @@ -;;; ebox-dsl.el --- DSL compiler facade for Ebox -*- lexical-binding: t; -*- +;;; ebox-dsl.el --- Canonical author DSL for Ebox -*- lexical-binding: t; -*- ;;; Commentary: -;; Owns `.ebox` DSL compilation and compatibility sugar over the Ebox element -;; and style models. It does not own core render or buffer patch execution. +;; Owns the complete inert `.ebox' author boundary: form parsing, form-local +;; property validation, and desugaring into canonical TextNode/BoxNode plus +;; typed LayoutConfig values. It does not own layout, paint materialization, +;; buffer publication, or framework runtime identity. ;;; Code: (require 'cl-lib) (require 'ebox-style) -(require 'ebox-tree) +(require 'ebox-canonical) (require 'ebox-flex) (require 'ebox-grid) -(require 'ebox-canonical) -(declare-function ebox-create "ebox" (&rest plist)) +(cl-defstruct + (ebox-dsl--properties + (:constructor ebox-dsl--make-properties)) + "Normalized properties for one canonical Text or Box author source." + tag + source-metadata + outer + layout + declarations) -(defconst ebox-dsl--outer-values '(inline block) - "Valid outer participation values for the canonical Box DSL.") +(defconst ebox-dsl--box-tags + '(box row column flex grid) + "Author tags that construct one canonical BoxNode.") -(defconst ebox-dsl--layout-values '(normal row column flex grid) - "Valid child layout values for the canonical Box DSL.") +(defconst ebox-dsl--source-fields + '(:key :class :id) + "Shared source metadata accepted by Ebox author surfaces.") -(defun ebox--dsl-unquote-value (value) +(defun ebox-dsl--source-handle () + "Return one opaque identity for a normalized author node." + (make-symbol "ebox-source")) + +(defun ebox-dsl--validate-plist (plist context) + "Validate PLIST shape for CONTEXT and return PLIST." + (unless (and (proper-list-p plist) (zerop (% (length plist) 2))) + (error "%s properties must be an even plist: %S" context plist)) + (cl-loop for key in plist by #'cddr + unless (keywordp key) + do (error "%s property name must be a keyword: %S" context key)) + plist) + +(defun ebox-dsl--field-values (plist field) + "Return every FIELD value from PLIST in source order." + (cl-loop for (key value) on plist by #'cddr + when (eq key field) + collect value)) + +(defun ebox-dsl--optional-field (plist field context) + "Return optional FIELD from PLIST, rejecting duplicates for CONTEXT." + (let ((values (ebox-dsl--field-values plist field))) + (when (> (length values) 1) + (error "%s accepts %S at most once" context field)) + (and values (cons t (car values))))) + +(defun ebox-dsl--without-keys (plist keys) + "Return PLIST without entries whose keys occur in KEYS." + (cl-loop for (key value) on plist by #'cddr + unless (memq key keys) + append (list key value))) + +(defun ebox-dsl--keep-keys (plist keys) + "Return PLIST entries whose keys occur in KEYS." + (cl-loop for (key value) on plist by #'cddr + when (memq key keys) + append (list key value))) + +(defun ebox-dsl--source-metadata + (properties source-handle context) + "Return source metadata from PROPERTIES and SOURCE-HANDLE for CONTEXT." + (let ((metadata (list :source-handle source-handle))) + (dolist (field ebox-dsl--source-fields) + (when-let* ((entry (ebox-dsl--optional-field + properties field context))) + (setq metadata + (append metadata (list field (cdr entry)))))) + metadata)) + +(defun ebox-dsl--compile-style (tag properties context) + "Compile and validate TAG author PROPERTIES for CONTEXT." + (condition-case err + (if properties + (ebox-style-compile-form tag properties) + nil) + (error + (error "%s rejected properties: %s" + context (error-message-string err))))) + +(defun ebox-dsl--layout-config + (tag declarations extensions context) + "Construct TAG LayoutConfig from DECLARATIONS and EXTENSIONS for CONTEXT." + (if (eq tag 'text) + nil + (condition-case err + (ebox-layout-config-for-form + tag + (if (memq tag '(row column)) + extensions + (let ((names (ebox-layout-config-property-names tag))) + (ebox-style-declaration-properties + declarations + (lambda (property) + (memq (plist-get property :name) names)))))) + (error + (error "%s rejected layout config: %s" + context (error-message-string err)))))) + +(defun ebox-dsl--normalize-properties (tag properties source-handle) + "Project evaluated TAG PROPERTIES for opaque SOURCE-HANDLE. + +TAG is `text', `box', `row', `column', `flex', or `grid'. The returned +`ebox-dsl--properties' contains typed constructor inputs, source metadata, +and canonical declarations. Child forms and text payloads are outside this +function." + (unless (memq tag (cons 'text ebox-dsl--box-tags)) + (error "Ebox author properties has unknown author tag: %S" tag)) + (let* ((context (format "Ebox %S" tag)) + (properties + (ebox-dsl--validate-plist properties context))) + (dolist (field '(:content :layout :display :ebox-type :ebox-content-node)) + (when (plist-member properties field) + (error "%s does not accept private author property %S" + context field))) + (let* ((outer-field + (and (not (eq tag 'text)) + (ebox-dsl--optional-field + properties :outer context))) + (outer (and (not (eq tag 'text)) + (if outer-field (cdr outer-field) 'block))) + (extension-keys (and (memq tag '(row column)) + '(:item-gap :cross-align))) + (extensions + (ebox-dsl--keep-keys + properties extension-keys)) + (style-properties + (ebox-dsl--without-keys + properties + (append (unless (eq tag 'text) '(:outer)) + extension-keys + ebox-dsl--source-fields))) + (declarations + (ebox-dsl--compile-style + tag style-properties context))) + (ebox-dsl--make-properties + :tag tag + :source-metadata + (ebox-dsl--source-metadata + properties source-handle context) + :outer outer + :layout + (ebox-dsl--layout-config + tag declarations extensions context) + :declarations declarations)))) + +(defconst ebox-dsl--tags '(text box row column flex grid) + "The complete Ebox author tag set.") + +(defun ebox-dsl--unquote-value (value) "Unquote one static DSL property VALUE when it is quoted data." (if (and (consp value) (eq (car value) 'quote) @@ -29,218 +168,91 @@ (cadr value) value)) -(defun ebox--dsl-unquote-properties (items) - "Unquote static property values in top-level DSL ITEMS. -Structural child forms remain untouched; only values immediately following -keyword properties are normalized." +(defun ebox-dsl--unquote-properties (items) + "Unquote static property values in top-level DSL ITEMS." (let (result) (while items (let ((item (pop items))) (if (keywordp item) (progn (unless items - (error "ebox-build: missing value for %S" item)) + (error "Ebox build: missing value for %S" item)) (push item result) - (push (ebox--dsl-unquote-value (pop items)) result)) + (push (ebox-dsl--unquote-value (pop items)) result)) (push item result)))) (nreverse result))) -(defun ebox-dsl--axis-value (props property default allowed) - "Return PROPS PROPERTY or DEFAULT after validating it against ALLOWED." - (let ((value (if (plist-member props property) - (plist-get props property) - default))) - (unless (memq value allowed) - (error "ebox-build: invalid %S value %S" property value)) - value)) +(defun ebox-dsl--split (items) + "Return `(PROPERTIES . CHILDREN)' parsed from author ITEMS." + (let (properties children) + (while items + (let ((item (pop items))) + (if (keywordp item) + (progn + (unless items + (error "Ebox build: missing value for %S" item)) + (setq properties + (append properties (list item (pop items))))) + (setq children (append children (list item)))))) + (cons properties children))) -(defun ebox-dsl--with-display (node outer layout) - "Return a shallow copy of NODE with canonical OUTER and LAYOUT display." - (let ((copy (copy-sequence node))) - (plist-put copy :display - (list outer (if (eq layout 'normal) 'flow layout))) - copy)) - -(defun ebox--build-children-layout (children) - "Build a vertical default layout from CHILDREN." - (setq children (delq nil children)) - (cond - ((null children) nil) - ((null (cdr children)) (ebox-dsl-build (car children))) - (t (apply #'ebox-column (mapcar #'ebox-dsl-build children))))) - -(defun ebox--build-box-content (children &optional _props) - "Return content or child layout represented by DSL CHILDREN. -The first value is string content. The second value is a lazy child node." - (setq children (delq nil children)) - (cond - ((null children) - (cl-values nil nil)) - ((cl-every #'stringp children) - (cl-values (string-join children "\n") nil)) - (t - (cl-values nil (ebox--build-children-layout children))))) - -(defun ebox--build-box (items) - "Build a box node from DSL ITEMS." - (let* ((split (ebox--build-split-attrs items)) - (props (car split)) - (children (cdr split))) - (cl-multiple-value-bind (content child-node) - (ebox--build-box-content children props) - (when (and (or content child-node) (plist-member props :content)) - (error "ebox-build: box cannot combine :content with child nodes")) - (apply #'ebox-create - (append (if child-node - (ebox--preformatted-box-props props) - props) - (when content (list :content content)) - (when child-node - (list :ebox-content-node child-node))))))) - -(defun ebox--build-text (items) - "Build one canonical TextNode from DSL ITEMS." - (let* ((split (ebox--build-split-attrs items)) - (props (car split)) - (payloads (delq nil (cdr split)))) - (when props - (error "Ebox build: Text author properties are not implemented in this slice")) +(defun ebox-dsl--build-text (_source items) + "Build one canonical TextNode from author ITEMS." + (let* ((split (ebox-dsl--split items)) + (properties (car split)) + (payloads (cdr split))) (unless (= (length payloads) 1) - (error "ebox-build: text requires exactly one string payload")) + (error "Ebox text requires exactly one string payload")) (unless (stringp (car payloads)) - (error "ebox-build: text payload must be a string: %S" (car payloads))) - (apply #'ebox-text-create - (append props (list :value (car payloads)))))) + (error "Ebox text payload must be a string: %S" (car payloads))) + (let* ((plan (ebox-dsl--normalize-properties + 'text properties (ebox-dsl--source-handle))) + (node + (apply #'ebox-text-create + (append (list :value (car payloads)) + (ebox-dsl--properties-source-metadata plan) + (list :declarations + (ebox-dsl--properties-declarations plan)))))) + node))) -(defun ebox--build-wrap-layout (tag props layout) - "Wrap LAYOUT with box PROPS for DSL TAG, or return LAYOUT unchanged." - (if (null props) - layout - (when (plist-member props :content) - (error "ebox-build: %S cannot combine :content with child nodes" tag)) - (apply #'ebox-create - (append (ebox--preformatted-box-props props) - (list :display '(block flow) - :ebox-content-node layout))))) - -(defun ebox--build-layout (tag constructor items) - "Build a row/column layout TAG with CONSTRUCTOR from DSL ITEMS." - (let* ((split (ebox--build-split-attrs items)) - (props (car split)) - (children (cdr split)) - (layout - (apply constructor - (mapcar (lambda (child) - (if (ebox-child-range--descriptor-p child) - child - (ebox-dsl-build child))) - (delq nil children))))) - (ebox--build-wrap-layout tag props layout))) - -(defun ebox--build-flex (items) - "Build a flex node from DSL ITEMS." - (let* ((split (ebox--build-split-attrs items)) - (props (car split)) - (children (mapcar #'ebox-dsl-build (delq nil (cdr split))))) - (apply #'ebox-flex (append props children)))) - -(defun ebox--build-flex-item (items) - "Build a flex item wrapper from DSL ITEMS." - (let* ((split (ebox--build-split-attrs items)) - (props (car split)) - (children (cdr split)) - (item-props (ebox--plist-keep-keys props ebox--flex-item-prop-keys)) - (box-props (ebox--plist-remove-keys props ebox--flex-item-prop-keys))) - (unless children - (error "ebox-build: item requires a child node")) - (let ((child (ebox--flex-wrap-node-with-box-props - 'item (ebox--build-children-layout children) box-props))) - (dolist (key ebox--flex-item-prop-keys) - (when (plist-member item-props key) - (plist-put child key (plist-get item-props key)))) - (apply #'ebox-flex-item child item-props)))) - -(defun ebox--build-grid (items) - "Build a grid node from DSL ITEMS." - (let* ((split (ebox-grid--split-attrs items)) - (props (car split)) - (children (mapcar #'ebox-dsl-build (delq nil (cdr split))))) - (apply #'ebox-grid (append props children)))) - -(defun ebox--build-spacer (items) - "Build a spacer node from DSL ITEMS." - (let* ((split (ebox--build-split-attrs items)) - (props (car split)) - (children (cdr split))) - (when children - (error "ebox-build: spacer cannot have child nodes")) - (when (plist-member props :content) - (error "ebox-build: spacer cannot set :content")) - (apply #'ebox-spacer props))) - -(defun ebox--build-canonical-box (items) - "Build canonical Box ITEMS with orthogonal outer and layout axes." - (let* ((split (ebox--build-split-attrs items)) - (props (car split)) - (children (cdr split)) - (outer (ebox-dsl--axis-value - props :outer 'block ebox-dsl--outer-values)) - (layout (ebox-dsl--axis-value - props :layout 'normal ebox-dsl--layout-values)) - (box-props (ebox--plist-remove-keys props '(:outer :layout))) - node) - (setq node - (pcase layout - ('normal - (ebox--build-box (append box-props children))) - ('row - (ebox--build-layout - 'box #'ebox-row (append box-props children))) - ('column - (ebox--build-layout - 'box #'ebox-column (append box-props children))) - ('flex - (ebox--build-flex (append box-props children))) - ('grid - (ebox--build-grid (append box-props children))))) - (ebox-dsl--with-display node outer layout))) +(defun ebox-dsl--build-box (tag _source items) + "Build canonical BoxNode TAG from author ITEMS." + (let* ((split (ebox-dsl--split items)) + (properties (car split)) + (child-forms (cdr split)) + (plan (ebox-dsl--normalize-properties + tag properties (ebox-dsl--source-handle))) + (children (mapcar #'ebox-dsl-build child-forms)) + (node + (apply #'ebox-box-create + (append + (list :layout (ebox-dsl--properties-layout plan) + :outer (ebox-dsl--properties-outer plan) + :children children + :declarations + (ebox-dsl--properties-declarations plan)) + (ebox-dsl--properties-source-metadata plan))))) + node)) (defun ebox-dsl-build (dsl) - "Build an Ebox node from an ETML-style list DSL. + "Build one canonical Ebox node from inert author DSL. -Supported forms: - (box :content \"Hello\" :width (160)) - (box :content \"Fits preview\" :width (viewport)) - (box :width (240) (row (box \"A\") (box \"B\"))) - (row (box :content \"Left\") (spacer :width (16)) (box :content \"Right\")) - (column (box :content \"Top\") (box :content \"Bottom\")) - (grid :grid-template-columns ((80) (80)) - (box :grid-column (1 :span 2) \"Header\")) - (flex :width (320) :gap (1 (12)) (item :flex 1 (box \"A\"))) - -`box' and `ebox' create `ebox-create' nodes. `row', `column', -`flex', and `spacer' compile to existing Ebox layout helpers. `row' -and `column' may also receive box properties; those properties wrap -the child layout in a box using the rendered child layout as content." +The complete grammar is String plus `text', `box', `row', `column', `flex', +and `grid'. Strings normalize to Text. The five Box forms select a typed +LayoutConfig; no form accepts `:content', `:layout', or a raw runtime node." (cond - ((ebox--node-p dsl) dsl) - ((stringp dsl) (ebox-text-create :value dsl)) + ((stringp dsl) + (ebox-dsl--build-text dsl (list dsl))) ((not (and (consp dsl) (symbolp (car dsl)))) - (error "ebox-build: invalid DSL node %S" dsl)) + (error "Ebox build: invalid author node %S" dsl)) + ((not (memq (car dsl) ebox-dsl--tags)) + (error "Ebox build: unknown author tag %S" (car dsl))) + ((eq (car dsl) 'text) + (ebox-dsl--build-text + dsl (ebox-dsl--unquote-properties (cdr dsl)))) (t - (let ((tag (car dsl)) - (items (ebox--dsl-unquote-properties (cdr dsl)))) - (pcase tag - ('text (ebox--build-text items)) - ('box (ebox--build-canonical-box items)) - ('ebox (ebox--build-box items)) - ('row (ebox--build-layout tag #'ebox-row items)) - ('column (ebox--build-layout tag #'ebox-column items)) - ('flex (ebox--build-flex items)) - ('item (ebox--build-flex-item items)) - ('spacer (ebox--build-spacer items)) - ('grid (ebox--build-grid items)) - (_ (error "ebox-build: unknown DSL tag %S" tag))))))) + (ebox-dsl--build-box + (car dsl) dsl (ebox-dsl--unquote-properties (cdr dsl)))))) (provide 'ebox-dsl) diff --git a/ebox-flex.el b/ebox-flex.el index a425c35..2d832c3 100644 --- a/ebox-flex.el +++ b/ebox-flex.el @@ -40,9 +40,8 @@ (cache-key signature rendered &optional side-effects)) (declare-function ebox--render-with-cache "ebox-incremental" (node &optional force cache-probe)) -(declare-function ebox-tree--register-legacy-flex-item-adapter - "ebox-tree" (adapter token source content)) -(declare-function ebox-create "ebox" (&rest plist)) +(declare-function ebox-node-factory--create-from-properties + "ebox-node-factory" (&rest plist)) (declare-function ebox-get "ebox" (box property)) (declare-function ebox--ensure-node-id "ebox" (node)) (declare-function ebox--ensure-region-id "ebox" (box)) @@ -82,14 +81,6 @@ '(:order :flex :flex-grow :flex-shrink :flex-basis :align-self) "Properties that belong to flex item metadata.") -(defconst ebox--flex-item-direct-adapter-token - (make-symbol "ebox-flex-item-direct-adapter") - "Opaque provenance token for a legacy Flex item without a Box wrapper.") - -(defconst ebox--flex-item-wrapper-adapter-token - (make-symbol "ebox-flex-item-wrapper-adapter") - "Opaque provenance token for a legacy Flex item with one Box wrapper.") - (defconst ebox--flex-container-layout-prop-keys '(:flex-direction :flex-wrap :flex-flow :justify-content :align-items :align-content @@ -176,28 +167,12 @@ That text is preformatted and must not be sent through the default wrapper." props (append props (list :wrap-mode nil)))) -(defun ebox--flex-wrap-node-with-box-props (tag node props) - "Wrap NODE in an `ebox-create' box using PROPS for DSL/API TAG." - (if (null props) - node - (when (plist-member props :content) - (error "ebox-build: %S cannot combine :content with child nodes" tag)) - (let ((box (apply #'ebox-create - (append (ebox--preformatted-box-props props) - (list :display '(block flow) - :ebox-content-node node))))) - (when (listp node) - (dolist (key ebox--flex-item-prop-keys) - (when (plist-member node key) - (plist-put box key (plist-get node key))))) - box))) - (defun ebox--flex-make-wrapper-box (props) "Return a flex container wrapper box for PROPS, or nil." (when (and props (ebox--flex-visual-box-props-p props)) (when (plist-member props :content) (error "ebox-flex: flex cannot combine :content with child nodes")) - (apply #'ebox-create + (apply #'ebox-node-factory--create-from-properties (append (ebox--preformatted-box-props props) (list :content ""))))) @@ -528,13 +503,22 @@ programmatic port and are therefore rejected here." (plist-get source :ebox-type) (not (eq (plist-get source :ebox-type) 'box)))) -(defun ebox--flex-bind-source-viewport (source viewport thunk) +(defun ebox--flex-box-source-p (source) + "Return non-nil when SOURCE owns Box geometry for Flex sizing." + (and (listp source) + (not (stringp source)) + (eq (plist-get source :ebox-type) 'box) + (not (eq (plist-get source :ebox-kind) 'text)))) + +(defun ebox--flex-bind-source-viewport (_source viewport thunk) "Call THUNK in SOURCE's detached Flex-item render context. -VIEWPORT is bound only for composite SOURCE nodes; the Flex parent context is -always bound because SOURCE remains a direct Flex participant while rendered -as a detached geometry root." +When VIEWPORT is non-nil it is the containing block assigned by the Flex +parent and therefore applies to SOURCE's complete subtree, including a +canonical Box with responsive descendants. Intrinsic measurement passes nil +explicitly. The Flex parent context is always bound because SOURCE remains a +direct Flex participant while rendered as a detached geometry root." (let ((ebox--render-root-parent-kind 'flex)) - (if (and viewport (ebox--flex-composite-source-p source)) + (if viewport (let ((ebox-viewport-width viewport)) (funcall thunk)) (funcall thunk)))) @@ -555,7 +539,8 @@ width cannot affect that final output. The first occurrence is only observed; this avoids signature and retained-cost work for one-off responsive sizes." (when (and ebox--render-cache-table (numberp viewport) - (ebox--flex-composite-source-p source)) + (or (ebox--flex-composite-source-p source) + (ebox--viewport-dependent-subtree-p source))) (let* ((observations (or (gethash ebox--render-cache-table ebox--flex-sized-render-observation-table) @@ -589,7 +574,7 @@ this avoids signature and retained-cost work for one-off responsive sizes." Direct box flex items are measured intrinsically. Composite items receive VIEWPORT for descendants so auto and max-width layouts can resolve against their containing block, while stack/column containers do not stretch their own - outer measurement width to VIEWPORT." +outer measurement width to VIEWPORT." (if (ebox--flex-composite-source-p source) (ebox--flex-bind-source-viewport source viewport @@ -618,7 +603,7 @@ grapheme." (let ((declared-min (or (ebox--resolve-size-content-pixel box (ebox-get box :min-width) 0) 0))) - (if (null (ebox-get box :wrap-mode)) + (if (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode)) ;; CSS's automatic minimum is min-content only when the author ;; leaves `min-width' at its automatic value. A component that ;; deliberately declares `:min-width 0' must be allowed to @@ -660,13 +645,13 @@ grapheme." (defun ebox--flex-min-main (source rendered axis) "Return SOURCE's minimum main-axis size for flex layout on AXIS." - (if (and (listp source) (eq (plist-get source :ebox-type) 'box)) + (if (ebox--flex-box-source-p source) (ebox--flex-box-min-main source rendered axis) (ebox--flex-string-main rendered axis))) (defun ebox--flex-max-main (source axis) "Return SOURCE's maximum main-axis size for flex layout on AXIS." - (when (and (listp source) (eq (plist-get source :ebox-type) 'box)) + (when (ebox--flex-box-source-p source) (ebox--flex-box-main-constraint source axis (if (eq axis 'row) :max-width :max-height)))) @@ -683,12 +668,11 @@ grapheme." ((eq basis 'auto) (ebox--flex-string-main rendered axis)) ((eq basis 'content) - (if (and (listp source) (eq (plist-get source :ebox-type) 'box)) + (if (ebox--flex-box-source-p source) (ebox--flex-box-content-basis-main source rendered axis) (ebox--flex-string-main rendered axis))) ((and (eq axis 'row) - (listp source) - (eq (plist-get source :ebox-type) 'box)) + (ebox--flex-box-source-p source)) (let* ((normalized (ebox--normalize-horizontal-size-value basis ebox--preferred-size-keywords)) @@ -727,9 +711,7 @@ grapheme." (ebox--flex-recache-source-boxes child))) ('flex (dolist (child (ebox-tree-layout-children node)) - (ebox--flex-recache-source-boxes child))) - ('flex-item - (ebox--flex-recache-source-boxes (plist-get node :node))))))) + (ebox--flex-recache-source-boxes child))))))) (defun ebox--flex-box-main-margin (box axis) "Return BOX's total margin on AXIS." @@ -763,7 +745,7 @@ grapheme." (defun ebox--flex-copy-node-for-size (node axis main cross stretch) "Return NODE or a copy with flex-computed MAIN/CROSS size." - (if (and (listp node) (eq (plist-get node :ebox-type) 'box)) + (if (ebox--flex-box-source-p node) (let ((copy (copy-sequence node))) (ebox--ensure-region-id node) (plist-put copy :region-id (ebox-get node :region-id)) @@ -784,7 +766,11 @@ grapheme." (defun ebox--flex-measure-cacheable-p (source props) "Return non-nil when SOURCE and PROPS have stable flex measurements." - (and (ebox--render-cacheable-node-p source) + (and (or (and (eq (and (listp source) + (plist-get source :ebox-type)) + 'box) + (not (ebox--flex-composite-source-p source))) + (ebox--render-cacheable-node-p source)) (not (ebox--viewport-dependent-size-value-p (plist-get props :flex-basis))))) @@ -793,13 +779,20 @@ grapheme." The result is nil when SOURCE cannot use the persistent measurement cache; otherwise it carries the key, signature, and any cached measurement so a caller can pass the same miss context to the renderer." - (when (ebox--flex-measure-cacheable-p source props) - (let* ((cache-key - (list (ebox--ensure-node-id source) 'flex-measure axis props)) - (signature (ebox--flex-render-cache-signature source))) - (list :cache-key cache-key - :signature signature - :cached (ebox--render-cache-lookup cache-key signature))))) + (let ((direct-box-p + (and (eq (and (listp source) (plist-get source :ebox-type)) 'box) + (not (ebox--flex-composite-source-p source))))) + ;; `ebox--flex-render-source-for-measurement' renders a direct Box with no + ;; ambient inline viewport. Compute the cache proof in that exact same + ;; context so a root resize cannot invalidate an unchanged natural size. + (let ((ebox-viewport-width (if direct-box-p nil ebox-viewport-width))) + (when (ebox--flex-measure-cacheable-p source props) + (let* ((cache-key + (list (ebox--ensure-node-id source) 'flex-measure axis props)) + (signature (ebox--flex-render-cache-signature source))) + (list :cache-key cache-key + :signature signature + :cached (ebox--render-cache-lookup cache-key signature))))))) (defun ebox--flex-measure-cache-value (probe) "Return a copied cached measurement from PROBE, or nil on a miss." @@ -871,11 +864,11 @@ box heights use declared line constraints. In those cases the final sized render can be the first render instead of rendering the whole subtree once for measurement and immediately rendering it again at the computed size." (let ((basis (plist-get props :flex-basis))) - (when (and (listp source) - (eq (plist-get source :ebox-type) 'box) + (when (and (ebox--flex-box-source-p source) (ebox--flex-fixed-basis-value-p basis) (or (not (eq axis 'row)) - (ebox-get source :wrap-mode))) + (ebox-style-soft-wrap-p + (ebox-get source :wrap-mode)))) (let* ((min-main (ebox--flex-min-main source "" axis)) (max-main (ebox--flex-max-main source axis)) (base (ebox--flex-basis-main source "" axis basis)) @@ -1013,10 +1006,7 @@ Each item in PARTS is a cons cell (RENDERED . WIDTH)." "Render flex ITEM with MAIN and optional CROSS size. Return a sized render entry containing the rendered string and dimensions." (let* ((source (plist-get item :source)) - (source-box-p - (eq (and (listp source) - (plist-get source :ebox-type)) - 'box)) + (source-box-p (ebox--flex-box-source-p source)) (align (ebox--flex-resolved-align item container-align)) (stretch (memq align '(stretch normal))) (render-main (plist-get item :render-main)) @@ -1265,7 +1255,7 @@ Return a sized render entry containing the rendered string and dimensions." (let ((items (cl-loop for child in children for index from 0 - for source = (ebox--flex-item-source-node child) + for source = child for props = (ebox--flex-item-props child) for measurement = (ebox--flex-item-measurement source props axis viewport) @@ -1296,7 +1286,7 @@ Return a sized render entry containing the rendered string and dimensions." (puthash source-id t source-set)) (cl-loop for child in children for index from 0 - for source = (ebox--flex-item-source-node child) + for source = child for source-id = (plist-get source :node-id) when (gethash source-id source-set) do (let* ((props (ebox--flex-item-props child)) @@ -1640,65 +1630,6 @@ metadata and rendering decisions." (ebox--flex-pad-width result cross-size) result)))) -;;;###autoload -(defun ebox-flex-item (node &rest plist) - "Wrap NODE with flex item metadata and optional box properties from PLIST." - (let ((item-props (ebox--plist-keep-keys plist ebox--flex-item-prop-keys)) - (box-props (ebox--plist-remove-keys plist ebox--flex-item-prop-keys))) - (let* ((source (ebox--flex-wrap-node-with-box-props - 'ebox-flex-item node box-props)) - (source (copy-sequence source)) - (declarations - (append (plist-get source :ebox-style-declarations) - (ebox-style-compile-declarations item-props))) - (adapter (list :ebox-type 'flex-item - :display '(block flow) - :node source - :props item-props)) - (token (if box-props - ebox--flex-item-wrapper-adapter-token - ebox--flex-item-direct-adapter-token))) - (plist-put source :ebox-style-declarations declarations) - (ebox-tree--register-legacy-flex-item-adapter - adapter token source - (and box-props (plist-get source :ebox-content-node)))))) - -;;;###autoload -(defun ebox-flex (&rest items) - "Return a lazy flex layout node from ITEMS. - -Container properties include flex layout properties -\(:flex-direction, :flex-wrap, :flex-flow, :justify-content, :align-items, -:align-content, :gap, :row-gap, :column-gap) plus normal `ebox-create' -box properties. When visual box properties are present, :width and :height -size the outer box and the inner flex layout uses its content area. - -Item properties can be stored with `ebox-flex-item' or directly on a -child box: :order, :flex-grow, :flex-shrink, :flex-basis, :flex, and -:align-self. `ebox-flex-item' can also carry normal box properties around -its child." - (let* ((split (ebox--build-split-attrs items)) - (raw-props (car split)) - (box-props (ebox--plist-remove-keys - raw-props ebox--flex-container-layout-prop-keys)) - (wrapper-box (ebox--flex-make-wrapper-box box-props)) - (props (ebox--flex-normalize-container-props raw-props)) - (children (delq nil (cdr split))) - (node (list :ebox-type 'flex - :display '(block flex) - :props props - :raw-props raw-props - :ebox-style-declarations - (ebox-style-compile-declarations raw-props) - :box wrapper-box - :children children))) - (when wrapper-box - (plist-put wrapper-box :ebox-style-wrapper t)) - (dolist (key '(:id :class :selector-state :selector-attributes :key)) - (when (plist-member raw-props key) - (plist-put node key (plist-get raw-props key)))) - node)) - (defun ebox--render-flex-box (node rendered) "Render flex NODE's container box around RENDERED, when present." (if-let* ((box (plist-get node :box))) @@ -1825,7 +1756,7 @@ assembles already-rendered items." (cond ((< measure-index (length children)) (let* ((child (nth measure-index children)) - (source (ebox--flex-item-source-node child)) + (source child) (props (ebox--flex-item-props child)) (measurement (ebox--flex-item-measurement @@ -1856,6 +1787,29 @@ assembles already-rendered items." (plist-put entry :render-index (1+ render-index)) t)))))))) +(defun ebox--flex-window-canonical-node-p (node) + "Return non-nil when NODE is one canonical Flex Layout Box." + (and (eq (plist-get node :ebox-kind) 'box) + (when-let* ((config (plist-get node :ebox-layout-config))) + (eq (ebox-layout-config-kind config) 'flex)))) + +(defun ebox--flex-window-box (node) + "Return NODE's Box wrapper for lazy Flex rendering." + (if (ebox--flex-window-canonical-node-p node) + node + (plist-get node :box))) + +(defun ebox--flex-window-props (node) + "Return NODE's resolved Flex properties for lazy row production." + (if (ebox--flex-window-canonical-node-p node) + (append + (ebox-layout-config-props (plist-get node :ebox-layout-config)) + (ebox--flex-box-constraints node)) + (ebox--flex-container-content-props + (plist-get node :props) + (plist-get node :raw-props) + (plist-get node :box)))) + (defun ebox--flex-window-wrapper-supported-p (box) "Return non-nil when BOX can safely wrap an incomplete flex prefix." (or (null box) @@ -1863,7 +1817,8 @@ assembles already-rendered items." (or (null (ebox-get box :min-height)) (equal (ebox-get box :min-height) 0)) (null (ebox-get box :max-height)) - (null (ebox-get box :wrap-mode)) + (or (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode)) + (ebox--flex-window-canonical-node-p box)) (eq (ebox-get box :text-align) 'left) (eq (ebox-get box :vertical-align) 'top)))) @@ -1872,6 +1827,16 @@ assembles already-rendered items." When COMPLETE is nil, trailing vertical chrome is suppressed so the result is a prefix of the eventual full wrapper render." (let ((copy (copy-sequence box))) + (when (ebox--flex-window-canonical-node-p copy) + ;; The lazy producer has already rendered the Flex children. Lower the + ;; private wrapper copy to Normal content so the generic Box painter + ;; applies only this Box's chrome and ownership once. + (cl-remf copy :children) + (cl-remf copy :ebox-child-sequence) + (plist-put copy :ebox-layout-config (ebox-normal-layout-create)) + (plist-put copy :display + (list (or (car-safe (plist-get copy :display)) 'block) + 'flow))) (unless complete (plist-put copy :padding-bottom-height 0) (plist-put copy :margin-bottom-height 0) @@ -1912,7 +1877,7 @@ a prefix of the eventual full wrapper render." (defun ebox--flex-window-render-box-lines (node content complete limit) "Return NODE flex wrapper lines around CONTENT up to LIMIT." (let* ((rendered - (if-let* ((box (plist-get node :box))) + (if-let* ((box (ebox--flex-window-box node))) (let ((copy (ebox--flex-window-wrapper-copy box complete)) (region-id (ebox--ensure-region-id box))) (plist-put copy :region-id region-id) @@ -1952,7 +1917,8 @@ the chunk begins at the wrapper top and should include top chrome." (border-top-p (and include-top (ebox-get box :border-top-p))) (border-top-color (ebox-get box :border-top-color)) - (color (ebox-get box :color)) + (color (and (ebox-style--text-paint-owner-p box) + (ebox-get box :color))) (bgcolor (ebox-get box :bgcolor)) (inner-height (+ (length content-lines) padding-top)) result) @@ -2060,14 +2026,13 @@ the chunk begins at the wrapper top and should include top chrome." (defun ebox--flex-window-definite-main (source axis) "Return SOURCE's definite main size for prefix flex rendering." (when (and (eq axis 'row) - (listp source) - (eq (plist-get source :ebox-type) 'box) + (ebox--flex-box-source-p source) (plist-member source :width)) (ebox--flex-box-main-constraint source axis :width))) (defun ebox--flex-window-cheap-item (child axis index) "Return cheap flex item metadata for CHILD, or nil when unsupported." - (let* ((source (ebox--flex-item-source-node child)) + (let* ((source child) (props (ebox--flex-item-props child)) (basis (plist-get props :flex-basis)) (main (ebox--flex-window-definite-main source axis)) @@ -2096,7 +2061,8 @@ the chunk begins at the wrapper top and should include top chrome." :base main :target main :window-frozen-shrinkable - (and (ebox-get source :wrap-mode) + (and (ebox-style-soft-wrap-p + (ebox-get source :wrap-mode)) (not (zerop (or (plist-get props :flex-shrink) 1))) t)))))) @@ -2109,7 +2075,8 @@ the chunk begins at the wrapper top and should include top chrome." (memq (plist-get props :flex-wrap) '(wrap nowrap)) (not (ebox--flex-reverse-p props)) (ebox--flex-default-order-p children) - (ebox--flex-window-wrapper-supported-p (plist-get node :box)))) + (ebox--flex-window-wrapper-supported-p + (ebox--flex-window-box node)))) (defun ebox--flex-window-lines-from-pieces (pieces) "Return flex content string from already rendered row PIECES." @@ -2126,7 +2093,7 @@ the chunk begins at the wrapper top and should include top chrome." (defun ebox--flex-window-prefix-line-count (node pieces) "Return visible line count for NODE prefix PIECES without wrapper rerender." - (+ (ebox--flex-window-top-chrome-lines (plist-get node :box)) + (+ (ebox--flex-window-top-chrome-lines (ebox--flex-window-box node)) (if pieces (ebox-string-height (ebox--flex-window-lines-from-pieces pieces)) @@ -2269,7 +2236,7 @@ the chunk begins at the wrapper top and should include top chrome." (defun ebox--flex-window-completion-prefix-tail-rewrite-count (node) "Return how many cached prefix tail lines completion rewrites for NODE." - (let ((box (plist-get node :box))) + (let ((box (ebox--flex-window-box node))) (if (and box (ebox-get box :border-bottom-p) (= (floor (ebox-get box :padding-bottom-height)) 0)) @@ -2320,7 +2287,8 @@ the chunk begins at the wrapper top and should include top chrome." (+ cached-content-count (length new-content-lines)) (length (ebox-string-lines content)))) (prefix-line-count - (+ (ebox--flex-window-top-chrome-lines (plist-get node :box)) + (+ (ebox--flex-window-top-chrome-lines + (ebox--flex-window-box node)) prospective-content-count)) (source-complete-visible-p (and source-complete @@ -2331,14 +2299,14 @@ the chunk begins at the wrapper top and should include top chrome." (wrapper-lines (and chunk-wrapper-p new-content-lines (ebox--flex-window-render-wrapper-chunk-lines - (plist-get node :box) + (ebox--flex-window-box node) new-content-lines cached-content-count seedless-chunk-p))) (prospective-line-count (+ cached-line-count (length wrapper-lines))) (completion-prefix-supported-p (and chunk-wrapper-p effective-source-complete (ebox--flex-window-complete-prefix-supported-p - (plist-get node :box)))) + (ebox--flex-window-box node)))) (exact-append-p (or completion-prefix-supported-p (and chunk-wrapper-p (not effective-source-complete)))) @@ -2357,7 +2325,7 @@ the chunk begins at the wrapper top and should include top chrome." (completed-tail-line (and completion-prefix-supported-p prospective-tail (ebox--flex-window-complete-tail-line - (plist-get node :box) (car prospective-tail)))) + (ebox--flex-window-box node) (car prospective-tail)))) content-lines content-line-count content-line-tail @@ -2454,10 +2422,7 @@ Return nil when the flex container needs the normal full renderer." (ebox--flex-window-cache-store node limit (catch 'unsupported - (let* ((props (ebox--flex-container-content-props - (plist-get node :props) - (plist-get node :raw-props) - (plist-get node :box))) + (let* ((props (ebox--flex-window-props node)) (axis (ebox--flex-axis props)) (children (ebox-tree-layout-children node)) (main-size (if (eq axis 'row) @@ -2493,7 +2458,7 @@ Return nil when the flex container needs the normal full renderer." (visible-line-count (or (plist-get seed :prefix-line-count) (ebox--flex-window-top-chrome-lines - (plist-get node :box)))) + (ebox--flex-window-box node)))) result complete) (cl-labels diff --git a/ebox-fragment.el b/ebox-fragment.el index 327306e..ab354b5 100644 --- a/ebox-fragment.el +++ b/ebox-fragment.el @@ -13,6 +13,8 @@ (declare-function ebox--current-display-signature "ebox-measure" ()) (declare-function ebox--ensure-node-id "ebox" (node)) +(declare-function ebox-style--declaration-value-equal-p + "ebox-style" (left right)) (declare-function ebox--complete-layout-snapshot "ebox-incremental" (buffer node snapshot &optional copy)) @@ -129,18 +131,24 @@ Backend marker and buffer span data are intentionally excluded." :border-top-color :border-bottom-color) "Box properties that affect paint without changing layout geometry.") +(defconst ebox--typography-style-signature-keys + '(:font :font-family :font-height :font-weight :font-slant) + "Typography properties whose resolved metrics may change geometry.") + (defconst ebox--geometry-style-signature-keys - '(:box-sizing - :width :min-width :max-width - :height :min-height :max-height - :padding-left-pixel :padding-right-pixel - :padding-top-height :padding-bottom-height - :margin-left-pixel :margin-right-pixel - :margin-top-height :margin-bottom-height - :border-left-pixel :border-right-pixel - :text-align :vertical-align - :overflow :wrap-mode - :flex-props :flex-participation) + (append + '(:box-sizing + :width :min-width :max-width + :height :min-height :max-height) + ebox--typography-style-signature-keys + '(:padding-left-pixel :padding-right-pixel + :padding-top-height :padding-bottom-height + :margin-left-pixel :margin-right-pixel + :margin-top-height :margin-bottom-height + :border-left-pixel :border-right-pixel + :text-align :vertical-align + :overflow :wrap-mode + :flex-props :flex-participation)) "Style signature keys that require layout or owner rerender work.") (defconst ebox--structure-style-signature-keys @@ -196,8 +204,8 @@ Backend marker and buffer span data are intentionally excluded." (ebox-fragment-plist-keys new))) (cl-pushnew key keys)) (dolist (key keys) - (unless (equal (plist-get old key) - (plist-get new key)) + (unless (ebox-style--declaration-value-equal-p + (plist-get old key) (plist-get new key)) (push key changed))) changed)) diff --git a/ebox-grid.el b/ebox-grid.el index ef858c3..56b7a6e 100644 --- a/ebox-grid.el +++ b/ebox-grid.el @@ -19,7 +19,8 @@ (declare-function ebox--ensure-node-id "ebox" (node)) (declare-function ebox--ensure-region-id "ebox" (box)) (declare-function ebox-get "ebox" (box property)) -(declare-function ebox-create "ebox" (&rest plist)) +(declare-function ebox-node-factory--create-from-properties + "ebox-node-factory" (&rest plist)) (declare-function ebox-render "ebox-layout" (node)) (declare-function ebox--lines-concat-horizontal "ebox" (&rest strings)) (declare-function ebox-string-lines "ebox" (string)) @@ -332,17 +333,10 @@ programmatic port and are therefore rejected here." (when (ebox-grid--visual-props-p props) (when (plist-member props :content) (error "ebox-grid: grid cannot combine :content with child nodes")) - (apply #'ebox-create + (apply #'ebox-node-factory--create-from-properties (append (ebox--preformatted-box-props props) (list :content ""))))) -;;;###autoload -(defun ebox-grid-fr (factor) - "Return a fractional grid track with FACTOR weight." - (unless (and (numberp factor) (> factor 0)) - (error "ebox-grid-fr: FACTOR must be positive: %S" factor)) - (list 'fr factor)) - (defun ebox-grid--placement-part (value default) "Return (START SPAN) parsed from placement VALUE and DEFAULT." (cond @@ -865,43 +859,6 @@ size for start/center/end alignment unless it would overflow its track." (ebox-grid--legacy-constraints props wrapper) (ebox-tree-layout-children node))))) -;;;###autoload -(defun ebox-grid (&rest items) - "Return a two-dimensional grid layout node from ITEMS. - -Grid properties include `:grid-template-columns', `:grid-template-rows', -`:grid-auto-flow', `:gap', `:grid-row-gap', `:grid-column-gap', -`:justify-items', `:align-items', `:width', and `:height'. Track sizes use -Ebox pixel/line units, `auto', `(fr FACTOR)', or symbols such as `1fr'. -Children may carry `:grid-column', `:grid-row', `:grid-column-span', and -`:grid-row-span' placement properties directly. -`ebox-grid-fr' is a convenient constructor for fractional tracks." - (let* ((split (ebox-grid--split-attrs items)) - (raw-props (car split)) - (children (delq nil (cdr split))) - (box-props (cl-loop for (key value) on raw-props by #'cddr - unless (memq key ebox--grid-layout-prop-keys) - collect key and collect value)) - (wrapper (ebox-grid--wrapper box-props)) - (config - (ebox-grid--normalize-config-props - (ebox--plist-keep-keys raw-props ebox--grid-layout-prop-keys))) - (node (list :ebox-type 'grid - :display '(block grid) - :props raw-props - :raw-props raw-props - :ebox-grid-config config - :ebox-style-declarations - (ebox-style-compile-declarations raw-props) - :box wrapper - :children children))) - (when wrapper - (plist-put wrapper :ebox-style-wrapper t)) - (dolist (key '(:id :class :selector-state :selector-attributes :key)) - (when (plist-member raw-props key) - (plist-put node key (plist-get raw-props key)))) - node)) - (provide 'ebox-grid) ;;; ebox-grid.el ends here diff --git a/ebox-incremental.el b/ebox-incremental.el index 42b2edf..590cd2d 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -49,6 +49,8 @@ "tp-surface" (object)) (declare-function ebox-surface--cascade-local-owner-proof-p "ebox-surface" (state candidate-state dirty-set)) +(declare-function ebox-surface-prepare-inline-candidate-styles + "ebox-surface" (old-state candidate-index node-ids)) (declare-function ebox-string-lines "ebox" (string)) (declare-function ebox-get "ebox" (box key)) (declare-function ebox--ensure-node-id "ebox" (node)) @@ -90,8 +92,13 @@ (declare-function ebox-native-commit-plan-eligible-p "ebox-native-commit" (old-state candidate-state prepared)) +(declare-function ebox-native-commit-prepare-viewport-continuity + "ebox-native-commit" + (previous-state candidate-state)) (declare-function ebox-native-commit-topology-stable-p "ebox-native-commit" (old-state candidate-state prepared)) +(declare-function ebox-native-reflow-release-session + "ebox-native-reflow" (session)) (defvar ebox-viewport-width) (defvar ebox-viewport-height) @@ -199,6 +206,13 @@ arguments.") (defvar ebox-incremental--candidate-base-state nil "Dynamic `(BUFFER . STATE)' holding proof-time published runtime nodes.") +(defun ebox-incremental--proof-base-state (buffer) + "Return BUFFER's published state while candidate lookups are overridden." + (or (and ebox-incremental--candidate-base-state + (eq buffer (car ebox-incremental--candidate-base-state)) + (cdr ebox-incremental--candidate-base-state)) + (ebox--buffer-render-state buffer))) + (defvar ebox-incremental--candidate-path-copy-trace nil "Dynamic hash table receiving logical candidate path-copy facts. Each key is a base anchor node id. Values describe the final copied node, @@ -389,7 +403,15 @@ Kept as a narrow predicate for callers that need box-only sizing semantics." (while plist (let ((key (pop plist)) (value (pop plist))) - (unless (memq key '(:render-cache :surface-object)) + ;; These fields are deterministic products of rendering the same + ;; canonical node. Including them makes the first render populate a + ;; cache key that becomes unreachable as soon as that render records + ;; its own proof, forcing every fixed descendant through layout again + ;; on the first viewport update. + (unless (memq key '(:render-cache :surface-object + :ebox-sequence-location + :ebox-content-width-exact-p + :ebox-content-layout-complete-p)) (push key signature) (push (ebox--render-cache-value-signature value) signature)))) (nreverse signature))) @@ -426,6 +448,7 @@ Kept as a narrow predicate for callers that need box-only sizing semantics." ebox-viewport-width) (when (ebox--viewport-height-dependent-subtree-p node) ebox-viewport-height) + ebox--intrinsic-layout-measurement ebox--inline-auto-width-intrinsic-p (ebox--render-cache-node-body-signature node))) @@ -1030,7 +1053,7 @@ same traversal in complete-render overwrite order." (host-ref-table (make-hash-table :test 'equal)) (range-ref-table (make-hash-table :test 'equal)) (runtime-type-count-table (make-hash-table :test 'eq)) - native-node-postorder range-sequence-p) + native-node-postorder) (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))) @@ -1090,7 +1113,6 @@ same traversal in complete-render overwrite order." (index-region-id node node-id) (index-host-ref node node-id) (when-let* ((sequence (plist-get node :ebox-child-sequence))) - (setq range-sequence-p t) (dotimes (segment-index (ebox-child-range--sequence-count sequence)) @@ -1123,11 +1145,9 @@ same traversal in complete-render overwrite order." (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))) + ;; source boxes are indexed for ownership without adding + ;; duplicate compiler work. + (when native-layout-p (push node native-node-postorder)))))) (visit root-node nil t)) (let ((selector-index (ebox-tree-selector-index root-node))) @@ -1146,8 +1166,7 @@ same traversal in complete-render overwrite order." (plist-get selector-index :selector-type-table) :runtime-type-count-table runtime-type-count-table :native-node-postorder - (if range-sequence-p [] - (vconcat (nreverse 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." @@ -1508,7 +1527,9 @@ the returned copy without mutating BUFFER's stored snapshot table." (defun ebox--clear-buffer-render-state (&optional buffer) "Remove stored buffer-owned runtime state for BUFFER. Defaults to the current buffer." - (let ((buffer (or buffer (current-buffer)))) + (let* ((buffer (or buffer (current-buffer))) + (state (gethash buffer ebox--buffer-render-state-table)) + (native-session (plist-get state :native-sync-session))) (when-let* ((cache (ebox--buffer-render-cache buffer))) (when-let* ((root-cache (gethash cache ebox--render-root-cache-table-table))) @@ -1517,7 +1538,9 @@ Defaults to the current buffer." (remhash cache ebox--render-cache-ring-table)) (remhash buffer ebox--buffer-render-state-table) (remhash buffer ebox-incremental--batch-table) - (ebox-cache-discard-report buffer))) + (ebox-cache-discard-report buffer) + (when (and native-session (require 'ebox-native-reflow nil t)) + (ebox-native-reflow-release-session native-session)))) ;;; Runtime Buffer Lookup @@ -2451,7 +2474,8 @@ keeps the change local to the owner's own rendered spans." (cl-every (lambda (key) (or (memq key ebox--span-patch-local-style-keys) (eq key :surface-properties) - (memq key ebox--paint-style-signature-keys))) + (memq key ebox--paint-style-signature-keys) + (memq key ebox--typography-style-signature-keys))) changed-keys)))) (defun ebox--border-box-inline-content-fits-p (style-node) @@ -2521,6 +2545,11 @@ outer width and height are definite and still contain their inner chrome." '(paint)) ((eq key :content) '(internal-fragment external-footprint-proof)) + ;; Typography is local to the rendered fragment but may change its pixel + ;; footprint. Authorize the same exact-footprint attempt as content; + ;; Surface rejects wrapping or allocation changes before publication. + ((memq key ebox--typography-style-signature-keys) + '(internal-fragment external-footprint-proof)) ((memq key ebox--span-patch-local-style-keys) '(internal-fragment)) ((and (eq (ebox-get style-node :box-sizing) 'border-box) @@ -2542,9 +2571,24 @@ outer width and height are definite and still contain their inner chrome." (t '(external-footprint parent-layout)))) +(defun ebox--dirty-entry-geometry-owner-id (buffer entry) + "Return ENTRY's node that owns geometry in BUFFER. +Text retains content identity and paint provenance, while its containing Box +owns the allocated layout slot. Keep the dirty entry addressed to Text, but +classify and prove a Text content change against that parent Box." + (let* ((source-node-id (plist-get entry :node-id)) + (source-node (and source-node-id + (ebox--buffer-runtime-node buffer source-node-id)))) + (if (and (eq (plist-get entry :dirty-kind) 'geometry) + (eq (plist-get source-node :ebox-kind) 'text) + (memq :content (plist-get entry :changed-keys))) + (or (ebox--runtime-parent-id buffer source-node-id) + source-node-id) + source-node-id))) + (defun ebox--dirty-entry-impact-vector (buffer entry) "Return normalized impact vector for dirty ENTRY in BUFFER." - (let* ((node-id (plist-get entry :node-id)) + (let* ((node-id (ebox--dirty-entry-geometry-owner-id buffer entry)) (node (and node-id (ebox--buffer-runtime-node buffer node-id))) (style-node (and node (ebox-fragment-style-source-node node))) impacts) @@ -2669,6 +2713,10 @@ outer width and height are definite and still contain their inner chrome." When BUFFER is non-nil, non-paint entries promote to patchable owners." (let* ((node-id (plist-get entry :node-id)) (dirty-kind (plist-get entry :dirty-kind)) + (geometry-owner-id + (if (and buffer (eq dirty-kind 'geometry)) + (ebox--dirty-entry-geometry-owner-id buffer entry) + node-id)) (requires-owned-overflow-coverage (or (plist-get entry :requires-owned-overflow-coverage) (and buffer @@ -2698,7 +2746,7 @@ When BUFFER is non-nil, non-paint entries promote to patchable owners." (or (and buffer requires-owned-overflow-coverage (or (ebox--next-patchable-owner-ancestor - buffer node-id dirty-kind t) + buffer geometry-owner-id dirty-kind t) (ebox--buffer-root-node-id buffer))) (and buffer (or (memq 'size-interpretation impact-vector) @@ -2706,7 +2754,7 @@ When BUFFER is non-nil, non-paint entries promote to patchable owners." (ebox--impact-vector-requires-conservative-owner-p impact-vector))) (ebox--promote-to-patchable-owner - buffer node-id dirty-kind + buffer geometry-owner-id dirty-kind (plist-get entry :changed-keys) impact-vector)) (and buffer (ebox--buffer-root-node-id buffer)) @@ -2913,8 +2961,7 @@ cross-axis keys change its row footprint, exactly the pair "Return FLEX-NODE's child whose source owns NODE-ID." (cl-find-if (lambda (child) - (when-let* ((source (ebox--flex-item-source-node child))) - (equal (plist-get source :node-id) node-id))) + (equal (plist-get child :node-id) node-id)) (ebox-tree-layout-children flex-node))) (defun ebox--flex-cross-axis (axis) @@ -2930,7 +2977,8 @@ its new minimum must still fit CURRENT-MAIN." (ebox--flex-main-definite-size-p style-node axis)) (let ((rendered (if (and (eq axis 'row) - (null (ebox-get style-node :wrap-mode))) + (ebox-style-no-soft-wrap-p + (ebox-get style-node :wrap-mode))) (ebox--with-buffer-render-context buffer (ebox-render style-node)) ""))) @@ -3323,38 +3371,46 @@ running expensive span and snapshot checks." parent-id (ebox--runtime-parent-id buffer parent-id)))) found)) -(defun ebox--skip-flex-span-probe-p (buffer entry owner-id) - "Return non-nil when a flex OWNER-ID span probe would duplicate rerender work." - (let ((node-id (plist-get entry :node-id)) - (changed-keys (plist-get entry :changed-keys))) - (and (eq (plist-get entry :dirty-kind) 'geometry) - changed-keys - (when-let* ((owner (ebox--buffer-runtime-node buffer owner-id)) - ((eq (ebox--display-inner owner) 'flex)) - ((or (equal owner-id node-id) - (not - (and - (or (ebox--inline-size-change-p changed-keys) - (ebox--block-size-change-p changed-keys)) - (ebox--definite-containment-formatting-context-p - buffer owner-id))))) - (child-id - (ebox--runtime-child-under-ancestor - buffer owner-id node-id))) - (and (ebox--direct-flex-parent-p buffer child-id) - (not (ebox--flex-item-slot-safe-p - buffer child-id changed-keys))))))) - (defun ebox--span-patch-owner-id-for-dirty-entry (entry buffer) "Return a local span patch owner for ENTRY in BUFFER, or nil." - (let* ((node-id (plist-get entry :node-id)) - (dirty-kind (plist-get entry :dirty-kind)) + (let* ((dirty-kind (plist-get entry :dirty-kind)) + (source-node-id (plist-get entry :node-id)) + (node-id (ebox--dirty-entry-geometry-owner-id buffer entry)) + (source-parent-id + (and source-node-id + (ebox--runtime-parent-id buffer source-node-id))) + (source-parent + (and source-parent-id + (ebox--buffer-runtime-node buffer source-parent-id))) + (source-parent-kind + (and source-parent (ebox-tree-display-inner source-parent))) + (fast-grid-evidence + (and ebox-incremental--candidate-state-for-slot-proof + (eq dirty-kind 'geometry) + (memq :content (plist-get entry :changed-keys)) + (ebox-incremental--fast-grid-slot-evidence + buffer entry + ebox-incremental--candidate-state-for-slot-proof))) (impact-vector (or (plist-get entry :impact-vector) (and (plist-get entry :changed-keys) (ebox--dirty-entry-impact-vector buffer entry))))) (unless (ebox--impact-vector-requires-conservative-owner-p impact-vector) (cond + (fast-grid-evidence + node-id) + ((and ebox-incremental--candidate-state-for-slot-proof + (eq dirty-kind 'geometry) + (memq :content (plist-get entry :changed-keys)) + ;; The one-line source-slot proof only accepts Row/Column + ;; parents. Reject Flow/Flex/Grid here before it materializes + ;; snapshots and walks an ancestor certificate that its own + ;; final predicate would discard. + (memq source-parent-kind '(row column)) + (ebox-incremental--content-slot-stable-p + buffer entry + ebox-incremental--candidate-state-for-slot-proof)) + source-node-id) ((and (ebox--dirty-entry-span-patch-candidate-p buffer entry) (not (ebox--impact-vector-requires-parent-promotion-p impact-vector)) @@ -3365,21 +3421,22 @@ running expensive span and snapshot checks." (ebox--cached-patchable-owner-p buffer node-id 'span) (or (not (ebox--direct-flex-parent-p buffer node-id)) (ebox--flex-item-slot-safe-p - buffer node-id (plist-get entry :changed-keys) t))) - node-id) + buffer node-id (plist-get entry :changed-keys) t) + (ebox--flex-item-fixed-basis-content-allocation-stable-p + buffer node-id) + (and ebox-incremental--candidate-state-for-slot-proof + (ebox-incremental--allocated-parent-slot-stable-p + buffer entry + ebox-incremental--candidate-state-for-slot-proof)))) + node-id) ((and (eq dirty-kind 'geometry) (plist-get entry :changed-keys)) - (let ((owner-id - (or (and (ebox--block-size-change-p - (plist-get entry :changed-keys)) - (ebox--block-size-containment-owner-id - buffer node-id)) - (ebox--promote-to-span-patchable-owner - buffer node-id dirty-kind - (plist-get entry :changed-keys))))) - (unless (and owner-id - (ebox--skip-flex-span-probe-p buffer entry owner-id)) - owner-id))))))) + (or (and (ebox--block-size-change-p + (plist-get entry :changed-keys)) + (ebox--block-size-containment-owner-id buffer node-id)) + (ebox--promote-to-span-patchable-owner + buffer node-id dirty-kind + (plist-get entry :changed-keys)))))))) (defun ebox--make-patch-op-merge-index () "Return fresh mutable state for indexed patch-op antichain merging." @@ -3572,19 +3629,26 @@ When NIL-DEPENDENT is non-nil, nil is treated as viewport-dependent auto width." (defun ebox--node-direct-viewport-width-dependent-p (node) "Return non-nil when NODE's own layout depends on viewport width." (when (listp node) - (or (ebox--flex-participation-viewport-dependent-p node) - (unless (eq (plist-get node :ebox-type) 'flex-item) - (cond - ((eq (ebox--display-inner node) 'flex) - (ebox--viewport-dependent-size-value-p - (plist-get (plist-get node :props) :width) t)) - ((eq (ebox--display-inner node) 'flow) - (or (ebox--viewport-dependent-size-value-p - (plist-get node :width) t) - (ebox--viewport-dependent-size-value-p - (plist-get node :min-width)) - (ebox--viewport-dependent-size-value-p - (plist-get node :max-width))))))))) + (and (not (eq (plist-get node :ebox-kind) 'text)) + (or (ebox--flex-participation-viewport-dependent-p node) + (cond + ((eq (plist-get node :ebox-kind) 'box) + (or (ebox--viewport-dependent-size-value-p + (plist-get node :width) t) + (ebox--viewport-dependent-size-value-p + (plist-get node :min-width)) + (ebox--viewport-dependent-size-value-p + (plist-get node :max-width)))) + ((eq (ebox--display-inner node) 'flex) + (ebox--viewport-dependent-size-value-p + (plist-get (plist-get node :props) :width) t)) + ((eq (ebox--display-inner node) 'flow) + (or (ebox--viewport-dependent-size-value-p + (plist-get node :width) t) + (ebox--viewport-dependent-size-value-p + (plist-get node :min-width)) + (ebox--viewport-dependent-size-value-p + (plist-get node :max-width))))))))) (defun ebox--node-direct-viewport-dependent-p (node) "Return non-nil when NODE's own layout depends on viewport context." @@ -3973,6 +4037,13 @@ during `ebox-incremental-flush'." (seen-old (make-hash-table :test #'eql)) (missing (make-symbol "ebox-range-reuse-missing")) owned) + (when (and (eq (plist-get parent :ebox-kind) 'box) + (not (cl-every + (lambda (item) + (memq (plist-get item :ebox-kind) '(text box))) + next-items))) + (error "Canonical Ebox Box Range accepts only Text/Box nodes: %S" + next-items)) (dolist (entry reuse-map) (let ((new-index (car-safe entry)) (old-index (cdr-safe entry))) @@ -4191,7 +4262,7 @@ the patched Host's ancestor chain." (plist-get next-node :ebox-type))) (throw 'unsupported nil)) (let ((changed-keys - (ebox-tree-node-local-changed-keys + (ebox-incremental--candidate-local-changed-keys previous-node next-node))) (unless (cl-every (lambda (key) @@ -4321,12 +4392,14 @@ logical tree crosses that boundary." replacement new-child))) -(defun ebox-incremental-surface-region-candidate-root (buffer region-id) +(defun ebox-incremental-surface-region-candidate-root + (buffer region-id &optional isolate-owner-subtree-p) "Return a copy-on-write candidate root for REGION-ID in BUFFER. Only the region owner and its ancestor path are copied; untouched runtime subtrees remain shared with the published root. Return nil when the retained index cannot identify a directly replaceable region owner, leaving callers to -use their ordinary isolated-copy path." +use their ordinary isolated-copy path. ISOLATE-OWNER-SUBTREE-P additionally +copies the owner's descendants before mutation while retaining their ids." (let* ((state (ebox--buffer-render-state buffer)) (root (plist-get state :root-node)) (node-table (plist-get state :node-table)) @@ -4343,7 +4416,10 @@ use their ordinary isolated-copy path." (when (and root owner box (hash-table-p parent-table) (equal (plist-get box :region-id) region-id)) - (let* ((candidate-box (copy-sequence box)) + (let* ((candidate-box + (if isolate-owner-subtree-p + (ebox-tree-copy-node-structure box) + (copy-sequence box))) (replacement (if (eq owner box) candidate-box @@ -4401,10 +4477,30 @@ shared with the published generation." (let* ((old-node (gethash node-id node-table)) (local (or (gethash node-id replacements) old-node)) (child-replacements (gethash node-id children)) + (effective-child-replacements + (if (or (null child-replacements) (eq local old-node)) + child-replacements + (let ((local-children (make-hash-table :test 'equal))) + (ebox-tree-for-each-direct-child + local + (lambda (child) + (puthash (plist-get child :node-id) child + local-children))) + (mapcar + (lambda (replacement) + (let* ((old-child (car replacement)) + (child-id (plist-get old-child :node-id)) + (local-child (gethash child-id local-children))) + (unless local-child + (error + "Ebox candidate ancestor dropped nested anchor %S" + child-id)) + (cons local-child (cdr replacement)))) + child-replacements)))) (candidate - (if child-replacements + (if effective-child-replacements (ebox-tree-copy-with-direct-child-replacements - local child-replacements) + local effective-child-replacements) local)) (parent-id (gethash node-id parent-table))) (ebox-incremental--candidate-record-path-copy @@ -4718,7 +4814,7 @@ CURRENT-SNAPSHOT identifies the subtree identities being replaced." (and (not conflict) (not - (ebox--runtime-region-id-conflict + (ebox--runtime-region-id-conflict (plist-get historical-snapshot :region-id-set) buffer))))) @@ -4799,38 +4895,35 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." replacements))) (ebox-incremental--candidate-path-copy-many root base-index replacements)) - ;; Ancestor/descendant overlap is intentionally rare and order-sensitive: - ;; a later descendant refines the earlier candidate-owned ancestor. + ;; A descendant refines the candidate-owned ancestor in the same + ;; bottom-up path copy. Re-indexing the complete candidate after each + ;; replacement repeats work and discards the overlap information that + ;; the retained parent table already provides. (when (cl-some (lambda (entry) (ebox-incremental--candidate-replacement-old-semantic-key entry)) entries) (error "Ebox semantic candidate replacements must be disjoint")) - (let ((first-p t)) + (let ((replacements (make-hash-table :test 'equal))) (ebox-incremental--validate-candidate-replacements base-index entries) (dolist (entry entries) (let* ((node-id (ebox-incremental--candidate-replacement-anchor-id entry)) - (replacement - (ebox-incremental--candidate-replacement-subtree entry)) - (index - (if first-p - base-index - (ebox-incremental--candidate-path-index root))) (old-anchor - (gethash node-id (plist-get index :node-table)))) - (setq first-p nil) + (gethash node-id (plist-get base-index :node-table)))) (unless old-anchor (error - "Ebox candidate anchor is absent after an ancestor replacement: %S" + "Ebox candidate anchor is absent: %S" node-id)) - (ebox-tree-reconcile-runtime old-anchor replacement) - (setq root - (ebox-incremental--candidate-path-copy-one - root node-id replacement index)))) - root))))) + (puthash + node-id + (ebox-incremental--reconcile-detached-replacement + candidate state entry old-anchor detached-history) + replacements))) + (ebox-incremental--candidate-path-copy-overlap-many + root base-index replacements)))))) (defun ebox-incremental--candidate-reconcile-range-items (sequence ref items) @@ -4979,11 +5072,18 @@ but must re-enter the candidate indexes as a new object." (new-parent (if location (let ((copy (copy-sequence old-parent))) - (plist-put - copy :ebox-child-sequence + (ebox-tree--put-child-sequence + copy (ebox-child-range--replace-item-at sequence (plist-get location :segment-index) - (plist-get location :offset) old-child new-child))) + (plist-get location :offset) old-child new-child)) + (when (eq (plist-get old-parent :ebox-kind) 'box) + (ebox-tree--set-author-style-count + copy + (+ (ebox-tree-author-style-count old-parent) + (- (ebox-tree-author-style-count new-child) + (ebox-tree-author-style-count old-child))))) + copy) (ebox-tree-copy-with-direct-child-replacements old-parent (list (cons old-child new-child))))) (grandparent-id (gethash current-parent-id parent-table))) @@ -5135,7 +5235,19 @@ but must re-enter the candidate indexes as a new object." (cdr pair)))) (cl-mapcar #'cons old-payloads new-payloads)))) (new-parent (copy-sequence parent))) - (plist-put new-parent :ebox-child-sequence (car result)) + (ebox-tree--put-child-sequence new-parent (car result)) + (when (eq (plist-get parent :ebox-kind) 'box) + (ebox-tree--set-author-style-count + new-parent + (+ (ebox-tree-author-style-count parent) + (- (cl-loop for payload in new-payloads + sum (cl-loop for child in payload + sum (ebox-tree-author-style-count + child))) + (cl-loop for payload in old-payloads + sum (cl-loop for child in payload + sum (ebox-tree-author-style-count + child))))))) (push (ebox-child-range--metrics-plist (cdr result)) metrics) (setq root (ebox-incremental--candidate-path-copy-overlay @@ -5152,12 +5264,6 @@ but must re-enter the candidate indexes as a new object." (setf (ebox-candidate--range-metrics candidate) (nreverse metrics)) root)) -(defun ebox-incremental--node-explicit-color-p (node) - "Return non-nil when NODE owns a declaration winner for foreground." - (let ((style (plist-get node :ebox-computed-style))) - (or (and style (ebox-style--specified-property-p style :color)) - (plist-member (ebox-style-node-declarations node) 'ebox/color)))) - (defun ebox-incremental--candidate-paint-top-anchors (state patch-table patch-ids) "Return PATCH-IDS without a patched ancestor in STATE." @@ -5170,31 +5276,28 @@ but must re-enter the candidate indexes as a new object." (unless found (push node-id result)))))) (defun ebox-incremental--candidate-apply-paint-patches (candidate root) - "Apply CANDIDATE paint patches and inherited color in one subtree pass." + "Apply CANDIDATE paint declarations and copy their dependency closure. +The candidate owns source declarations only. When a patch changes any +schema-inherited or custom property, descendants are path-copied as pending +style consumers; the Style Adapter computes their values exactly once later." (let* ((state (ebox-candidate--base-state candidate)) (parents (plist-get state :parent-table)) (patch-table (make-hash-table :test #'equal)) + (patch-paths (make-hash-table :test #'equal)) patch-ids) (dolist (entry (ebox-candidate--paint-patches candidate)) (let ((node-id (ebox-incremental--candidate-paint-patch-anchor-id entry))) (puthash node-id entry patch-table) (push node-id patch-ids))) + (dolist (node-id patch-ids) + (let ((current node-id)) + (while current + (puthash current t patch-paths) + (setq current (gethash current parents))))) (cl-labels - ((nearest-parent-color - (node-id) - (let ((parent-id (gethash node-id parents)) color found) - (while (and parent-id (not found)) - (let ((parent - (ebox-incremental--candidate-current-node - state parent-id))) - (when (and parent (plist-member parent :color)) - (setq color (plist-get parent :color) found t))) - (setq parent-id - (and (not found) (gethash parent-id parents)))) - color)) - (rewrite - (node inherited-color inherited-changed-p) + ((rewrite + (node inherited-input-changed-p) (if (or (not (listp node)) (stringp node)) node (let* ((node-id (plist-get node :node-id)) @@ -5206,9 +5309,10 @@ but must re-enter the candidate indexes as a new object." (and entry (ebox-incremental--candidate-paint-patch-changed-keys entry))) - (direct-color-p (memq :color changed-keys)) - (style-node-p (plist-member node :color)) - (next (if entry (copy-sequence node) node))) + (old-declarations (ebox-style-node-declarations node)) + (next (if (or entry inherited-input-changed-p) + (copy-sequence node) + node))) (when entry (dolist (key changed-keys) (plist-put next key (copy-tree (plist-get patch key)))) @@ -5220,42 +5324,23 @@ but must re-enter the candidate indexes as a new object." (ebox-style-merge-declarations (copy-tree (plist-get node key)) (copy-tree (plist-get patch key))))))) - (let* ((explicit-color-p - (and style-node-p - (ebox-incremental--node-explicit-color-p next))) - (declarations - (and style-node-p - (ebox-style-node-declarations next))) - (declared-color - (and declarations - (plist-member declarations 'ebox/color))) - (old-color (and style-node-p (plist-get node :color))) - (desired-color - (cond (direct-color-p (plist-get next :color)) - ((and style-node-p inherited-changed-p - declared-color) - (cadr declared-color)) - ((and style-node-p inherited-changed-p - (not explicit-color-p)) - inherited-color) - (style-node-p (plist-get next :color)) - (t inherited-color))) - (color-changed-p - (and style-node-p - (not (equal old-color desired-color))))) - (when color-changed-p - (when (eq next node) (setq next (copy-sequence node))) - (plist-put next :color (copy-tree desired-color))) + (let* ((new-declarations (ebox-style-node-declarations next)) + (descendant-input-changed-p + (or inherited-input-changed-p + (and entry + (ebox-style--inherited-declarations-changed-p + old-declarations new-declarations))))) + (when (or entry inherited-input-changed-p) + (ebox-tree--set-author-style-count next 1)) (let (child-replacements) (dolist (child (ebox-tree--children-raw next)) - (let ((replacement - (rewrite - child desired-color - (if style-node-p - (or direct-color-p color-changed-p) - inherited-changed-p)))) - (unless (eq replacement child) - (push (cons child replacement) child-replacements)))) + (when (or descendant-input-changed-p + (gethash (plist-get child :node-id) patch-paths)) + (let ((replacement + (rewrite child descendant-input-changed-p))) + (unless (eq replacement child) + (push (cons child replacement) + child-replacements))))) (when child-replacements (setq next (ebox-tree-copy-with-direct-child-replacements @@ -5273,7 +5358,7 @@ but must re-enter the candidate indexes as a new object." (setq root (ebox-incremental--candidate-path-copy-overlay root node-id - (rewrite current (nearest-parent-color node-id) nil) + (rewrite current nil) state nil))))))) (defun ebox-incremental--candidate-logical-root @@ -5712,7 +5797,7 @@ complete preorder first-owner and postorder last-box semantics." (and native-layout-p (not (and (eq type 'flex) (eq child (plist-get node :box))))))) - (when (and native-layout-p (not (eq type 'flex-item))) + (when native-layout-p (push node postorder)))))) (visit root t)) (vconcat (nreverse postorder)))) @@ -5731,10 +5816,28 @@ complete preorder first-owner and postorder last-box semantics." (append old-postorder nil))) nil))) +(defun ebox-incremental--candidate-range-owned-node-ids (range-delta-table) + "Return candidate node ids absorbed by RANGE-DELTA-TABLE parents." + (let ((owned (make-hash-table :test 'equal))) + (when (hash-table-p range-delta-table) + (cl-labels ((visit + (node) + (when (and (listp node) (not (stringp node))) + (puthash (plist-get node :node-id) t owned) + (ebox-tree-for-each-direct-child node #'visit)))) + (maphash + (lambda (_parent-id delta) + (dolist (payload (plist-get delta :new-payloads)) + (dolist (node payload) (visit node)))) + range-delta-table))) + owned)) + (defun ebox-incremental--candidate-dirty-set-from-touched (old-state candidate-root touched &optional range-delta-table) "Return declarative dirty entries for path-local TOUCHED nodes." (let ((old-root (plist-get old-state :root-node)) + (range-owned-node-ids + (ebox-incremental--candidate-range-owned-node-ids range-delta-table)) dirty) (if (not (equal (plist-get old-root :node-id) (plist-get candidate-root :node-id))) @@ -5748,7 +5851,9 @@ complete preorder first-owner and postorder last-box semantics." (dolist (entry touched) (pcase-let ((`(,old-node ,new-node ,_parent-id ,children-changed) entry)) - (when old-node + (when (and old-node + (not (gethash (plist-get new-node :node-id) + range-owned-node-ids))) (let* ((changed-keys (ebox-incremental--candidate-local-changed-keys old-node new-node)) @@ -5793,7 +5898,8 @@ complete preorder first-owner and postorder last-box semantics." (ebox-tree-node-local-source-signature old-node) :new-signature (ebox-tree-node-local-source-signature new-node)) - (unless range-delta + (if range-delta + (list :range-delta range-delta) (ebox-incremental--child-change-props old-node new-node children-changed))) dirty)))))) @@ -6052,38 +6158,59 @@ replace those O(n) table copies without changing this delta contract." (dolist (entry touched) (when-let* ((old-node (car entry)) (old-host-ref (plist-get old-node :host-ref))) - (let ((node-id (plist-get old-node :node-id))) - (when (equal (gethash old-host-ref host-ref-table) node-id) - (remhash old-host-ref host-ref-table))))) + (let* ((new-node (nth 1 entry)) + (new-host-ref (plist-get new-node :host-ref)) + (node-id (plist-get old-node :node-id))) + (unless (equal old-host-ref new-host-ref) + (when (equal (gethash old-host-ref host-ref-table) node-id) + (remhash old-host-ref host-ref-table)))))) (dolist (entry touched) (pcase-let ((`(,old-node ,new-node ,parent-id ,_children-changed) entry)) - (let ((node-id (plist-get new-node :node-id))) - (when old-node + (let* ((node-id (plist-get new-node :node-id)) + (old-type (and old-node + (plist-get old-node :ebox-type))) + (new-type (plist-get new-node :ebox-type)) + (old-record + (and old-node + (ebox-incremental--candidate-node-region-record + old-node))) + (new-record + (ebox-incremental--candidate-node-region-record new-node)) + (stable-unique-region-p + (and old-record new-record + (equal (car old-record) (car new-record)) + (= (or (and old-region-box-count-table + (gethash (car old-record) + old-region-box-count-table)) + 0) + 1)))) + (when (and old-node (not (eq old-type new-type))) (ebox-incremental--adjust-runtime-type-count - runtime-type-count-table old-node -1) - (when-let* ((record - (ebox-incremental--candidate-node-region-record - old-node))) - (puthash (car record) t affected-region-ids)) + runtime-type-count-table old-node -1)) + (when (and old-record (not stable-unique-region-p)) + (puthash (car old-record) t affected-region-ids) (when (hash-table-p old-region-box-count-table) (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) + (when (or (null old-node) (not (eq old-type new-type))) + (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)) ;; Replacement subtrees were validated before recording and ;; checked against untouched Host refs before path copying. (when-let* ((new-host-ref (plist-get new-node :host-ref))) - (puthash new-host-ref node-id host-ref-table)) - (when-let* ((record - (ebox-incremental--candidate-node-region-record - new-node))) - (puthash (car record) t affected-region-ids)) - (when (hash-table-p old-region-box-count-table) + (unless (and old-node + (equal new-host-ref + (plist-get old-node :host-ref))) + (puthash new-host-ref node-id host-ref-table))) + (when (and new-record (not stable-unique-region-p)) + (puthash (car new-record) t affected-region-ids)) + (when (and (hash-table-p old-region-box-count-table) + (not stable-unique-region-p)) (ebox-incremental--candidate-adjust-region-box-count region-box-count-table new-node 1))))) (maphash @@ -6133,13 +6260,11 @@ replace those O(n) table copies without changing this delta contract." (eq (plist-get entry :dirty-kind) 'structure)) dirty-set)) (native-node-postorder - (if range-index-deltas - (plist-get old-state :native-node-postorder) - (if structure-p + (if (or range-index-deltas structure-p) (ebox-incremental--candidate-native-postorder candidate-root) - (ebox-incremental--candidate-map-native-postorder - old-state node-table)))) + (ebox-incremental--candidate-map-native-postorder + old-state node-table))) (index (list :node-table node-table :parent-table parent-table @@ -6201,35 +6326,67 @@ recognized from its first untouched sibling without walking the whole tree." (visit candidate-root) nil))))) +(defun ebox-incremental--plist-changed-keys (old new) + "Return ordered keys whose presence or value differs in OLD and NEW plists." + (let (seen changed) + (dolist (source (list old new)) + (cl-loop for key in source by #'cddr + unless (memq key seen) + do (push key seen) + do (let ((old-entry (plist-member old key)) + (new-entry (plist-member new key))) + (unless (and (eq (not (null old-entry)) + (not (null new-entry))) + (equal-including-properties + (cadr old-entry) (cadr new-entry))) + (push key changed))))) + (nreverse changed))) + +(defun ebox-incremental--declaration-changed-keys (old new) + "Return engine longhands changed between canonical OLD and NEW declarations." + (let ((all-properties (lambda (_property) t))) + (ebox-incremental--plist-changed-keys + (ebox-style-expand-ebox-plist + (ebox-style-declaration-properties old all-properties)) + (ebox-style-expand-ebox-plist + (ebox-style-declaration-properties new all-properties))))) + (defun ebox-incremental--candidate-local-changed-keys (old new) "Return real source changes between published OLD and candidate NEW. Published nodes carry computed ECSS longhands while fresh candidate nodes are -still declaration-stage values. When canonical declarations are unchanged, -compare only non-style source fields directly instead of allocating two full -filtered signatures and then deleting every derived style difference." - (if (not (equal (ebox-style-node-declarations old) - (ebox-style-node-declarations new))) - (ebox-tree-node-local-changed-keys old new) - (let (seen changed) - (dolist (source (list old new)) - (let ((plist source)) - (while plist - (let ((key (pop plist))) - (pop plist) - (unless (or (memq key seen) - (memq key ebox-tree--excluded-source-keys) - (memq key '(:props :raw-props)) - (ebox-style-property key)) - (push key seen) - (let ((old-entry (plist-member old key)) - (new-entry (plist-member new key))) - (unless (and (eq (not (null old-entry)) - (not (null new-entry))) - (equal-including-properties - (cadr old-entry) (cadr new-entry))) - (push key changed)))))))) - (nreverse changed)))) +still declaration-stage values. Compare non-style facts directly and compare +canonical declarations only after expanding them to the engine longhands used +by impact classification; the declaration container itself is not geometry." + (let (seen changed) + (dolist (source (list old new)) + (let ((plist source)) + (while plist + (let ((key (pop plist))) + (pop plist) + (unless (or (memq key seen) + (memq key ebox-tree--excluded-source-keys) + (memq key '(:props :raw-props + :ebox-style-declarations)) + (ebox-style-property key)) + (push key seen) + (let ((old-entry (plist-member old key)) + (new-entry (plist-member new key))) + (unless (and (eq (not (null old-entry)) + (not (null new-entry))) + (equal-including-properties + (cadr old-entry) (cadr new-entry))) + (push key changed)))))))) + (setq changed (nreverse changed)) + (unless (ebox-style-declarations-equal-p + (ebox-style-node-declarations old) + (ebox-style-node-declarations new)) + (dolist (key (ebox-incremental--declaration-changed-keys + (ebox-style-node-declarations old) + (ebox-style-node-declarations new))) + (unless (memq key changed) + (setq changed (append changed (list key)))))) + changed)) (defun ebox-incremental--declarative-dirty-set (old-state candidate-root candidate-index) @@ -6252,7 +6409,7 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." (lambda (node-id new-node) (when-let* ((old-node (gethash node-id old-table))) (let* ((changed-keys - (ebox-tree-node-local-changed-keys + (ebox-incremental--candidate-local-changed-keys old-node new-node)) (children-changed (not @@ -6293,6 +6450,7 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." Candidate layout may replace entries while proving a publication. Each candidate therefore owns its cache tables until TP commits the generation." (dolist (entry '((:render-cache . equal) + (:layout-snapshots . equal) (:layout-fragments . equal) (:render-signature-cache . eq) (:native-fragment-owner-cache . equal) @@ -6462,6 +6620,60 @@ must be recomputed in the next publication." (list :render-signature-cache signature-cache :viewport-height-dependent-subtree-cache height-cache))) +(defun ebox-incremental--computed-local-dirty-set + (old-state candidate-index dirty-set) + "Return DIRTY-SET expressed as locally computed render effects. +The Style Adapter has already consumed selector metadata and author +declarations for nodes marked `:ebox-candidate-computed-style-p'. For those +nodes, retain ordinary source changes, record selector fields as metadata, and +derive render changes from the computed fragment style signatures." + (let ((old-nodes (plist-get old-state :node-table)) + (new-nodes (plist-get candidate-index :node-table))) + (mapcar + (lambda (entry) + (let* ((node-id (plist-get entry :node-id)) + (old-node (and node-id (gethash node-id old-nodes))) + (new-node (and node-id (gethash node-id new-nodes)))) + (if (or (eq (plist-get entry :dirty-kind) 'structure) + (not old-node) + (not new-node) + (not (plist-get new-node :ebox-candidate-computed-style-p))) + entry + (let* ((source-keys + (ebox-incremental--candidate-local-changed-keys + old-node new-node)) + (metadata-keys + (cl-remove-if-not + (lambda (key) + (memq key ebox-tree-metadata-source-keys)) + source-keys)) + (ordinary-keys + (cl-remove-if + (lambda (key) + (or (ebox-style-property key) + (memq key ebox-tree-metadata-source-keys))) + source-keys)) + (style-keys + (ebox-fragment-plist-changed-keys + (ebox-fragment-node-style-signature old-node) + (ebox-fragment-node-style-signature new-node))) + (changed-keys + (delete-dups (append ordinary-keys style-keys))) + (next (copy-sequence entry))) + (plist-put next :changed-keys changed-keys) + (plist-put next :metadata-changed-keys metadata-keys) + (plist-put next :dirty-kind + (if changed-keys + (ebox-incremental--declarative-dirty-kind + changed-keys) + 'metadata)) + (plist-put next :old-signature + (ebox-tree-node-local-source-signature old-node)) + (plist-put next :new-signature + (ebox-tree-node-local-source-signature new-node)) + next)))) + dirty-set))) + (defun ebox-incremental--prepare-declarative-runtime (buffer old-state candidate-root &optional logical-candidate-p local-preparation scroll-patch-p) @@ -6478,12 +6690,35 @@ must be recomputed in the next publication." (ebox--runtime-index candidate-root t))) (candidate-region-box-table (plist-get candidate-index :region-box-table)) - (dirty-set + (candidate-subject-table + (and local-preparation + (ebox-surface-prepare-inline-candidate-styles + old-state candidate-index + (plist-get local-preparation :touched-node-ids)))) + (styles-prepared-p (hash-table-p candidate-subject-table)) + (source-dirty-set (if local-preparation (plist-get local-preparation :dirty-set) (ebox-incremental--declarative-dirty-set old-state candidate-root candidate-index))) + (dirty-set source-dirty-set) (current-display-signature (ebox--current-display-signature))) + (when local-preparation + ;; Participation depends on the final parent relation and, when the + ;; local Style Adapter succeeded, on computed values. Validate that + ;; final contract once here; Surface consumes the resulting + ;; certificate instead of repeating the same indexed walk. + (ebox-tree-validate-indexed-participation + (plist-get candidate-index :node-table) + (plist-get candidate-index :parent-table) + (plist-get local-preparation :participation-node-ids) + (if styles-prepared-p 'computed 'author))) + (when styles-prepared-p + ;; The local style adapter has consumed every pending author fact on + ;; the touched paths. Clear the exact retained subtree counts before + ;; owner planning so a computed local change cannot masquerade as an + ;; unresolved cascade change and force root scope. + (ebox-tree-clear-author-style-pending candidate-root)) (unless (equal (plist-get old-state :display-signature) current-display-signature) (push @@ -6540,6 +6775,9 @@ must be recomputed in the next publication." (plist-get local-preparation :range-replacement-count) :runtime-index-complete-p (plist-get local-preparation :runtime-index-complete-p) + :computed-participation-validated-p + (and local-preparation styles-prepared-p) + :styles-prepared-p styles-prepared-p :index candidate-index :dirty-set dirty-set :display-signature current-display-signature @@ -6601,12 +6839,6 @@ must be recomputed in the next publication." ;; published, and path copying introduces no new sibling or ref ;; relations outside the replaced anchors. Re-walking the whole ;; tree here made every commit pay one full-page validation. - (let ((index (plist-get local-preparation :index))) - (ebox-tree-validate-indexed-participation - (plist-get index :node-table) - (plist-get index :parent-table) - (plist-get local-preparation :participation-node-ids) - 'author)) (setq local-preparation (plist-put local-preparation :detached-identity-history detached-history)) @@ -6627,7 +6859,9 @@ must be recomputed in the next publication." (let* ((entry (ebox--dirty-entry-with-impact-vector buffer entry)) (node-id (plist-get entry :node-id)) (dirty-kind (plist-get entry :dirty-kind))) - (if (and (equal (plist-get entry :changed-keys) '(:children)) + (if (plist-get entry :range-delta) + (ebox--patch-op 'span-patch node-id :dirty entry) + (if (and (equal (plist-get entry :changed-keys) '(:children)) (or (plist-get entry :pure-child-reorder-p) (and (plist-get entry :child-splice-p) (ebox-incremental--simple-new-child-splice-p @@ -6652,7 +6886,7 @@ must be recomputed in the next publication." (or (ebox-incremental--declarative-next-owner-id buffer node-id entry) (ebox--buffer-root-node-id buffer)))) - (ebox--patch-op 'owner-rerender node-id :dirty entry))))))) + (ebox--patch-op 'owner-rerender node-id :dirty entry)))))))) (defun ebox-incremental--coalesce-sibling-line-span-ops (buffer ops) "Merge sibling span patches sharing one published line in BUFFER. @@ -6676,6 +6910,12 @@ published slots, so this only changes op granularity, never safety." (maphash (lambda (parent-id group) (if (and (cdr group) + (cl-every + (lambda (op) + (null (cl-intersection + (plist-get (plist-get op :dirty) :changed-keys) + ebox--typography-style-signature-keys))) + group) (not (equal parent-id (ebox--buffer-root-node-id buffer))) (when-let* ((snapshot (ebox--ensure-layout-snapshot-spans buffer parent-id)) @@ -6743,10 +6983,10 @@ box whose child layout is a column. Flex/Grid allocation contexts remain outside this proof and continue through the ordinary conservative owner path." (or (eq (ebox-tree-display-inner node) 'column) (and (eq (plist-get node :ebox-type) 'box) - (plist-get node :ebox-content-node) - (eq (ebox-tree-display-inner - (plist-get node :ebox-content-node)) - 'column)))) + (let ((children (ebox-tree-layout-children node))) + (and (= (length children) 1) + (eq (ebox-tree-display-inner (car children)) + 'column)))))) (defun ebox-incremental--formatting-context-reflow-fast-eligible-p (buffer old-state _candidate-state candidate-id root-id) @@ -7039,7 +7279,10 @@ suppresses the ordinary fallback when any fact is missing." (plist-put copy :node-ids nil) copy)) (cl-remove-if-not - (lambda (entry) (eq (plist-get entry :dirty-kind) 'geometry)) + (lambda (entry) + (or (eq (plist-get entry :dirty-kind) 'geometry) + (and (eq (plist-get entry :dirty-kind) 'structure) + (plist-get entry :range-delta)))) dirty-set))) (paint-dirty (cl-remove-if-not @@ -7049,6 +7292,14 @@ suppresses the ordinary fallback when any fact is missing." (and geometry-dirty (plist-put (copy-sequence prepared) :dirty-set geometry-dirty))) + (range-dirty + (cl-remove-if-not (lambda (entry) + (plist-get entry :range-delta)) + geometry-dirty)) + (ordinary-geometry-dirty + (cl-remove-if (lambda (entry) + (plist-get entry :range-delta)) + geometry-dirty)) ;; Two independent content changes commonly arise from one semantic ;; action (for example a control label plus status text). Try the ;; narrow per-owner allocation union before walking and completing a @@ -7060,6 +7311,7 @@ suppresses the ordinary fallback when any fact is missing." buffer old-state candidate-state geometry-prepared))) (geometry-context-proof (and (not geometry-two-owner-proof) + (null range-dirty) (>= (length geometry-dirty) 2) (ebox-incremental--formatting-context-reflow-proof buffer old-state candidate-state geometry-prepared))) @@ -7069,8 +7321,17 @@ suppresses the ordinary fallback when any fact is missing." geometry-dirty (let ((ebox-incremental--candidate-state-for-slot-proof candidate-state)) - (ebox-incremental--layout-owner-plan - buffer old-state candidate-state geometry-dirty)))) + (append + (mapcar + (lambda (entry) + (ebox--patch-op 'span-patch + (plist-get entry :node-id) + :dirty entry)) + range-dirty) + (and ordinary-geometry-dirty + (ebox-incremental--layout-owner-plan + buffer old-state candidate-state + ordinary-geometry-dirty)))))) (geometry-span-proof (and geometry-plan (ebox-incremental--span-patch-projection-proof @@ -7176,6 +7437,14 @@ suppresses the ordinary fallback when any fact is missing." (not (ebox-style-cascade-active-p))) (ebox-surface--cascade-local-owner-proof-p old-state candidate-state dirty-set)))) + (when (and geometry-span-proof + (not (plist-get geometry-span-proof :owner-proofs))) + ;; A detached single owner is rendered outside its ancestor wrappers. + ;; Preserve the committed external owner suffix when this proof joins a + ;; mixed geometry/paint publication, just as the two-owner union does. + (setq geometry-proof + (plist-put (copy-sequence geometry-span-proof) + :retain-external-owner-suffix-p t))) (when (and geometry-proof paint-valid-p disjoint-p allocation-disjoint-p cascade-safe-p (not (plist-get prepared :scroll-state-transaction)) @@ -7428,11 +7697,12 @@ because it could address a run whose topology the closure just replaced." (defun ebox-incremental--local-reflow-op (buffer render-dirty-set) "Return one local wrapper op for related geometry RENDER-DIRTY-SET." (when (and (> (length render-dirty-set) 1) - (not - (cl-every - (lambda (entry) - (equal (plist-get entry :changed-keys) '(:content))) - render-dirty-set)) + (or (= (length render-dirty-set) 2) + (not + (cl-every + (lambda (entry) + (equal (plist-get entry :changed-keys) '(:content))) + render-dirty-set))) (cl-every (lambda (entry) (let ((entry (ebox--dirty-entry-with-impact-vector @@ -7489,7 +7759,32 @@ rendered into its retained slot and must pass the complete footprint proof." (cl-every (lambda (entry) (and (eq (plist-get entry :dirty-kind) 'geometry) - (not (plist-get entry :children)))) + (not (plist-get entry :children)) + ;; Typography can change intrinsic allocation inside the + ;; candidate Flex item. Those owners already have exact + ;; local span proofs; speculatively rendering their whole + ;; common item here duplicates that work and commonly + ;; rejects after the expensive render. + (null (cl-intersection + (plist-get entry :changed-keys) + ebox--typography-style-signature-keys)) + ;; A whole-owner footprint proof cannot retain the same + ;; intrinsic content runs when a changed text run already + ;; has a different measured width. Reject that group + ;; before rendering it; the per-owner slot proofs can + ;; still publish each changed run precisely. + (let ((old-content + (plist-get (plist-get entry :old-signature) + :content)) + (new-content + (plist-get (plist-get entry :new-signature) + :content))) + (or (not (memq :content + (plist-get entry :changed-keys))) + (not (and (stringp old-content) + (stringp new-content))) + (= (ebox--string-pixel-width old-content) + (ebox--string-pixel-width new-content)))))) render-dirty-set)) (let* ((owner-ids (delete-dups @@ -7614,7 +7909,7 @@ rendered into its retained slot and must pass the complete footprint proof." buffer render-dirty-set))))) (defun ebox-incremental--cascade-root-owner-plan-p - (buffer old-state candidate-state dirty-set) + (old-state candidate-state dirty-set) "Return non-nil when cascade makes a local proof unusable. OLD-STATE and CANDIDATE-STATE bound the style evidence for DIRTY-SET. Dynamic or changed cascade dependencies cannot use a local owner; a static binding @@ -7631,41 +7926,19 @@ closure may still plan one for a later owner-scoped surface projection." objects))) (null (gethash object style-states))))) dirty-set)))) - (and - (or (plist-get old-state :cascade-active-p) + (or + (/= (ebox-tree-author-style-count (plist-get old-state :root-node)) + (ebox-tree-author-style-count + (plist-get candidate-state :root-node))) + (and + (or (plist-get old-state :cascade-active-p) (plist-get old-state :cascade-required-p) (ebox-style-cascade-active-p)) - (cl-some (lambda (entry) + (cl-some (lambda (entry) (not (eq (plist-get entry :dirty-kind) 'paint))) dirty-set) - (or missing-structure-style-p - (not - (and - (cl-every - (lambda (entry) - (or - (eq (plist-get entry :dirty-kind) 'paint) - (let ((old-signature (plist-get entry :old-signature)) - (new-signature (plist-get entry :new-signature))) - (or - (and (plist-get old-signature :width) - (plist-get old-signature :height) - (equal (plist-get old-signature :width) - (plist-get new-signature :width)) - (equal (plist-get old-signature :height) - (plist-get new-signature :height))) - (and (memq :content (plist-get entry :changed-keys)) - (stringp (plist-get old-signature :content)) - (stringp (plist-get new-signature :content)) - (= (length (plist-get old-signature :content)) - (length (plist-get new-signature :content)))) - (ebox-incremental--fast-grid-slot-evidence - buffer entry candidate-state) - (ebox-incremental--allocated-parent-slot-stable-p - buffer entry candidate-state) - (ebox-incremental--content-slot-stable-p - buffer entry candidate-state))))) - dirty-set) + (or missing-structure-style-p + (not (ebox-surface--cascade-local-owner-proof-p old-state candidate-state dirty-set))))))) @@ -7748,19 +8021,31 @@ closure may still plan one for a later owner-scoped surface projection." The candidate index is already the proof boundary for source identity. This check verifies its observable sets and the copied path's parent slots before the surface is allowed to reuse retained TP objects." - (let* ((old-node-table (plist-get old-state :node-table)) + (let* ((partitioned-p (plist-get prepared :partitioned-owner-proof-p)) + (old-node-table (plist-get old-state :node-table)) (new-node-table (plist-get candidate-state :node-table)) (old-parent-table (plist-get old-state :parent-table)) (new-parent-table (plist-get candidate-state :parent-table)) - (touched (delete-dups - (append (plist-get prepared :touched-node-ids) - (list owner-id - (ebox--buffer-root-node-id buffer)))))) - (and (ebox-incremental--hash-key-set-equal-p - old-node-table new-node-table) - (ebox-incremental--hash-key-set-equal-p - (plist-get old-state :region-id-set) - (plist-get candidate-state :region-id-set)) + (touched + (if partitioned-p + (let ((walk owner-id) + (ids + (mapcar (lambda (dirty) (plist-get dirty :node-id)) + (plist-get prepared :dirty-set)))) + (while walk + (push walk ids) + (setq walk (gethash walk old-parent-table))) + (delete-dups ids)) + (delete-dups + (append (plist-get prepared :touched-node-ids) + (list owner-id (ebox--buffer-root-node-id buffer))))))) + (and (or partitioned-p + (ebox-incremental--hash-key-set-equal-p + old-node-table new-node-table)) + (or partitioned-p + (ebox-incremental--hash-key-set-equal-p + (plist-get old-state :region-id-set) + (plist-get candidate-state :region-id-set))) (cl-every (lambda (node-id) (let ((old-node (gethash node-id old-node-table)) @@ -7943,8 +8228,16 @@ the complete proof." (plist-get new-node :ebox-type)) (equal (ebox-tree-node-key old-node) (ebox-tree-node-key new-node)) - (ebox-incremental--node-child-ids-equal-p - old-node new-node) + ;; This certificate describes OWNER-ID's allocation in its + ;; parent, not the contents it allocates internally. A + ;; Range owner is expected to replace its children while + ;; retaining that external slot; the final rendered-span + ;; validator still proves its exact published footprint. + ;; Ancestors above the owner must retain child topology + ;; because it defines the certified sibling allocation. + (or (equal node-id owner-id) + (ebox-incremental--node-child-ids-equal-p + old-node new-node)) (equal allocated-style (ebox-incremental--layout-slot-style-signature old-node)) @@ -8037,6 +8330,14 @@ candidate's unrendered caches." "Return non-nil when TRACK is fixed or a positive fractional track." (or (numberp track) (and (consp track) (null (cdr track)) (numberp (car track))) + (and (proper-list-p track) + (memq (plist-get track :kind) '(fixed fr)) + (let ((value (if (eq (plist-get track :kind) 'fixed) + (plist-get track :size) + (plist-get track :factor)))) + (and (numberp value) (>= value 0) + (or (eq (plist-get track :kind) 'fixed) + (> value 0))))) (and (symbolp track) (string-match-p "\\`[0-9]+\\(?:\\.[0-9]+\\)?fr\\'" (symbol-name track))) @@ -8049,7 +8350,10 @@ candidate's unrendered caches." The fast variable-content proof deliberately excludes fractional tracks; their allocation remains on the full ancestor-slot proof path." (or (numberp track) - (and (consp track) (null (cdr track)) (numberp (car track))))) + (and (consp track) (null (cdr track)) (numberp (car track))) + (and (proper-list-p track) + (eq (plist-get track :kind) 'fixed) + (numberp (plist-get track :size))))) (defun ebox-incremental--compute-allocated-parent-slot-stable-p (buffer entry candidate-state) @@ -8057,7 +8361,7 @@ allocation remains on the full ancestor-slot proof path." This bounded proof accepts an auto-width one-line child only when its nearest unchanged Grid ancestor uses fixed or positive fractional columns and the complete published ancestor slot chain remains identical." - (let* ((old-state (ebox--buffer-render-state buffer)) + (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id (gethash node-id (plist-get old-state :node-table)))) @@ -8082,8 +8386,8 @@ complete published ancestor slot chain remains identical." old-grid old-ancestor new-grid new-ancestor) (setq ancestor-id (gethash ancestor-id parent-table))))) - (let* ((old-props (and old-grid (plist-get old-grid :props))) - (new-props (and new-grid (plist-get new-grid :props))) + (let* ((old-props (and old-grid (ebox-tree-layout-props old-grid))) + (new-props (and new-grid (ebox-tree-layout-props new-grid))) (tracks (and old-props (plist-get old-props :grid-template-columns))) (owner-snapshot @@ -8112,7 +8416,7 @@ complete published ancestor slot chain remains identical." (ebox-incremental--ancestor-slot-signature buffer old-state candidate-state node-id))))) -(defun ebox-incremental--content-slot-stable-p +(defun ebox-incremental--compute-content-slot-stable (buffer entry candidate-state) "Return strict evidence that a one-line auto-width owner is local. The owner may have no declared width when its nearest unchanged formatting @@ -8120,7 +8424,7 @@ context has a stable allocated slot. A column parent is safe when its own layout facts and child topology are unchanged; a row parent is accepted only for the final child, so changing that child's intrinsic width cannot shift a following sibling. The surface footprint validator remains the final gate." - (let* ((old-state (ebox--buffer-render-state buffer)) + (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id (gethash node-id (plist-get old-state :node-table)))) @@ -8174,6 +8478,64 @@ following sibling. The surface footprint validator remains the final gate." 1) ancestor-signature))) +(defun ebox-incremental--content-slot-stable-p + (buffer entry candidate-state) + "Return cached one-line content-slot evidence for ENTRY." + (if (not ebox-incremental--allocated-slot-proof-cache) + (ebox-incremental--compute-content-slot-stable + buffer entry candidate-state) + (let* ((old-signature (plist-get entry :old-signature)) + (new-signature (plist-get entry :new-signature)) + (key (list 'content-slot (plist-get entry :node-id) + (plist-get entry :changed-keys) + (plist-get old-signature :content) + (plist-get new-signature :content))) + (missing (make-symbol "ebox-content-slot-proof-missing")) + (cached (gethash key ebox-incremental--allocated-slot-proof-cache + missing))) + (if (not (eq cached missing)) + (unless (eq cached 'no-evidence) cached) + (let ((evidence + (ebox-incremental--compute-content-slot-stable + buffer entry candidate-state))) + (puthash key (or evidence 'no-evidence) + ebox-incremental--allocated-slot-proof-cache) + evidence))))) + +(defun ebox-incremental--owner-slot-stable-p + (buffer old-state candidate-state owner-id) + "Return OWNER-ID's retained parent-slot certificate, or nil. +The owner and its parent must retain identity, child topology, layout style, +and a normal Column allocation. Candidate rendering remains responsible for +proving that changed descendants still fit this slot exactly." + (let* ((old-nodes (plist-get old-state :node-table)) + (new-nodes (plist-get candidate-state :node-table)) + (old-parents (plist-get old-state :parent-table)) + (new-parents (plist-get candidate-state :parent-table)) + (old-node (and owner-id (gethash owner-id old-nodes))) + (new-node (and owner-id (gethash owner-id new-nodes))) + (parent-id (and owner-id (gethash owner-id old-parents))) + (old-parent (and parent-id (gethash parent-id old-nodes))) + (new-parent (and parent-id (gethash parent-id new-nodes))) + (snapshot + (and old-node + (ebox-incremental--cached-allocation-snapshot + buffer owner-id)))) + (and old-node new-node old-parent new-parent snapshot + (equal parent-id (gethash owner-id new-parents)) + (eq (ebox-tree-display-inner old-parent) 'column) + (eq (ebox-tree-display-inner new-parent) 'column) + (ebox-incremental--node-child-ids-equal-p old-node new-node) + (ebox-incremental--node-child-ids-equal-p old-parent new-parent) + (equal (ebox-incremental--layout-slot-style-signature old-node) + (ebox-incremental--layout-slot-style-signature new-node)) + (equal (ebox-incremental--layout-slot-style-signature old-parent) + (ebox-incremental--layout-slot-style-signature new-parent)) + (not (ebox-tree-node-visible-overflow-p old-node)) + (not (ebox-tree-node-visible-overflow-p new-node)) + (ebox-incremental--ancestor-slot-signature + buffer old-state candidate-state owner-id)))) + (defun ebox-incremental--allocated-parent-slot-stable-p (buffer entry candidate-state) "Return cached allocated-slot evidence for ENTRY in CANDIDATE-STATE." @@ -8198,7 +8560,7 @@ following sibling. The surface footprint validator remains the final gate." Unlike the auto-width row proof, a definite non-growing child cannot change its parent's allocation when its glyph string changes. Parent topology, style, and the published ancestor slot chain remain mandatory." - (let* ((old-state (ebox--buffer-render-state buffer)) + (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id (gethash node-id (plist-get old-state :node-table)))) @@ -8290,13 +8652,13 @@ the complete snapshot proof." (setq node-id old-parent)))) valid)) -(defun ebox-incremental--fast-grid-slot-evidence +(defun ebox-incremental--compute-fast-grid-slot-evidence (buffer entry candidate-state) "Return minimal stable Grid-slot evidence for one variable owner. This intentionally stops at the nearest unchanged Grid instead of rebuilding every ancestor snapshot. The full proof remains available for all other projection kinds and the surface output validator remains authoritative." - (let* ((old-state (ebox--buffer-render-state buffer)) + (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id (gethash node-id (plist-get old-state :node-table)))) @@ -8345,8 +8707,8 @@ projection kinds and the surface output validator remains authoritative." (setq child-id ancestor-id ancestor-id parent-id))) (setq ancestor-id nil)))) - (let* ((old-props (and old-grid (plist-get old-grid :props))) - (new-props (and new-grid (plist-get new-grid :props))) + (let* ((old-props (and old-grid (ebox-tree-layout-props old-grid))) + (new-props (and new-grid (ebox-tree-layout-props new-grid))) (context-kind (and old-grid (ebox-tree-display-inner old-grid))) (tracks (and old-props (plist-get old-props :grid-template-columns))) @@ -8384,6 +8746,30 @@ projection kinds and the surface output validator remains authoritative." :grid-snapshot grid-snapshot :owner-snapshot snapshot))))) +(defun ebox-incremental--fast-grid-slot-evidence + (buffer entry candidate-state) + "Return cached minimal Grid-slot evidence for ENTRY in CANDIDATE-STATE." + (if (not ebox-incremental--allocated-slot-proof-cache) + (ebox-incremental--compute-fast-grid-slot-evidence + buffer entry candidate-state) + (let* ((old-signature (plist-get entry :old-signature)) + (new-signature (plist-get entry :new-signature)) + (key (list 'fast-grid (plist-get entry :node-id) + (plist-get entry :changed-keys) + (plist-get old-signature :content) + (plist-get new-signature :content))) + (missing (make-symbol "ebox-fast-grid-proof-missing")) + (cached (gethash key ebox-incremental--allocated-slot-proof-cache + missing))) + (if (not (eq cached missing)) + (unless (eq cached 'no-evidence) cached) + (let ((evidence + (ebox-incremental--compute-fast-grid-slot-evidence + buffer entry candidate-state))) + (puthash key (or evidence 'no-evidence) + ebox-incremental--allocated-slot-proof-cache) + evidence))))) + (defun ebox-incremental--variable-content-fits-slot-p (snapshot content) "Return non-nil when one-line CONTENT fits the retained owner slot. @@ -8430,6 +8816,25 @@ metadata; this predicate only authorizes the local attempt." (and (memq :content changed-keys) (ebox-incremental--fixed-content-slot-stable-p buffer slot-dirty candidate-state))) + (content-slot-evidence + (and (memq :content changed-keys) + (ebox-incremental--content-slot-stable-p + buffer slot-dirty candidate-state))) + (local-typography-p + (and (not (memq :content changed-keys)) + (cl-some + (lambda (key) + (memq key ebox--typography-style-signature-keys)) + changed-keys) + (cl-every + (lambda (key) + (or (memq key ebox--paint-style-signature-keys) + (memq key ebox--typography-style-signature-keys))) + changed-keys))) + (fixed-flex-evidence + (and (memq :content changed-keys) + (ebox--flex-item-fixed-basis-content-allocation-stable-p + buffer owner-id))) (spans (and snapshot (plist-get snapshot :buffer-spans))) (impact-vector (let ((ebox-incremental--candidate-state-for-slot-proof @@ -8454,12 +8859,30 @@ metadata; this predicate only authorizes the local attempt." :detail-generation (plist-get old-state :layout-snapshot-detail-generation))) - (ebox-incremental--ancestor-slot-signature - buffer old-state candidate-state owner-id)))) + (or content-slot-evidence + (ebox-incremental--ancestor-slot-signature + buffer old-state candidate-state owner-id)))) + (allocated-width + (let* ((owner-slot + (if content-slot-evidence + (or (cadr content-slot-evidence) + (car content-slot-evidence)) + (car ancestor-slot-signature))) + (slots (plist-get + (plist-get owner-slot :parent-slot-signature) + :slots)) + (widths + (mapcar + (lambda (slot) + (- (plist-get slot :end-pixel) + (plist-get slot :start-pixel))) + slots))) + (and widths (apply #'min widths))))) (when (and (or (eq (plist-get operation :op) 'span-patch) (and (eq (plist-get operation :op) 'owner-rerender) - (memq :content changed-keys) - variable-grid-evidence)) + (or local-typography-p + (and (memq :content changed-keys) + variable-grid-evidence)))) (eq (plist-get dirty :dirty-kind) 'geometry) ;; A declarative content update may also carry paint keys ;; (for example a stylesheet-computed :color) in the dirty @@ -8467,19 +8890,21 @@ metadata; this predicate only authorizes the local attempt." ;; that those extra keys cannot affect geometry. Other ;; local style keys remain on their existing conservative ;; path. - (memq :content changed-keys) + (or (memq :content changed-keys) local-typography-p) (cl-every (lambda (key) (or (eq key :content) (eq key :surface-properties) - (memq key ebox--paint-style-signature-keys))) + (memq key ebox--paint-style-signature-keys) + (memq key ebox--typography-style-signature-keys))) changed-keys) (not (plist-get dirty :children)) (null (plist-get dirty :node-ids)) - (or (not (memq 'parent-layout impact-vector)) - (ebox-incremental--content-slot-stable-p - buffer slot-dirty candidate-state) + (or local-typography-p + (not (memq 'parent-layout impact-vector)) + content-slot-evidence fixed-content-evidence + fixed-flex-evidence variable-grid-evidence (ebox-incremental--allocated-parent-slot-stable-p buffer slot-dirty candidate-state)) @@ -8487,7 +8912,8 @@ metadata; this predicate only authorizes the local attempt." (or (and (not (plist-get old-state :cascade-active-p)) (not (plist-get old-state :cascade-required-p)) (not (ebox-style-cascade-active-p))) - (and (memq :content changed-keys) + (and (or (memq :content changed-keys) + local-typography-p) (ebox-surface--cascade-local-owner-proof-p old-state candidate-state (list dirty)))) (null (plist-get old-state :scroll-region-ids)) @@ -8504,10 +8930,11 @@ metadata; this predicate only authorizes the local attempt." (plist-get new-node :key)) (ebox-incremental--node-child-ids-equal-p old-node new-node) - (equal (ebox-incremental--layout-slot-style-signature - old-node) - (ebox-incremental--layout-slot-style-signature - new-node)) + (or local-typography-p + (equal (ebox-incremental--layout-slot-style-signature + old-node) + (ebox-incremental--layout-slot-style-signature + new-node))) (ebox-incremental--same-root-surface-identity-p (plist-get old-state :root-node) (plist-get candidate-state :root-node)) @@ -8515,11 +8942,12 @@ metadata; this predicate only authorizes the local attempt." buffer old-state candidate-state prepared owner-id) (ebox--layout-snapshot-detailed-p snapshot) spans - (or (not path-copied-p) + (or local-typography-p + (not path-copied-p) (ebox-incremental--path-span-line-count-stable-p - buffer new-node spans) - (ebox-incremental--content-slot-stable-p - buffer slot-dirty candidate-state) + buffer new-node spans) + content-slot-evidence + fixed-flex-evidence variable-grid-evidence (ebox-incremental--allocated-parent-slot-stable-p buffer slot-dirty candidate-state)) @@ -8547,25 +8975,19 @@ metadata; this predicate only authorizes the local attempt." "\n" (plist-get (plist-get dirty :new-signature) :content))) - (or variable-grid-evidence fixed-content-evidence)) + (or variable-grid-evidence fixed-content-evidence + fixed-flex-evidence content-slot-evidence)) :variable-content-max-width - (and snapshot - (plist-get - (plist-get snapshot :external-footprint-signature) - :max-line-pixel-width)) - :allocated-width - (let* ((owner-slot (car ancestor-slot-signature)) - (slots (plist-get - (plist-get owner-slot :parent-slot-signature) - :slots)) - (slot (and (= (length slots) 1) (car slots)))) - (and slot - (- (plist-get slot :end-pixel) - (plist-get slot :start-pixel)))) + (or (and content-slot-evidence allocated-width) + (and snapshot + (plist-get + (plist-get snapshot :external-footprint-signature) + :max-line-pixel-width))) + :allocated-width allocated-width :layout-snapshot-detail-generation (plist-get old-state :layout-snapshot-detail-generation) :static-cascade-p - (and (memq :content changed-keys) + (and (or (memq :content changed-keys) local-typography-p) (or (plist-get old-state :cascade-active-p) (plist-get old-state :cascade-required-p)) (ebox-surface--cascade-local-owner-proof-p @@ -8599,24 +9021,31 @@ metadata; this predicate only authorizes the local attempt." (setq valid t) (dolist (proof proofs) (when (and valid (plist-get proof :variable-content-p)) - (let* ((dirty (car (plist-get proof :dirty-set))) - (snapshot (plist-get proof :snapshot)) - (content (plist-get (plist-get dirty :new-signature) - :content)) - (fits (ebox-incremental--variable-content-fits-slot-p - snapshot content))) - (when (not fits) - (setq valid nil))))) + (let ((snapshot (plist-get proof :snapshot)) + (allocated-width (plist-get proof :allocated-width))) + (dolist (dirty (plist-get proof :dirty-set)) + (when valid + (let ((content (plist-get (plist-get dirty :new-signature) + :content))) + (when (and (stringp content) + (not + (if (and (numberp allocated-width) + (not (string-match-p "\n" content))) + (<= (ebox--string-pixel-width content) + allocated-width) + (ebox-incremental--variable-content-fits-slot-p + snapshot content)))) + (setq valid nil)))))))) valid)) -(defun ebox-incremental--fixed-basis-flex-group-span-proof +(defun ebox-incremental--grouped-owner-span-proof (buffer old-state prepared candidate-state owner-plan) - "Return a single-owner proof for grouped fixed-basis Flex descendants. + "Return a single-owner proof for grouped descendants in one stable slot. -The layout owner plan has already established one content-independent Flex -allocation. This final gate renders the complete candidate item into its -published slot, verifies exact footprint compatibility, and packages the -result in the same owner-proof shape consumed by span publication." +The layout owner plan may supply either a content-independent fixed Flex basis +or a retained content-slot certificate. The surface still renders the whole +candidate owner into its published slot and verifies exact footprint, overflow, +role, and outside-complement compatibility before publication." (when (and (= (length owner-plan) 1) (> (length (plist-get prepared :dirty-set)) 1)) (let* ((operation (car owner-plan)) @@ -8636,11 +9065,65 @@ result in the same owner-proof shape consumed by span publication." (lambda (entry) (copy-sequence (plist-get entry :changed-keys))) dirty-set))) + (fixed-flex-slot-p + (ebox--flex-item-fixed-basis-content-allocation-stable-p + buffer owner-id)) + (owner-slot-proof + (ebox-incremental--owner-slot-stable-p + buffer old-state candidate-state owner-id)) + (fixed-flex-ancestor-proof + (and fixed-flex-slot-p + (not owner-slot-proof) + (ebox-incremental--ancestor-slot-signature + buffer old-state candidate-state owner-id))) + ;; A grouped patch publishes the owner as one unit. Its retained + ;; external allocation is therefore the canonical certificate; + ;; probing each changed leaf repeats the same ancestor walk and + ;; produces a certificate for an identity that is not published as + ;; the group owner. Leaf evidence is only the fallback when the + ;; owner has no retained slot certificate yet. + (content-slot-proof + (and (not owner-slot-proof) + (not fixed-flex-ancestor-proof) + (cl-loop + for entry in dirty-set + for proof = (and (memq :content + (plist-get entry :changed-keys)) + (ebox-incremental--content-slot-stable-p + buffer entry candidate-state)) + when proof return proof))) + (allocation-stable-p + (or owner-slot-proof fixed-flex-ancestor-proof + content-slot-proof)) + (ancestor-slot-signature + (or owner-slot-proof fixed-flex-ancestor-proof content-slot-proof + (and snapshot + (list (list :node-id owner-id + :parent-slot-signature + (plist-get snapshot + :parent-slot-signature)))))) (allocated-width - (and snapshot - (plist-get - (plist-get snapshot :external-footprint-signature) - :max-line-pixel-width))) + (or + (when-let* ((capacity-proof + (or owner-slot-proof fixed-flex-ancestor-proof + content-slot-proof)) + (slot-owner + (or (cadr capacity-proof) + (car capacity-proof))) + (slots + (plist-get + (plist-get slot-owner :parent-slot-signature) + :slots))) + (apply #'min + (mapcar + (lambda (slot) + (- (plist-get slot :end-pixel) + (plist-get slot :start-pixel))) + slots))) + (and snapshot + (plist-get + (plist-get snapshot :external-footprint-signature) + :max-line-pixel-width)))) (cascade-safe-p (or (and (not (plist-get old-state :cascade-active-p)) (not (plist-get old-state :cascade-required-p)) @@ -8653,7 +9136,8 @@ result in the same owner-proof shape consumed by span publication." (cons buffer old-state))) (when (and owner-id old-node new-node snapshot allocated-width (not (equal owner-id root-id)) - (eq (plist-get operation :op) 'owner-rerender) + (memq (plist-get operation :op) + '(span-patch owner-rerender)) (cl-every (lambda (entry) (and (eq (plist-get entry :dirty-kind) 'geometry) @@ -8664,13 +9148,18 @@ result in the same owner-proof shape consumed by span publication." (lambda (key) (or (eq key :content) (eq key :surface-properties) - (memq key ebox--paint-style-signature-keys))) + (memq key ebox--paint-style-signature-keys) + (memq key '(:font :font-family :font-height + :font-weight :font-slant)))) (plist-get entry :changed-keys)))) dirty-set) - (ebox--flex-item-fixed-basis-content-allocation-stable-p - buffer owner-id) - (ebox--cached-flex-item-slot-footprint-safe-p - buffer owner-id changed-keys) + allocation-stable-p + ;; Fixed-basis allocation is content-independent on the + ;; flex main axis. The surface performs the one canonical + ;; candidate render and exact old-slot footprint check, so + ;; a second proof render here would only duplicate work. + (or owner-slot-proof fixed-flex-ancestor-proof + content-slot-proof) (equal (gethash owner-id (plist-get old-state :parent-table)) (gethash owner-id @@ -8686,6 +9175,8 @@ result in the same owner-proof shape consumed by span publication." (plist-get candidate-state :root-node)) (null (plist-get old-state :scroll-region-ids)) (not (ebox--node-visible-overflow-p old-node)) + (not (ebox--node-visible-overflow-p new-node)) + ancestor-slot-signature cascade-safe-p) (let* ((owner-proof (list :owner-id owner-id @@ -8693,12 +9184,16 @@ result in the same owner-proof shape consumed by span publication." :changed-keys (copy-sequence changed-keys) :snapshot snapshot :allocated-width allocated-width + :variable-content-p + (and allocated-width + (cl-some + (lambda (entry) + (memq :content + (plist-get entry :changed-keys))) + dirty-set)) + :variable-content-max-width allocated-width :role-owned-lines-p t - :ancestor-slot-signature - (list (list :node-id owner-id - :parent-slot-signature - (plist-get snapshot - :parent-slot-signature))) + :ancestor-slot-signature ancestor-slot-signature :layout-snapshot-detail-generation (plist-get old-state :layout-snapshot-detail-generation) @@ -8714,32 +9209,134 @@ result in the same owner-proof shape consumed by span publication." (plist-get old-state :layout-snapshot-detail-generation) :static-cascade-p cascade-safe-p))))))) +(defun ebox-incremental--range-span-proof + (buffer old-state prepared candidate-state owner-plan) + "Return a strict equal-cardinality Range replacement proof, or nil." + (when (and (= (length owner-plan) 1) + (= (length (plist-get prepared :dirty-set)) 1)) + (let* ((operation (car owner-plan)) + (dirty (car (plist-get prepared :dirty-set))) + (delta (plist-get dirty :range-delta)) + (owner-id (plist-get operation :owner-id)) + (old-node (gethash owner-id (plist-get old-state :node-table))) + (new-node (gethash owner-id + (plist-get candidate-state :node-table))) + (old-items (and delta (apply #'append + (plist-get delta :old-payloads)))) + (new-items (and delta (apply #'append + (plist-get delta :new-payloads)))) + (snapshot + (and old-node + (ebox-incremental--cached-layout-snapshot-details + buffer owner-id))) + (spans (and snapshot (plist-get snapshot :buffer-spans))) + (ancestor-slot-signature + (and owner-id + (ebox-incremental--ancestor-slot-signature + buffer old-state candidate-state owner-id))) + (cascade-safe-p + (or (and (not (plist-get old-state :cascade-active-p)) + (not (plist-get old-state :cascade-required-p)) + (not (ebox-style-cascade-active-p))) + (ebox-surface--cascade-local-owner-proof-p + old-state candidate-state (list dirty))))) + (when (and delta old-node new-node snapshot spans ancestor-slot-signature + (eq (plist-get operation :op) 'span-patch) + (eq (plist-get dirty :dirty-kind) 'structure) + (equal (plist-get dirty :changed-keys) '(:children)) + old-items new-items + (= (length old-items) (length new-items)) + (= (length spans) (length old-items)) + (cl-every (lambda (item) + (eq (plist-get item :ebox-kind) 'box)) + new-items) + (equal (gethash owner-id (plist-get old-state :parent-table)) + (gethash owner-id + (plist-get candidate-state :parent-table))) + (equal (ebox-incremental--layout-slot-style-signature old-node) + (ebox-incremental--layout-slot-style-signature new-node)) + (equal (ebox-tree-display-inner old-node) 'column) + (ebox-incremental--same-root-surface-identity-p + (plist-get old-state :root-node) + (plist-get candidate-state :root-node)) + (null (plist-get old-state :scroll-region-ids)) + (not (ebox-tree-node-visible-overflow-p old-node)) + (not (ebox-tree-node-visible-overflow-p new-node)) + cascade-safe-p) + (list :owner-id owner-id + :owner-ids (list owner-id) + :dirty-set (list dirty) + :snapshot snapshot + :old-spans spans + :allocated-width + (plist-get + (plist-get snapshot :external-footprint-signature) + :max-line-pixel-width) + :ancestor-slot-signature ancestor-slot-signature + :layout-snapshot-detail-generation + (plist-get old-state :layout-snapshot-detail-generation) + :static-cascade-p cascade-safe-p + :range-splice-p t))))) + (defun ebox-incremental--span-patch-projection-proof (buffer old-state prepared candidate-state owner-plan &optional path-copied-p) "Return one atomic single- or multi-owner projection proof, or nil." (if (= (length owner-plan) 1) - (or (ebox-incremental--single-span-patch-projection-proof - buffer old-state prepared candidate-state owner-plan path-copied-p) - (ebox-incremental--fixed-basis-flex-group-span-proof - buffer old-state prepared candidate-state owner-plan)) - (let (proofs valid) - (setq valid (and owner-plan - (= (length owner-plan) - (length (plist-get prepared :dirty-set))))) + (when-let* ((proof + (or (ebox-incremental--single-span-patch-projection-proof + buffer old-state prepared candidate-state owner-plan + path-copied-p) + (ebox-incremental--grouped-owner-span-proof + buffer old-state prepared candidate-state owner-plan) + (ebox-incremental--range-span-proof + buffer old-state prepared candidate-state owner-plan)))) + (unless (plist-get proof :owner-ids) + (setq proof + (plist-put proof :owner-ids + (list (plist-get proof :owner-id))))) + proof) + (let ((remaining (copy-sequence (plist-get prepared :dirty-set))) + proofs + valid) + (setq valid (and owner-plan t)) (dolist (operation owner-plan) (when valid - (let* ((dirty (plist-get operation :dirty)) + (let* ((owned + (cl-remove-if-not + (lambda (dirty) + (ebox--patch-op-covers-node-p + buffer operation (plist-get dirty :node-id) + (if (eq (plist-get dirty :dirty-kind) 'paint) + 'paint-patch + 'span-patch))) + remaining)) (local-prepared (copy-sequence prepared)) proof) + (dolist (dirty owned) + (setq remaining (delq dirty remaining))) (setq local-prepared - (plist-put local-prepared :dirty-set (list dirty))) + (plist-put local-prepared :dirty-set owned)) + ;; Each local proof owns only its assigned identity partition. + ;; Other disjoint operations in the same atomic projection may + ;; legitimately add or remove identities (notably a Range), so a + ;; per-owner proof must not demand global node-set equality. The + ;; aggregate branch still requires every dirty entry to be owned + ;; and every resulting owner footprint to be disjoint. + (setq local-prepared + (plist-put local-prepared :partitioned-owner-proof-p t)) (setq proof - (and dirty - (ebox-incremental--single-span-patch-projection-proof + (and owned + (ebox-incremental--span-patch-projection-proof buffer old-state local-prepared candidate-state (list operation) path-copied-p))) - (if proof (push proof proofs) (setq valid nil))))) + (if proof + (dolist (owner-proof + (or (plist-get proof :owner-proofs) + (list proof))) + (push owner-proof proofs)) + (setq valid nil))))) + (setq valid (and valid (null remaining))) (setq proofs (nreverse proofs)) (when (and valid ;; Variable content is admitted only when every owner has a @@ -8768,23 +9365,17 @@ result in the same owner-proof shape consumed by span publication." (let* ((owner-ids (mapcar (lambda (proof) (plist-get proof :owner-id)) proofs)) - (common-id - (ebox-incremental--common-runtime-ancestor-id - buffer owner-ids)) - (common-snapshot - (and common-id - (ebox-incremental--cached-layout-snapshot-details - buffer common-id))) - (common-max - (and common-snapshot - (plist-get - (plist-get common-snapshot - :external-footprint-signature) - :max-line-pixel-width))) (variable-p (cl-some (lambda (proof) (plist-get proof :variable-content-p)) - proofs))) + proofs)) + (capacities + (delq nil + (mapcar + (lambda (proof) + (plist-get proof :variable-content-max-width)) + proofs))) + (common-max (and capacities (apply #'max capacities)))) (list :owner-ids owner-ids :owner-proofs proofs :variable-content-p @@ -8805,6 +9396,30 @@ result in the same owner-proof shape consumed by span publication." (plist-get proof :static-cascade-p)) proofs))))))) +(defun ebox-incremental--owner-proof-list (proof) + "Return PROOF as one uniform list of leaf owner proofs." + (or (plist-get proof :owner-proofs) + (and (plist-get proof :owner-id) (list proof)))) + +(defun ebox-incremental--native-runtime-overrides (state) + "Return the retained native runtime facts owned by STATE." + (list :native-buffer-scroll-p + (plist-get state :native-buffer-scroll-p) + :native-sync-session + (plist-get state :native-sync-session) + :native-sync-pending + (plist-get state :native-sync-pending) + :native-sync-confirmed-p + (plist-get state :native-sync-confirmed-p) + :native-committed-rendered + (plist-get state :native-committed-rendered) + :native-committed-fragment-template + (plist-get state :native-committed-fragment-template) + :native-committed-owned-ranges + (plist-get state :native-committed-owned-ranges) + :native-fragment-owner-cache + (plist-get state :native-fragment-owner-cache))) + (defun ebox-incremental--surface-state-overrides (old-state prepared candidate-state projection-kind &optional span-proof source-path-copied-p formatting-proof) @@ -8823,41 +9438,29 @@ result in the same owner-proof shape consumed by span publication." :geometry-proof)))) (certificate-proofs (and certificate-proof - (or (plist-get certificate-proof :owner-proofs) - (and (plist-get certificate-proof :owner-id) - (list certificate-proof))))) + (ebox-incremental--owner-proof-list certificate-proof))) (overrides - (list :viewport-width (plist-get old-state :viewport-width) - :viewport-height (plist-get old-state :viewport-height) - :runtime-revision (plist-get candidate-state :runtime-revision) - :display-signature (plist-get prepared :display-signature) - :scroll-state-table (plist-get prepared :scroll-state-table) - :native-buffer-scroll-p - (plist-get candidate-state :native-buffer-scroll-p) - :native-sync-session - (plist-get candidate-state :native-sync-session) - :native-sync-pending - (plist-get candidate-state :native-sync-pending) - :native-sync-confirmed-p - (plist-get candidate-state :native-sync-confirmed-p) - :native-committed-rendered - (plist-get candidate-state :native-committed-rendered) - :native-committed-fragment-template - (plist-get candidate-state :native-committed-fragment-template) - :native-committed-owned-ranges - (plist-get candidate-state :native-committed-owned-ranges) - :native-fragment-owner-cache - (plist-get candidate-state :native-fragment-owner-cache) - :source-path-copied-p source-path-copied-p - :runtime-index-prepared-p - (or (plist-get prepared :runtime-index-complete-p) - (memq projection-kind - '(span-patch owner-scoped scroll-patch - formatting-context-reflow mixed-owner-reflow - viewport-reflow native-frame))) - :logical-candidate-p (plist-get prepared :logical-candidate-p) - :detached-identity-history - (plist-get prepared :detached-identity-history)))) + (append + (list :viewport-width (plist-get old-state :viewport-width) + :viewport-height (plist-get old-state :viewport-height) + :runtime-revision + (plist-get candidate-state :runtime-revision) + :display-signature (plist-get prepared :display-signature) + :scroll-state-table (plist-get prepared :scroll-state-table)) + (ebox-incremental--native-runtime-overrides candidate-state) + (list :source-path-copied-p source-path-copied-p + :runtime-index-prepared-p + (or (plist-get prepared :runtime-index-complete-p) + (memq projection-kind + '(span-patch owner-scoped scroll-patch + formatting-context-reflow mixed-owner-reflow + viewport-reflow native-frame))) + :logical-candidate-p + (plist-get prepared :logical-candidate-p) + :computed-participation-validated-p + (plist-get prepared :computed-participation-validated-p) + :detached-identity-history + (plist-get prepared :detached-identity-history))))) (setq overrides (plist-put overrides :native-touched-node-ids (or (plist-get candidate-state @@ -8970,16 +9573,15 @@ result in the same owner-proof shape consumed by span publication." (plist-get span-proof :allocated-width)) :owner-scoped-proofs (or (and (memq projection-kind '(span-patch owner-scoped)) - (plist-get span-proof :owner-proofs)) + (ebox-incremental--owner-proof-list span-proof)) (and (eq projection-kind 'mixed-owner-reflow) (eq (plist-get (plist-get prepared :mixed-owner-proof) :geometry-kind) 'span-patch) - (plist-get + (ebox-incremental--owner-proof-list (plist-get (plist-get prepared :mixed-owner-proof) - :geometry-proof) - :owner-proofs))) + :geometry-proof)))) :formatting-context-reflow-proof (and (or (eq projection-kind 'formatting-context-reflow) (and (eq projection-kind 'mixed-owner-reflow) @@ -9019,10 +9621,47 @@ result in the same owner-proof shape consumed by span publication." (cl-some (lambda (entry) (eq (plist-get entry :dirty-kind) 'structure)) (plist-get prepared :dirty-set))) + (range-structural-p + (and structural-dirty-p + (cl-every + (lambda (entry) + (or (not (eq (plist-get entry :dirty-kind) 'structure)) + (plist-get entry :range-delta))) + (plist-get prepared :dirty-set)))) (native-program-p - (and (require 'ebox-native-commit nil t) + (and (or (eq (plist-get old-state :projection-kind) 'native-frame) + (>= (length (plist-get prepared :dirty-set)) 3) + structural-dirty-p + (> (or (plist-get prepared :range-replacement-count) 0) 0)) + (zerop (ebox-tree-author-style-count + (plist-get old-state :root-node))) + (zerop (ebox-tree-author-style-count + (plist-get candidate-state :root-node))) + (require 'ebox-native-commit nil t) (ebox-native-commit-plan-eligible-p old-state candidate-state prepared))) + (_normalize-local-dirty + (when (and (not native-program-p) + (plist-get prepared :styles-prepared-p)) + (setq prepared + (plist-put + prepared :dirty-set + (ebox-incremental--computed-local-dirty-set + old-state (plist-get prepared :index) + (plist-get prepared :dirty-set)))) + (setq structural-dirty-p + (cl-some + (lambda (entry) + (eq (plist-get entry :dirty-kind) 'structure)) + (plist-get prepared :dirty-set))) + (setq range-structural-p + (and structural-dirty-p + (cl-every + (lambda (entry) + (or (not (eq (plist-get entry :dirty-kind) + 'structure)) + (plist-get entry :range-delta))) + (plist-get prepared :dirty-set)))))) (formatting-proof (and (not native-program-p) (not (cl-some @@ -9033,14 +9672,16 @@ result in the same owner-proof shape consumed by span publication." buffer old-state candidate-state prepared))) (mixed-shape-p (and (not native-program-p) - (not structural-dirty-p) + (or (not structural-dirty-p) range-structural-p) (cl-some (lambda (entry) (eq (plist-get entry :dirty-kind) 'paint)) (plist-get prepared :dirty-set)) (>= (cl-count-if (lambda (entry) - (eq (plist-get entry :dirty-kind) 'geometry)) + (or (eq (plist-get entry :dirty-kind) 'geometry) + (and (eq (plist-get entry :dirty-kind) 'structure) + (plist-get entry :range-delta)))) (plist-get prepared :dirty-set)) 1))) (mixed-proof @@ -9067,6 +9708,12 @@ result in the same owner-proof shape consumed by span publication." (plist-get mixed-proof :paint-owner-ids))))) (owner-plan (cond + ((/= (ebox-tree-author-style-count + (plist-get old-state :root-node)) + (ebox-tree-author-style-count + (plist-get candidate-state :root-node))) + (ebox-incremental--root-owner-plan + buffer (plist-get prepared :dirty-set))) (native-program-p (ebox-incremental--native-frame-plan buffer (plist-get prepared :dirty-set))) @@ -9082,7 +9729,7 @@ result in the same owner-proof shape consumed by span publication." (let ((ebox-incremental--candidate-state-for-slot-proof candidate-state)) (if (ebox-incremental--cascade-root-owner-plan-p - buffer old-state candidate-state + old-state candidate-state (plist-get prepared :dirty-set)) (ebox-incremental--root-owner-plan buffer (plist-get prepared :dirty-set)) @@ -9116,7 +9763,9 @@ result in the same owner-proof shape consumed by span publication." (mixed-proof 'mixed-owner-reflow) (formatting-proof 'formatting-context-reflow) (paint-p 'paint) - ((and span-proof (plist-get span-proof :static-cascade-p)) + ((and span-proof + (plist-get span-proof :static-cascade-p) + (not (plist-get span-proof :variable-content-p))) 'owner-scoped) (span-proof 'span-patch)))) (when projection-kind @@ -9131,7 +9780,7 @@ result in the same owner-proof shape consumed by span publication." :patch-count (length (plist-get mixed-proof :owner-ids)) :publication-scope 'mixed-owner-union :render-scope 'mixed-owner-union)))) - (when (eq projection-kind 'owner-scoped) + (when span-proof (setq report (plist-put report :ancestor-slot-count (length (plist-get span-proof @@ -9164,7 +9813,15 @@ result in the same owner-proof shape consumed by span publication." ;; publisher use its exact root fallback when that proof cannot ;; establish the outside text invariant. :on-mismatch - (and (> (or (plist-get prepared :range-replacement-count) 0) 0) + (and (or (> (or (plist-get prepared :range-replacement-count) 0) + 0) + (not + (let ((old-ranges (plist-get old-state :range-ref-table)) + (new-ranges + (plist-get candidate-state :range-ref-table))) + (or (and (null old-ranges) (null new-ranges)) + (ebox-incremental--hash-key-set-equal-p + old-ranges new-ranges))))) 'root) :projection-kind projection-kind :preserve-identities-p t @@ -9251,7 +9908,9 @@ whether NEXT-ROOT shares untouched published nodes." (mixed-proof 'mixed-owner-reflow) (formatting-proof 'formatting-context-reflow) (paint-p 'paint) - ((and span-proof (plist-get span-proof :static-cascade-p)) + ((and span-proof + (plist-get span-proof :static-cascade-p) + (not (plist-get span-proof :variable-content-p))) 'owner-scoped) (span-proof 'span-patch)))) (setq prepared (plist-put prepared :scroll-patch-fast-p @@ -9334,7 +9993,7 @@ runtime candidate whose identities already match the published root." (setq report (plist-put report :projection-kind projection-kind))) (when projection-kind (setq report (plist-put report :projection-kind projection-kind))) - (when (eq projection-kind 'owner-scoped) + (when span-proof (setq report (plist-put report :ancestor-slot-count (length (plist-get span-proof @@ -9367,9 +10026,12 @@ runtime candidate whose identities already match the published root." :preserve-identities-p t :state-overrides state-overrides)))))) -(defun ebox-incremental--viewport-state-overrides (state) - "Return surface overrides from viewport candidate STATE." - (list :viewport-width (plist-get state :viewport-width) +(defun ebox-incremental--viewport-state-overrides + (state &optional retain-native-p) + "Return surface overrides from viewport candidate STATE. +When RETAIN-NATIVE-P is non-nil, preserve its committed native frame bases." + (append + (list :viewport-width (plist-get state :viewport-width) :viewport-height (plist-get state :viewport-height) :display-signature (plist-get state :display-signature) :render-cache (plist-get state :render-cache) @@ -9404,10 +10066,12 @@ runtime candidate whose identities already match the published root." :runtime-index-prepared-p t :scroll-state-table (plist-get state :scroll-state-table) :detached-identity-history - (plist-get state :detached-identity-history))) + (plist-get state :detached-identity-history)) + (and retain-native-p + (ebox-incremental--native-runtime-overrides state)))) (defun ebox-incremental--viewport-root-proof-p - (old-state candidate-state dirty-set owner-plan) + (old-state candidate-state dirty-set owner-plan style-context-stable-p) "Return non-nil when a retained viewport root proof can begin." (let* ((old-root (plist-get old-state :root-node)) (candidate-root (plist-get candidate-state :root-node)) @@ -9422,9 +10086,11 @@ runtime candidate whose identities already match the published root." (and (eq (plist-get dirty :dirty-kind) 'geometry) (not (plist-get dirty :children)))) dirty-set) - (not (plist-get old-state :cascade-active-p)) - (not (plist-get old-state :cascade-required-p)) - (not (ebox-style-cascade-active-p)) + (or (and (not (plist-get old-state :cascade-active-p)) + (not (ebox-style-cascade-active-p))) + style-context-stable-p) + (zerop (ebox-tree-author-style-count old-root)) + (zerop (ebox-tree-author-style-count candidate-root)) (not (ebox-tree-node-visible-overflow-p old-root)) (hash-table-p objects) (> (hash-table-count objects) 0) @@ -9439,17 +10105,18 @@ runtime candidate whose identities already match the published root." (plist-get candidate-state :parent-table))))) (defun ebox-incremental--viewport-reflow-safe-p - (old-state candidate-state dirty-set owner-plan) + (old-state candidate-state dirty-set owner-plan style-context-stable-p) "Return non-nil when a viewport reflow can reuse the retained node tree. This strict projection retains only viewport-independent scroll producers." (and (ebox-incremental--viewport-root-proof-p - old-state candidate-state dirty-set owner-plan) + old-state candidate-state dirty-set owner-plan + style-context-stable-p) (or (ebox-incremental--stable-scroll-state-p old-state) (ebox-incremental--viewport-root-scroll-reflow-proof-p - old-state candidate-state)))) + old-state candidate-state style-context-stable-p)))) (defun ebox-incremental--viewport-root-scroll-reflow-proof-p - (old-state candidate-state) + (old-state candidate-state style-context-stable-p) "Return non-nil for one resizable root scroll owner. The root itself is the only scroll producer, so its viewport-dependent content is exactly the formatting context being reflowed. This proof does @@ -9477,9 +10144,11 @@ topology changes; those continue to use the conservative full projection." (ebox-incremental--hash-key-set-equal-p scroll-table candidate-scroll-table) (not (ebox-tree-node-visible-overflow-p old-root)) - (not (plist-get old-state :cascade-active-p)) - (not (plist-get old-state :cascade-required-p)) - (not (ebox-style-cascade-active-p)) + (or (and (not (plist-get old-state :cascade-active-p)) + (not (ebox-style-cascade-active-p))) + style-context-stable-p) + (zerop (ebox-tree-author-style-count old-root)) + (zerop (ebox-tree-author-style-count candidate-root)) (ebox-incremental--hash-key-set-equal-p (plist-get old-state :node-table) (plist-get candidate-state :node-table)) @@ -9553,10 +10222,12 @@ fresh by the mixed projection." :all (append stable affected))))) (defun ebox-incremental--viewport-mixed-scroll-proof-p - (old-state candidate-state dirty-set owner-plan partition) + (old-state candidate-state dirty-set owner-plan partition + style-context-stable-p) "Return PARTITION when a mixed viewport projection is proven safe." (and (ebox-incremental--viewport-root-proof-p - old-state candidate-state dirty-set owner-plan) + old-state candidate-state dirty-set owner-plan + style-context-stable-p) (plist-get partition :stable) (plist-get partition :affected) partition)) @@ -9592,7 +10263,8 @@ fresh by the mixed projection." (plist-get old-state :surface-node-object-table)))) (defun ebox-incremental-prepare-viewport-commit - (buffer viewport-width viewport-height axes display-signature) + (buffer viewport-width viewport-height axes display-signature + style-context-stable-p) "Prepare BUFFER for VIEWPORT-WIDTH, VIEWPORT-HEIGHT, AXES, and DISPLAY-SIGNATURE." (let* ((old-state (ebox--buffer-render-state buffer)) (change (ebox--viewport-constraint-change buffer axes)) @@ -9620,7 +10292,9 @@ fresh by the mixed projection." nil 'no-op :dirty-count 0 :patch-count 0 :patch-ops nil) (ebox--constraint-change-report-props change) (list :viewport-axes axes - :host-context-changed display-changed)) + :host-context-changed display-changed + :target-viewport-width viewport-width + :target-viewport-height target-height)) :state-overrides (list :viewport-width viewport-width :viewport-height target-height @@ -9637,7 +10311,14 @@ fresh by the mixed projection." :dirty-set dirty-set :display-signature display-signature :scroll-state-table candidate-scroll-table)) - owner-plan report projection-kind scroll-partition) + (report-overrides + (append (ebox--constraint-change-report-props change) + (list :viewport-axes axes + :host-context-changed display-changed + :target-viewport-width viewport-width + :target-viewport-height target-height))) + owner-plan report projection-kind scroll-partition + viewport-reflow-safe-p native-continuity-p) (plist-put candidate-state :viewport-width viewport-width) (plist-put candidate-state :viewport-height target-height) (plist-put candidate-state :display-signature display-signature) @@ -9659,14 +10340,21 @@ fresh by the mixed projection." (ebox-incremental--plist-overlay (ebox-incremental--layout-owner-report prepared owner-plan candidate-state) - (append (ebox--constraint-change-report-props change) - (list :viewport-axes axes - :host-context-changed display-changed)))) + report-overrides)) + (setq viewport-reflow-safe-p + (and (not display-changed) + (ebox-incremental--viewport-reflow-safe-p + old-state candidate-state dirty-set owner-plan + style-context-stable-p))) + (setq native-continuity-p + (and viewport-reflow-safe-p + (require 'ebox-native-commit nil t) + (ebox-native-commit-prepare-viewport-continuity + old-state candidate-state))) (setq projection-kind (cond - ((and (not display-changed) - (ebox-incremental--viewport-reflow-safe-p - old-state candidate-state dirty-set owner-plan)) + (native-continuity-p 'native-frame) + (viewport-reflow-safe-p 'viewport-reflow) ((and (not display-changed) (setq scroll-partition @@ -9674,13 +10362,27 @@ fresh by the mixed projection." candidate-state)) (ebox-incremental--viewport-mixed-scroll-proof-p old-state candidate-state dirty-set owner-plan - scroll-partition)) + scroll-partition style-context-stable-p)) 'viewport-reflow-mixed-scroll))) + (when native-continuity-p + (plist-put candidate-state :native-topology-stable-p t) + (plist-put candidate-state :native-touched-node-ids nil) + (plist-put candidate-state :native-removed-node-ids nil) + (setq owner-plan + (ebox-incremental--native-frame-plan buffer dirty-set) + report + (ebox-incremental--plist-overlay + (ebox-incremental--layout-owner-report + prepared owner-plan candidate-state) + report-overrides))) (when (memq projection-kind '(viewport-reflow viewport-reflow-mixed-scroll)) (plist-put candidate-state :layout-fragments-reuse-p t)) (when projection-kind (setq report (plist-put report :projection-kind projection-kind)) + (setq report + (plist-put report :viewport-style-context-stable-p + style-context-stable-p)) (when scroll-partition (setq report (plist-put report :viewport-scroll-stable-ids @@ -9697,14 +10399,23 @@ fresh by the mixed projection." :report-base report :projection-kind projection-kind :state-overrides - (if (memq projection-kind - '(viewport-reflow viewport-reflow-mixed-scroll)) - (ebox-incremental--viewport-reflow-state-overrides - old-state candidate-state - (and (eq projection-kind 'viewport-reflow-mixed-scroll) - scroll-partition)) + (cond + ((eq projection-kind 'native-frame) + (append + (ebox-incremental--viewport-state-overrides + candidate-state t) + (list :native-topology-stable-p t + :native-touched-node-ids nil + :native-removed-node-ids nil))) + ((memq projection-kind + '(viewport-reflow viewport-reflow-mixed-scroll)) + (ebox-incremental--viewport-reflow-state-overrides + old-state candidate-state + (and (eq projection-kind 'viewport-reflow-mixed-scroll) + scroll-partition))) + (t (ebox-incremental--viewport-state-overrides - candidate-state))))))) + candidate-state)))))))) (defun ebox-incremental--finalize-declarative-scroll-publication (scroll-keys &optional prefetch-delay) diff --git a/ebox-layout-config.el b/ebox-layout-config.el index 9988d0b..45a3b91 100644 --- a/ebox-layout-config.el +++ b/ebox-layout-config.el @@ -14,6 +14,8 @@ "ebox-flex" (props)) (declare-function ebox-grid-layout-config-props-p "ebox-grid" (props)) +(declare-function ebox-flex-layout-create "ebox-flex" (&rest plist)) +(declare-function ebox-grid-layout-create "ebox-grid" (&rest plist)) (cl-defstruct (ebox-layout-config @@ -102,6 +104,34 @@ "Return canonical ColumnConfig from evaluated PLIST." (ebox-layout-config--simple-axis-create 'column plist)) +(defun ebox-layout-config-property-names (tag) + "Return canonical property names owned by TAG's LayoutConfig." + (pcase tag + ('flex + '(:flex-direction :flex-wrap :justify-content + :align-items :align-content :row-gap :column-gap)) + ('grid + '(:grid-template-columns :grid-template-rows + :grid-auto-columns :grid-auto-rows :grid-auto-flow + :row-gap :column-gap + :justify-items :align-items :justify-content :align-content)) + ((or 'box 'row 'column) nil) + (_ (error "Unknown Ebox Box author form: %S" tag)))) + +(defun ebox-layout-config-for-form (tag properties) + "Return TAG's typed LayoutConfig from evaluated layout PROPERTIES." + (pcase tag + ('box + (when properties + (error "Ebox Normal layout does not accept config properties: %S" + properties)) + (ebox-normal-layout-create)) + ('row (apply #'ebox-row-layout-create properties)) + ('column (apply #'ebox-column-layout-create properties)) + ('flex (apply #'ebox-flex-layout-create properties)) + ('grid (apply #'ebox-grid-layout-create properties)) + (_ (error "Unknown Ebox Box author form: %S" tag)))) + (provide 'ebox-layout-config) ;;; ebox-layout-config.el ends here diff --git a/ebox-layout.el b/ebox-layout.el index 31344be..bc9613e 100644 --- a/ebox-layout.el +++ b/ebox-layout.el @@ -190,10 +190,17 @@ value on an inner rendered surface wins over the enclosing box's value." "Render BOX by owning proven exact preformatted child content directly. Return nil unless BOX has no visual chrome or sizing behavior that requires the generic box pipeline." - (when (and (not ebox--transparent-preformatted-box-fast-path-disabled) - (ebox--box-content-node box) + (let ((layout-kind (ebox--box-layout-kind box))) + (when (and (not ebox--transparent-preformatted-box-fast-path-disabled) + (or (ebox--box-content-node box) + (memq layout-kind '(row column flex grid))) (not (eq (ebox-get box :visibility) 'hidden)) - (null (ebox-get box :wrap-mode)) + (or (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode)) + ;; Typed Layout children are already a preformatted spatial + ;; result. Their owning Box must not send that result back + ;; through ordinary text wrapping merely because the public + ;; Box default is word wrapping. + (memq layout-kind '(row column flex grid))) (eq (ebox-get box :text-align) 'left) (eq (ebox-get box :vertical-align) 'top) (null (ebox-get box :height)) @@ -212,7 +219,8 @@ the generic box pipeline." (equal (ebox-get box :border-right-pixel) 0) (not (ebox-get box :border-top-p)) (not (ebox-get box :border-bottom-p)) - (null (ebox-get box :color)) + (null (and (ebox-style--text-paint-owner-p box) + (ebox-get box :color))) (null (ebox-get box :bgcolor))) (let* ((content (ebox--box-content box)) (known-width @@ -232,7 +240,7 @@ the generic box pipeline." (puthash region-id box ebox--region-box-table) (unless ebox--intrinsic-layout-measurement (ebox--scroll-clear-state region-id)) - (ebox--record-rendered-uniform-width rendered known-width)))))) + (ebox--record-rendered-uniform-width rendered known-width))))))) (defun ebox--render-flat-preformatted-box (box) "Render simple BOX chrome in a single line-oriented pass. @@ -253,7 +261,8 @@ and its formatted content exactly fills the used height." (equal (ebox-get box :margin-bottom-height) 0)) (let* ((content (ebox--box-content box)) (exact-p (and (plist-get box :ebox-content-width-exact-p) - (null (ebox-get box :wrap-mode)) + (ebox-style-no-soft-wrap-p + (ebox-get box :wrap-mode)) (eq (ebox-get box :text-align) 'left))) (formatted (if exact-p content @@ -287,7 +296,9 @@ and its formatted content exactly fills the used height." (ebox-lines-join (ebox--render-underfilled-simple-scroll-lines lines region-id content-height padding-line-filler - (ebox-get box :color) (ebox-get box :bgcolor))) + (and (ebox-style--text-paint-owner-p box) + (ebox-get box :color)) + (ebox-get box :bgcolor))) (ebox-lines-join (ebox--window-render-flat-wrapper-chunk-lines box lines 0 region-id padding-line-filler t t))))) @@ -394,7 +405,15 @@ Single-child wrappers are transparent to this scheduling boundary." (defun ebox--render-node-window-lines (node limit) "Return NODE's lazy window lines through the registered type renderer." - (when-let* ((type (and (listp node) (plist-get node :ebox-type))) + (when-let* ((type + (and (listp node) + (if (and (eq (plist-get node :ebox-kind) 'box) + (when-let* ((config + (plist-get node + :ebox-layout-config))) + (eq (ebox-layout-config-kind config) 'flex))) + 'flex + (plist-get node :ebox-type)))) (renderer (gethash type ebox--window-line-renderer-table))) (funcall renderer node limit))) @@ -428,7 +447,8 @@ Single-child wrappers are transparent to this scheduling boundary." START-INDEX is the first source line index represented by LINES." (let* ((padding-line-filler (ebox-pixel-space (ebox--content-pixel box))) - (color (ebox-get box :color)) + (color (and (ebox-style--text-paint-owner-p box) + (ebox-get box :color))) (bgcolor (ebox-get box :bgcolor)) (rendered (cl-loop for line in lines @@ -729,7 +749,7 @@ Returns a list of strings (lines)." When WRAP-MODE is nil, return TEXT without pre-wrapping. When WRAP-MODE is `kp', use Knuth-Plass algorithm via ekp package." (cond - ((null wrap-mode) + ((memq wrap-mode '(nil none)) text) ((eq wrap-mode 'kp) (if (and (require 'ekp nil t) @@ -942,39 +962,71 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (not (eq (and (listp node) (plist-get node :ebox-type)) 'box))) content)) -(defun ebox--normal-text-units (string) - "Return word/space/break units for inline Text STRING." +(defun ebox--normal-text-units (string wrap-mode) + "Return inline flow units for Text STRING under WRAP-MODE." (let ((lines (ebox-string-lines string)) units) (cl-loop for line in lines for line-tail on lines - do (let ((position 0) - (limit (length line))) - (while (< position limit) - (let* ((space-p - (not (null - (memq (aref line position) '(?\s ?\t))))) - (end position)) - (while (and (< end limit) - (eq (not (null - (memq (aref line end) - '(?\s ?\t)))) - space-p)) - (setq end (1+ end))) - (let ((part (substring line position end))) - (push (list :kind (if space-p 'space 'word) - :rendered part - :width (ebox--string-pixel-width part)) - units)) - (setq position end)))) + do (if (eq wrap-mode 'char) + (let ((clusters (ebox--grapheme-clusters line))) + (dotimes (index (length clusters)) + (let* ((cluster (aref clusters index)) + (part (substring + line + (plist-get cluster :start) + (plist-get cluster :end)))) + (push (list :kind 'char :rendered part + :width (plist-get cluster :width)) + units)))) + (let ((position 0) + (limit (length line))) + (while (< position limit) + (let* ((space-p + (not (null + (memq (aref line position) '(?\s ?\t))))) + (end position)) + (while (and (< end limit) + (eq (not (null + (memq (aref line end) + '(?\s ?\t)))) + space-p)) + (setq end (1+ end))) + (let ((part (substring line position end))) + (push (list :kind (if space-p 'space 'word) + :rendered part + :width (ebox--string-pixel-width part)) + units)) + (setq position end))))) unless (null (cdr line-tail)) do (push '(:kind break) units)) (nreverse units))) (defun ebox--render-normal-box-children (box children) "Render canonical CHILDREN without flattening inline Box boundaries." + (catch 'rendered + (when (and (= (length children) 1) + (eq (plist-get (car children) :ebox-kind) 'text) + (not (eq (ebox-get box :wrap-mode) 'kp))) + (throw + 'rendered + (ebox--call-with-box-content-context + box + (lambda () + (let* ((child (car children)) + (width (ebox--wrapper-content-viewport-pixel box)) + (wrap-mode (ebox-get box :wrap-mode)) + (rendered + (let ((ebox--inline-auto-width-intrinsic-p t)) + (ebox--render-with-cache child)))) + (if (and width (> width 0) + (ebox-style-soft-wrap-p wrap-mode)) + (ebox--wrap-text rendered width wrap-mode) + rendered)))))) (let* ((resolved-width (ebox--wrapper-content-viewport-pixel box)) (content-width (and resolved-width (> resolved-width 0) resolved-width)) + (wrap-mode (ebox-get box :wrap-mode)) + (soft-wrap-p (not (memq wrap-mode '(nil none kp)))) (line-width 0) pending-empty-line line-parts segments) @@ -988,7 +1040,7 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (setq line-parts nil line-width 0))) (append-atomic (rendered width) - (when (and content-width line-parts + (when (and soft-wrap-p content-width line-parts (> (+ line-width width) content-width)) (flush-line)) (push rendered line-parts) @@ -996,7 +1048,8 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." pending-empty-line nil)) (append-word (rendered width) - (if (or (null content-width) (<= width content-width)) + (if (or (not soft-wrap-p) + (null content-width) (<= width content-width)) (append-atomic rendered width) (let ((parts (ebox--wrap-line rendered content-width 'char))) (cl-loop for part in parts @@ -1033,7 +1086,7 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." ('space (let ((width (plist-get unit :width))) (cond - ((and content-width (> width content-width)) + ((and soft-wrap-p content-width (> width content-width)) (let ((rendered (plist-get unit :rendered))) (if (and (= (length rendered) 1) (get-text-property 0 'display rendered)) @@ -1041,12 +1094,16 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (append-word rendered width)))) ((null line-parts) (append-atomic (plist-get unit :rendered) width)) - ((and content-width (> (+ line-width width) content-width)) + ((and soft-wrap-p content-width + (> (+ line-width width) content-width)) (flush-line)) (t (append-atomic (plist-get unit :rendered) width))))) ('word (append-word (plist-get unit :rendered) (plist-get unit :width))) + ('char + (append-word (plist-get unit :rendered) + (plist-get unit :width))) ('box (append-atomic (plist-get unit :rendered) (plist-get unit :width))))) @@ -1063,7 +1120,7 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (setq pending-empty-line nil) (push rendered segments)) (if (eq (plist-get child :ebox-kind) 'text) - (dolist (unit (ebox--normal-text-units rendered)) + (dolist (unit (ebox--normal-text-units rendered wrap-mode)) (append-unit unit)) (append-unit (list :kind 'box :rendered rendered @@ -1076,12 +1133,12 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (flush-line)) (if segments (apply #'ebox--lines-stack-vertical (nreverse segments)) - ""))) + "")))) (defun ebox--render-box-layout-children (box) "Render canonical BOX children through its typed LayoutConfig." (let* ((kind (ebox--box-layout-kind box)) - (children (plist-get box :children)) + (children (ebox-tree-layout-children box)) (content (ebox--render-box-content-cached box @@ -1116,6 +1173,9 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (plist-get box :ebox-layout-config)) children)) (_ (error "Ebox Box has unsupported LayoutConfig: %S" kind))))))) + (plist-put box :ebox-content-layout-complete-p + (not (and (eq kind 'normal) + (eq (ebox-get box :wrap-mode) 'kp)))) (ebox--record-box-content-width-exact box content (or (memq kind '(row column flex grid)) (consp (cdr children)))) @@ -1123,14 +1183,20 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (defun ebox--box-content (box) "Return BOX content, rendering any lazy child layout content if present." - (ebox--propertize-typography - (cond - ((plist-member box :children) - (ebox--render-box-layout-children box)) - ((when-let* ((node (ebox--box-content-node box))) - (ebox--render-box-content-node box node))) - (t (ebox-get box :content))) - box)) + (let ((content + (cond + ((or (plist-member box :children) + (plist-get box :ebox-child-sequence)) + (ebox--render-box-layout-children box)) + ((when-let* ((node (ebox--box-content-node box))) + (ebox--render-box-content-node box node))) + (t (ebox-get box :content))))) + ;; Canonical Text leaves materialize the inherited font exactly once. + ;; A canonical Box owns geometry/background and must not wrap the same + ;; typography contribution around already-rendered descendants. + (if (eq (plist-get box :ebox-kind) 'box) + content + (ebox--propertize-typography content box)))) (defun ebox--line-min-content-pixel (line) "Return LINE's min-content width in content pixels." @@ -1172,7 +1238,7 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (defun ebox--content-min-pixel (box) "Return BOX's min-content width in content pixels." (let ((wrap-mode (ebox-get box :wrap-mode))) - (if (null wrap-mode) + (if (ebox-style-no-soft-wrap-p wrap-mode) (ebox--content-max-pixel box) (let ((lines (ebox-string-lines (ebox--box-content box))) (max-width 0)) @@ -1361,7 +1427,7 @@ FALLBACK is used for nil and auto values." (known-width (gethash content ebox--rendered-uniform-width-table))) (if (and (eq align 'left) - (or (and (null wrap-mode) + (or (and (ebox-style-no-soft-wrap-p wrap-mode) (or (plist-get box :ebox-content-width-exact-p) (and (numberp known-width) (= known-width @@ -1371,7 +1437,10 @@ FALLBACK is used for nil and auto values." (= known-width (ebox--content-pixel box))))) content (let* ((pixel-width (ebox--content-pixel box)) - (wrapped-content (ebox--wrap-text content pixel-width wrap-mode))) + (wrapped-content + (if (plist-get box :ebox-content-layout-complete-p) + content + (ebox--wrap-text content pixel-width wrap-mode)))) ;; kp mode distributes inner gaps, but Ebox still owns the box geometry. ;; Pad ragged lines (notably the last line) back to the content width ;; while preserving the requested alignment for those lines. @@ -1449,7 +1518,8 @@ Internal implementation of `ebox-render' for box nodes." (border-bottom-color (ebox-get box :border-bottom-color)) ;; Style and Layout - (color (ebox-get box :color)) + (color (and (ebox-style--text-paint-owner-p box) + (ebox-get box :color))) (bgcolor (ebox-get box :bgcolor)) (inner-height (+ content-height padding-top padding-bottom)) (padding-line-filler (ebox-pixel-space content-pixel)) @@ -1767,8 +1837,31 @@ Internal implementation of `ebox-render' for box nodes." (defvar ebox--surface-materialization-active nil "Non-nil while an Ebox TP producer is running raw layout.") -(defvar ebox--render-root-parent-kind nil - "Formatting context of a detached root rendered for its owning parent.") +(defun ebox--render-text-node (node) + "Render canonical TextNode NODE without entering Box geometry." + (let* ((region-id (or ebox--render-region-id + (ebox--ensure-region-id node))) + (value (copy-sequence (plist-get node :ebox-text-value))) + (styled (ebox--propertize-typography value node)) + (painted + (if (or (ebox-get node :color) (ebox-get node :bgcolor)) + (ebox--propertize-colors + styled (ebox-get node :color) (ebox-get node :bgcolor)) + styled)) + (surfaced + (ebox--apply-surface-properties + painted (ebox-get node :surface-properties))) + (fallback (ebox-pixel-space 0)) + (rendered + (ebox-lines-join + (cl-loop for line in (ebox-string-lines surfaced) + for index from 0 + collect (ebox--propertize-content-line + line region-id index fallback))))) + (puthash region-id node ebox--region-box-table) + (unless ebox--intrinsic-layout-measurement + (ebox--scroll-clear-state region-id)) + rendered)) (defun ebox--render-layout (node) "Render layout NODE directly to a multi-line propertized string. @@ -1786,12 +1879,13 @@ This function owns spatial layout only; it never creates a live TP surface." (ebox--ensure-node-id node)) (let ((type (plist-get node :ebox-type))) (cond + ((eq (plist-get node :ebox-kind) 'text) + (ebox--render-text-node node)) ((eq type 'box) (ebox--render-box node)) ((eq type 'concat) (ebox--render-concat node)) ((eq type 'stack) (ebox--render-stack node)) ((eq type 'flex) (ebox--render-flex node)) ((eq type 'grid) (ebox--render-grid node)) - ((eq type 'flex-item) (ebox--render-layout (plist-get node :node))) (t (error "ebox-render: unknown node type %S" type))))))) (declare-function ebox-surface-producer @@ -1840,6 +1934,7 @@ materialized surface is ephemeral and creates no live buffer state." (require 'ebox-surface) (if (and (not (stringp node)) (not (ebox-style-cascade-active-p)) + (zerop (ebox-tree-author-style-count node)) (not (ebox-surface--inline-inheritance-required-p node))) (let ((ebox--region-id-counter ebox--region-id-counter) (ebox--runtime-node-id-counter ebox--runtime-node-id-counter)) @@ -1876,42 +1971,6 @@ The blank lines match the pixel width of the first line of STRING." (blank (ebox-pixel-space px))) (ebox-lines-join (append lines (make-list extra blank))))))) -;;;###autoload -(defun ebox-concat (node1 node2) - "Return a lazy concat node that places NODE1 and NODE2 side by side. -NODE1 and NODE2 must be box/concat/stack nodes created by -`ebox-create', `ebox-concat', or `ebox-stack'. -The result is not rendered until `ebox-render' or `ebox-render-to-buffer' -is called. To compose more than two boxes, nest calls: - (ebox-concat a (ebox-concat b c))" - (list :ebox-type 'concat - :display '(block row) - :children (delq nil (list node1 node2)) - :left node1 - :right node2)) - -;;;###autoload -(defun ebox-spacer (&rest plist) - "Return a blank box node. -PLIST accepts the same properties as `ebox-create', such as :width, -:height, :margin, or :bgcolor." - (apply #'ebox-create :content "" plist)) - -;;;###autoload -(defun ebox-row (&rest nodes) - "Return a lazy horizontal layout node from NODES. -Nil entries are ignored. With zero non-nil nodes, return `ebox-spacer'. -A sole child Range keeps a material row parent so the Range stays addressable." - (setq nodes (delq nil nodes)) - (cond - ((null nodes) (ebox-spacer)) - ((and (null (cdr nodes)) - (not (ebox-child-range--descriptor-p (car nodes)))) - (car nodes)) - (t (list :ebox-type 'concat - :display '(block row) - :children nodes)))) - (defun ebox--render-row-children (children &optional config) "Render flat row CHILDREN with optional typed Row CONFIG. Auto-width children are rendered at intrinsic inline size while explicit @@ -1953,45 +2012,24 @@ horizontal concatenation." "Render a legacy concat NODE to a string." (ebox--render-row-children (ebox--layout-children node))) -;;;###autoload -(defun ebox-stack (node1 node2) - "Return a lazy stack node that places NODE1 above NODE2 vertically. -NODE1 and NODE2 must be box/concat/stack nodes created by -`ebox-create', `ebox-concat', or `ebox-stack'. -The result is not rendered until `ebox-render' or `ebox-render-to-buffer' -is called. To stack more than two boxes, nest calls: - (ebox-stack a (ebox-stack b c))" - (list :ebox-type 'stack - :display '(block column) - :children (delq nil (list node1 node2)) - :top node1 - :bottom node2)) - -;;;###autoload -(defun ebox-column (&rest nodes) - "Return a lazy vertical layout node from NODES. -Nil entries are ignored. With zero non-nil nodes, return `ebox-spacer'. -A sole child Range keeps a material column parent so the Range stays -addressable." - (setq nodes (delq nil nodes)) - (cond - ((null nodes) (ebox-spacer)) - ((and (null (cdr nodes)) - (not (ebox-child-range--descriptor-p (car nodes)))) - (car nodes)) - (t (list :ebox-type 'stack - :display '(block column) - :children nodes)))) - (defun ebox--stack-leaves (node) "Return NODE's vertical stack leaves in render order." (let (leaves) (cl-labels ((collect (current) - (if (and (listp current) - (eq (plist-get current :ebox-type) 'stack)) - (dolist (child (ebox--layout-children current)) - (collect child)) - (push current leaves)))) + (cond + ((and (listp current) + (eq (plist-get current :ebox-type) 'stack)) + (dolist (child (ebox--layout-children current)) + (collect child))) + ((ebox--scroll-window-canonical-column-p current) + ;; A canonical Column is itself the formatting context. + ;; Its direct children are the lazy sequence; nested + ;; Columns remain material leaves because they may own + ;; independent Box chrome. + (dolist (child (ebox-tree-layout-children current)) + (push child leaves))) + (t + (push current leaves))))) (collect node)) (nreverse leaves))) @@ -2664,11 +2702,55 @@ last cached leaf instead of walking the stack from the beginning." :complete complete-cache :box box)))))))) +(defun ebox--scroll-window-canonical-column-p (node) + "Return non-nil for the Column subset supported by lazy scroll layout." + (and (eq (plist-get node :ebox-kind) 'box) + (when-let* ((config (plist-get node :ebox-layout-config))) + (and (eq (ebox-layout-config-kind config) 'column) + (let ((props (ebox-layout-config-props config))) + (and (zerop (or (plist-get props :item-gap) 0)) + (eq (or (plist-get props :cross-align) 'stretch) + 'stretch))))))) + +(defun ebox--scroll-window-transparent-column-p (node) + "Return non-nil when NODE's Column Box adds no visible wrapper chrome." + (and (ebox--scroll-window-canonical-column-p node) + (null (ebox-get node :height)) + (zerop (or (ebox-get node :min-height) 0)) + (null (ebox-get node :max-height)) + (cl-every + (lambda (property) (zerop (or (ebox-get node property) 0))) + '(:padding-left-pixel :padding-right-pixel + :padding-top-height :padding-bottom-height + :margin-left-pixel :margin-right-pixel + :margin-top-height :margin-bottom-height + :border-left-pixel :border-right-pixel)) + (not (ebox-get node :border-top-p)) + (not (ebox-get node :border-bottom-p)) + (null (ebox-get node :color)) + (null (ebox-get node :bgcolor)) + (null (ebox-get node :font)) + (null (ebox-get node :font-family)) + (null (ebox-get node :font-height)) + (null (ebox-get node :font-weight)) + (null (ebox-get node :font-slant)) + (null (ebox-get node :surface-properties)) + (eq (ebox-get node :visibility) 'visible) + (eq (ebox-get node :text-align) 'left) + (eq (ebox-get node :vertical-align) 'top))) + (defun ebox--scroll-window-stack-source (node) "Return lazy scroll stack source metadata for NODE, or nil." (cond ((eq (plist-get node :ebox-type) 'stack) (list :stack node)) + ((ebox--scroll-window-canonical-column-p node) + (list :stack node)) + ((and (eq (plist-get node :ebox-kind) 'box) + (when-let* ((children (ebox-tree-layout-children node))) + (and (= (length children) 1) + (ebox--scroll-window-transparent-column-p (car children))))) + (list :stack (car (ebox-tree-layout-children node)))) ((and (eq (plist-get node :ebox-type) 'box) (eq (plist-get (plist-get node :ebox-content-node) :ebox-type) @@ -2689,8 +2771,9 @@ are always resolved from BOX so no closure can retain a replaced runtime." (plist-get spec :materialize-viewport-width)) (viewport-height (plist-get spec :viewport-height)) (source - (ebox--scroll-window-stack-source - (ebox--box-content-node box)))) + (or (ebox--scroll-window-stack-source + (ebox--box-content-node box)) + (ebox--scroll-window-stack-source box)))) (unless (and (numberp prefix-viewport-width) (> prefix-viewport-width 0) (numberp materialize-viewport-width) @@ -2722,7 +2805,7 @@ are always resolved from BOX so no closure can retain a replaced runtime." (defun ebox--scroll-window-source-lines-exact-p (box window) "Return non-nil when WINDOW lines can be used as BOX formatted content." (and (plist-get window :content-width-exact-p) - (null (ebox-get box :wrap-mode)) + (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode)) (eq (ebox-get box :text-align) 'left) (= (ebox--content-pixel box) (or (ebox--viewport-pixel-width nil) 0)))) @@ -2744,7 +2827,7 @@ are always resolved from BOX so no closure can retain a replaced runtime." (or (null (ebox-get box :min-height)) (equal (ebox-get box :min-height) 0)) (null (ebox-get box :max-height)) - (null (ebox-get box :wrap-mode)) + (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode)) (eq (ebox-get box :text-align) 'left) (eq (ebox-get box :vertical-align) 'top)))) @@ -2783,7 +2866,8 @@ no included vertical margin." (border-bottom-p (and include-bottom (ebox-get box :border-bottom-p))) (border-bottom-color (ebox-get box :border-bottom-color)) - (color (ebox-get box :color)) + (color (and (ebox-style--text-paint-owner-p box) + (ebox-get box :color))) (bgcolor (ebox-get box :bgcolor)) (padding-left-line (when (> padding-left 0) @@ -2966,7 +3050,8 @@ only used for incomplete lazy prefixes." (border-top-p (and include-top (ebox-get box :border-top-p))) (border-top-color (ebox-get box :border-top-color)) - (color (ebox-get box :color)) + (color (and (ebox-style--text-paint-owner-p box) + (ebox-get box :color))) (bgcolor (ebox-get box :bgcolor)) (inner-height (+ (length content-lines) padding-top)) result) @@ -3250,8 +3335,9 @@ only used for incomplete lazy prefixes." (let ((content-height (ebox--scroll-window-content-height box)) (scroll-offset (max 0 (or (ebox-get box :scroll-offset) 0))) (content-viewport (ebox--wrapper-content-viewport-pixel box)) - (source (ebox--scroll-window-stack-source - (ebox--box-content-node box)))) + (source (or (ebox--scroll-window-stack-source + (ebox--box-content-node box)) + (ebox--scroll-window-stack-source box)))) (when (and (not ebox--scroll-window-render-disabled) (eq (ebox-get box :overflow) 'scroll) (ebox--viewport-height-size-value-p (ebox-get box :height)) diff --git a/ebox-measure.el b/ebox-measure.el index 6b476d3..0451b6d 100644 --- a/ebox-measure.el +++ b/ebox-measure.el @@ -232,6 +232,19 @@ keep their absolute pixel width." (puthash string width cache)) width))))) +(defun ebox--measurement-face-remapping () + "Return face remaps that can affect geometric text measurement. +TP paint slots are stable paint-layer addresses. Their foreground/background +updates change redisplay without changing glyph geometry, so installing or +switching those layers must not invalidate Ebox measurement caches." + (when (boundp 'face-remapping-alist) + (cl-remove-if + (lambda (entry) + (let ((face (car-safe entry))) + (and (symbolp face) + (string-match-p "\\`tp-paint-slot-" (symbol-name face))))) + face-remapping-alist))) + ;;;###autoload (defun ebox-string-pixel-width (string) "Return the display pixel width of STRING's first line. @@ -251,7 +264,7 @@ This is the public, text-scale-aware measurement used by Ebox layout code." (face-attribute 'default :width nil t) (face-attribute 'default :foreground nil t) (face-attribute 'default :background nil t) - (and (boundp 'face-remapping-alist) face-remapping-alist) + (ebox--measurement-face-remapping) (and (boundp 'text-scale-mode-amount) text-scale-mode-amount))) (defsubst ebox--current-display-signature () diff --git a/ebox-native-commit.el b/ebox-native-commit.el index bb82af1..1173385 100644 --- a/ebox-native-commit.el +++ b/ebox-native-commit.el @@ -20,6 +20,8 @@ (session node frame &optional layout-package state)) (declare-function ebox-native-reflow-create-session "ebox-native-reflow" (&rest arguments)) +(declare-function ebox-native-reflow-fork-session + "ebox-native-reflow" (session &rest options)) (declare-function ebox-native-reflow-confirm-native-frame "ebox-native-reflow" (session generation key confirmed-revision)) @@ -232,6 +234,59 @@ type, key, parent, and sibling position are identical." (ebox-native-commit--runtime-types-supported-p state) (ebox-native-commit--region-index-safe-p state)))) +(defun ebox-native-commit-viewport-continuity-eligible-p + (previous-state candidate-state) + "Return non-nil when CANDIDATE-STATE can extend the retained native frame. +The viewport planner owns the unchanged-topology proof. This predicate owns +the native continuation contract: a live session, a confirmable prior frame, +the committed text/fragment bases, compiler postorder, and projection support." + (and (eq (plist-get previous-state :projection-kind) 'native-frame) + (plist-get previous-state :native-sync-session) + (or (plist-get previous-state :native-sync-pending) + (plist-get previous-state :native-sync-confirmed-p)) + (stringp (plist-get previous-state :native-committed-rendered)) + (vectorp + (plist-get previous-state :native-committed-fragment-template)) + (plist-member previous-state :native-committed-owned-ranges) + (let ((postorder (plist-get previous-state :native-node-postorder))) + (and (vectorp postorder) (> (length postorder) 0))) + (let ((state (copy-sequence candidate-state))) + (plist-put state :native-topology-stable-p t) + ;; Viewport context changed, but no declarative source node did. + ;; Presence of this empty set is an explicit compiler certificate. + (plist-put state :native-touched-node-ids nil) + (plist-put state :native-removed-node-ids nil) + (ebox-native-commit-projection-eligible-p + previous-state state nil)))) + +(defun ebox-native-commit--isolate-session + (previous-state candidate-state) + "Give CANDIDATE-STATE a private child of PREVIOUS-STATE's native session." + (when-let* ((session (plist-get previous-state :native-sync-session)) + (fork + (condition-case nil + (ebox-native-reflow-fork-session session) + (error nil)))) + (plist-put candidate-state :native-sync-session fork) + ;; A pending/confirmed frame is local to the previous Rust handle. The + ;; fork retains compiler facts but begins its own publication sequence. + (plist-put candidate-state :native-sync-pending nil) + (plist-put candidate-state :native-sync-confirmed-p nil) + t)) + +(defun ebox-native-commit-prepare-viewport-continuity + (previous-state candidate-state) + "Prepare a private native viewport continuation in CANDIDATE-STATE." + (and (ebox-native-commit-viewport-continuity-eligible-p + previous-state candidate-state) + (ebox-native-commit--isolate-session + previous-state candidate-state) + (progn + (plist-put candidate-state :native-topology-stable-p t) + (plist-put candidate-state :native-touched-node-ids nil) + (plist-put candidate-state :native-removed-node-ids nil) + t))) + (defun ebox-native-commit-plan-eligible-p (old-state candidate-state prepared) "Return non-nil when PREPARED can bypass superseded local layout proofs." @@ -249,22 +304,25 @@ type, key, parent, and sibling position are identical." (ebox-native-commit-object-delta-node-ids old-state candidate-state prepared))) (state (copy-sequence candidate-state))) - (plist-put candidate-state :native-topology-stable-p stable) - (plist-put candidate-state :native-touched-node-ids layout-touched) - (when context-axes-stable - (plist-put candidate-state :viewport-dependent-node-ids-ready t) - (plist-put candidate-state :viewport-dependent-node-ids - (plist-get old-state :viewport-dependent-node-ids)) - (plist-put candidate-state :viewport-dependent-node-id-axes - (plist-get old-state :viewport-dependent-node-id-axes))) - (unless stable - (cl-remf candidate-state :native-fragment-owner-cache)) (plist-put state :native-touched-node-ids layout-touched) (plist-put state :native-topology-stable-p stable) - (and (or stable layout-touched) - (not (ebox-style-cascade-active-p)) - (ebox-native-commit-projection-eligible-p - old-state state nil)))) + (when (and (or stable layout-touched) + (not (ebox-style-cascade-active-p)) + (ebox-native-commit-projection-eligible-p + old-state state nil) + (ebox-native-commit--isolate-session + old-state candidate-state)) + (plist-put candidate-state :native-topology-stable-p stable) + (plist-put candidate-state :native-touched-node-ids layout-touched) + (when context-axes-stable + (plist-put candidate-state :viewport-dependent-node-ids-ready t) + (plist-put candidate-state :viewport-dependent-node-ids + (plist-get old-state :viewport-dependent-node-ids)) + (plist-put candidate-state :viewport-dependent-node-id-axes + (plist-get old-state :viewport-dependent-node-id-axes))) + (unless stable + (cl-remf candidate-state :native-fragment-owner-cache)) + t))) (defun ebox-native-commit--root-box (node) "Return NODE's outer box, or nil." @@ -309,7 +367,7 @@ type, key, parent, and sibling position are identical." (maphash (lambda (_node-id node) (unless (memq (plist-get node :ebox-type) - '(box concat stack flex flex-item)) + '(box concat stack flex)) (setq supported nil))) nodes)) supported)) @@ -482,7 +540,7 @@ type, key, parent, and sibling position are identical." Return nil without mutating STATE when the native capability proof is absent." (when (and (require 'ebox-native-reflow nil t) (ebox-native-reflow-layout-ready-p) - (memq (plist-get state :projection-kind) '(nil native-frame)) + (eq (plist-get state :projection-kind) 'native-frame) (ebox-native-commit--runtime-types-supported-p state) (ebox-native-commit--region-index-safe-p state)) (when-let* ((frame-spec (ebox-native-commit--frame-spec state node))) diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 2eaf81e..0dcb1cf 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -46,6 +46,13 @@ (declare-function ebox-native--module-release "ebox_native_reflow" (session)) (declare-function ebox--grapheme-clusters "ebox-layout" (string)) (declare-function ebox--format-content-string "ebox-layout" (box content)) +(declare-function ebox-style-no-soft-wrap-p "ebox-style" (value)) +(declare-function ebox-style-soft-wrap-p "ebox-style" (value)) +(declare-function ebox-style--text-paint-owner-p "ebox-style" (node)) +(declare-function ebox-layout-config-kind "ebox-layout-config" (config)) +(declare-function ebox-layout-config-props "ebox-layout-config" (config)) +(declare-function ebox-tree-node-children "ebox-tree" (node)) +(declare-function ebox-buffer--font-face "ebox-buffer-backend" (style)) (declare-function ebox--ensure-node-id "ebox" (node)) (declare-function ebox--buffer-render-state "ebox-incremental" (buffer)) (declare-function ebox--ensure-region-id "ebox" (box)) @@ -55,7 +62,6 @@ (declare-function ebox--flex-container-content-props "ebox-flex" (props raw-props wrapper-box)) (declare-function ebox--flex-item-props "ebox-flex" (item)) -(declare-function ebox--flex-item-source-node "ebox-tree" (item)) (declare-function ebox--flex-line-value "ebox-flex" (value &optional default)) (declare-function ebox--content-min-pixel "ebox-layout" (box)) (declare-function ebox--horizontal-size-pixels @@ -1308,6 +1314,29 @@ Ebox nodes, identities, text, properties, or publication state." :layout-fragment-cache (make-hash-table :test 'equal) :layout-fragment-revision 0))) +(cl-defun ebox-native-reflow-fork-session + (session &key (workers 1) (max-jobs 4) (max-results 4) + (max-result-bytes ebox-native-reflow-max-result-bytes)) + "Return an unpublished native child of committed SESSION. +The child owns a fresh Rust handle, while immutable compiled layout facts and +a private copy of the fragment index seed its first frame. Pending/confirmed +frame state deliberately stays with SESSION because it names that handle." + (ebox-native-reflow--live-handle session) + (let ((fork + (ebox-native-reflow-create-session + :workers workers :max-jobs max-jobs :max-results max-results + :max-result-bytes max-result-bytes))) + (setf (ebox-native-reflow-session-styles fork) + (copy-sequence (ebox-native-reflow-session-styles session)) + (ebox-native-reflow-session-layout-package fork) + (ebox-native-reflow-session-layout-package session) + (ebox-native-reflow-session-layout-fragment-cache fork) + (copy-hash-table + (ebox-native-reflow-session-layout-fragment-cache session)) + (ebox-native-reflow-session-layout-fragment-revision fork) + (ebox-native-reflow-session-layout-fragment-revision session)) + fork)) + (defun ebox-native-reflow--live-handle (session) "Return SESSION's live module handle or signal an error." (unless (and (ebox-native-reflow-session-p session) @@ -1599,7 +1628,7 @@ This mirrors the static half of `ebox--render-transparent-preformatted-box'. Rust still verifies the dynamic equal-width and nonempty-line proof per frame." (and (eq box ebox-native-reflow--compile-root-node) (eq (plist-get source-child :ebox-type) 'stack) - (null (ebox-get box :wrap-mode)) + (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode)) (eq (ebox-get box :text-align) 'left) (eq (ebox-get box :vertical-align) 'top) (null (ebox-get box :height)) @@ -1618,16 +1647,145 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame." (null (ebox-get box :color)) (null (ebox-get box :bgcolor)))) +(defun ebox-native-reflow--simple-axis-align (value) + "Return Flex IR alignment equivalent to simple-axis VALUE." + (pcase value + ('start "flex-start") + ('end "flex-end") + ((or 'center 'stretch) (symbol-name value)) + (_ (error "Native reflow unsupported Row/Column alignment: %S" value)))) + +(defun ebox-native-reflow--simple-axis-item-align (kind child cross-align) + "Return CHILD alignment for simple-axis KIND and CROSS-ALIGN." + (cond + ((eq (plist-get child :ebox-kind) 'text) "flex-start") + ((not (eq cross-align 'stretch)) + (ebox-native-reflow--simple-axis-align cross-align)) + (t + (let ((cross-size (ebox-get child (if (eq kind 'row) :height :width)))) + (if (memq cross-size '(nil auto stretch)) + "stretch" + "flex-start"))))) + +(defun ebox-native-reflow--compile-typed-axis-children + (kind config children &optional fragments) + "Compile canonical Row/Column CHILDREN for KIND and CONFIG. +The native IR represents this simple-axis layout as fixed Flex items so +cross-axis stretch and item gap remain explicit without legacy runtime nodes." + (let* ((props (ebox-layout-config-props config)) + (gap (plist-get props :item-gap)) + (cross-align (plist-get props :cross-align)) + (row-p (eq kind 'row)) + (compiled-children + (mapcar + (lambda (child) + (if fragments + (ebox-native-reflow--compiled-scene-child fragments child) + (ebox-native-reflow--compile-node child))) + children))) + (if (and (memq kind '(row column)) + (equal gap 0) (eq cross-align 'stretch)) + ;; Default Row/Column are exactly Rust's simple axis algorithms: + ;; children are top-aligned/padded on Row and share the known content + ;; viewport on Column. Sending this subset through Flex repeats + ;; intrinsic measurement and distribution at every nesting level. + (list :type (symbol-name kind) + :children (vconcat compiled-children)) + (list + :type "flex" + :direction (symbol-name kind) + :wrap "nowrap" + :justify "flex-start" + :align-items + (ebox-native-reflow--simple-axis-align + (plist-get props :cross-align)) + :align-content "stretch" + ;; Row/Column are the complete child layout of their owning Box. Their + ;; inline axis therefore uses that Box's content viewport; `auto' would + ;; collapse a Column to max-content and lose cross-axis stretch. + :width (list :kind "stretch") + :height (list :kind "auto") + :row-gap (if row-p 0 gap) + :column-gap (if row-p gap 0) + :items + (vconcat + (cl-mapcar + (lambda (child compiled) + (list :node compiled + :order 0 :grow 0 :shrink 0 + :basis (list :kind "auto") + :align-self + (ebox-native-reflow--simple-axis-item-align + kind child cross-align))) + children compiled-children)))))) + +(defun ebox-native-reflow--compile-typed-box-child (box) + "Compile canonical BOX's typed child layout, or return nil." + (let* ((config (plist-get box :ebox-layout-config)) + (kind (and config (ebox-layout-config-kind config))) + (children (ebox-tree-node-children box))) + (unless config + (error "Native reflow canonical Box has no LayoutConfig")) + (pcase kind + ('normal + (pcase children + ('() nil) + (`(,child) (ebox-native-reflow--compile-node child)) + (_ (error "Native reflow does not yet compile multi-child Normal Box")))) + ((or 'row 'column) + (ebox-native-reflow--compile-typed-axis-children + kind config children)) + ('flex + (ebox-native-reflow--compile-flex-inner + (copy-sequence (ebox-layout-config-props config)) children)) + ('grid + (error "Native reflow does not yet compile Grid LayoutConfig")) + (_ (error "Native reflow unsupported LayoutConfig: %S" kind))))) + +(defun ebox-native-reflow--compile-text-node (node) + "Compile canonical Text NODE to the lightweight native Text IR." + (let* ((region-id (ebox--ensure-region-id node)) + (typography-style + (ebox-native-reflow--compile-add-face-style + (ebox-buffer--font-face node))) + (foreground-style + (ebox-native-reflow--compile-add-face-style + (ebox-native-reflow--compile-foreground-face + (ebox-get node :color)))) + (surface-template-id + (ebox-native-reflow--register-property-template + (ebox-get node :surface-properties) "text surface")) + (wrap-mode (or (ebox-get node :wrap-mode) 'none))) + (list :type "text" + :region-id region-id + :content + (ebox-native-reflow--compile-text + (or (plist-get node :ebox-text-value) + (ebox-get node :content) + "")) + :typography-style (or typography-style :null) + :foreground-style (or foreground-style :null) + :surface-template-id (or surface-template-id :null) + :wrap-mode + (ebox-native-reflow--symbol-name + wrap-mode 'none '(none word char) "Text wrap-mode")))) + (defun ebox-native-reflow--compile-box (box &optional child-override child-override-p child-resolved-p) "Compile normalized Ebox BOX to strict layout IR." - (when (plist-member box :children) - (error "Native reflow does not yet compile typed Box children")) - (let* ((source-child (plist-get box :ebox-content-node)) + (let* ((typed-box-p (eq (plist-get box :ebox-kind) 'box)) + (typed-text-p (eq (plist-get box :ebox-kind) 'text)) + (typed-layout-kind + (and typed-box-p + (ebox-layout-config-kind + (plist-get box :ebox-layout-config)))) + (source-child (plist-get box :ebox-content-node)) (child (if (or child-override-p child-resolved-p) child-override - (and source-child - (ebox-native-reflow--compile-node source-child)))) + (if typed-box-p + (ebox-native-reflow--compile-typed-box-child box) + (and source-child + (ebox-native-reflow--compile-node source-child))))) (wrap-mode (or (ebox-get box :wrap-mode) 'none)) (kp-content (and (not child) @@ -1647,7 +1805,12 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame." (foreground-style (ebox-native-reflow--compile-add-face-style (ebox-native-reflow--compile-foreground-face - (ebox-get box :color)))) + (and (ebox-style--text-paint-owner-p box) + (ebox-get box :color))))) + (typography-style + (and (ebox-style--text-paint-owner-p box) + (ebox-native-reflow--compile-add-face-style + (ebox-buffer--font-face box)))) (background-style (ebox-native-reflow--compile-add-face-style (let ((background (ebox-get box :bgcolor))) @@ -1679,24 +1842,40 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame." (when color (list :color color))))))))) (surface-template-id (ebox-native-reflow--register-property-template - (ebox-get box :surface-properties) "surface"))) + (ebox-get box :surface-properties) "surface")) + (fused-text-p + (and typed-box-p + (eq typed-layout-kind 'normal) + (equal (plist-get child :type) "text")))) (list :type "box" :region-id region-id - :content (if child - :null + :content (cond + (fused-text-p (plist-get child :content)) + (child :null) + (t (ebox-native-reflow--compile-text (or kp-content (ebox-get box :content) "") - (and kp-content t))) - :child (or child :null) + (and kp-content t)))) + :content-region-id + (if fused-text-p (plist-get child :region-id) :null) + :content-typography-style + (if fused-text-p (plist-get child :typography-style) :null) + :content-foreground-style + (if fused-text-p (plist-get child :foreground-style) :null) + :content-surface-template-id + (if fused-text-p (plist-get child :surface-template-id) :null) + :child (if fused-text-p :null (or child :null)) :content-width-exact (if (or child-override-p + (memq typed-layout-kind '(row column flex grid)) (ebox-native-reflow--transparent-stack-content-exact-p box source-child)) t :false) :width (ebox-native-reflow--compile-size - (ebox-get box :width) 'horizontal "auto") + (if typed-text-p 'max-content (ebox-get box :width)) + 'horizontal "auto") :min-width (ebox-native-reflow--compile-size (ebox-get box :min-width) 'horizontal "pixels") :max-width (ebox-native-reflow--compile-size @@ -1721,6 +1900,7 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame." :margin-bottom margin-bottom :border-left border-left :border-right border-right + :typography-style (or typography-style :null) :foreground-style (or foreground-style :null) :background-style (or background-style :null) :border-left-style (or border-left-style :null) @@ -1757,7 +1937,7 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame." (item axis &optional compiled-node compiled-node-p content-min-width content-min-width-p) "Compile flex ITEM metadata and source for AXIS." - (let* ((source (ebox--flex-item-source-node item)) + (let* ((source item) (props (ebox--flex-item-props item)) (order (plist-get props :order)) (grow (plist-get props :flex-grow)) @@ -1785,12 +1965,14 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame." ;; the source subtree again during native package construction. (when (and (eq axis 'row) (eq (plist-get source :ebox-type) 'box) - (ebox-get source :wrap-mode) - ;; A composite child already exists in the compiled fragment; - ;; Rust derives its intrinsic minimum directly from that child - ;; when the visible Elisp render did not measure it yet. - (or content-min-width-p - (null (plist-get source :ebox-content-node)))) + (not (eq (plist-get source :ebox-kind) 'text)) + (ebox-style-soft-wrap-p (ebox-get source :wrap-mode)) + ;; A composite child already exists in the compiled fragment; + ;; Rust derives its intrinsic minimum directly from that child + ;; when the visible Elisp render did not measure it yet. + (or content-min-width-p + (and (null (plist-get source :ebox-content-node)) + (null (ebox-tree-node-children source))))) (unless content-min-width-p (setq content-min-width (ebox--content-min-pixel source))) (setq compiled @@ -1806,88 +1988,89 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame." (if (eq axis 'row) 'horizontal 'vertical) "auto") :align-self - (ebox-native-reflow--compile-flex-align - (plist-get props :align-self) 'auto "align-self")))) + (if (eq (plist-get source :ebox-kind) 'text) + "flex-start" + (ebox-native-reflow--compile-flex-align + (plist-get props :align-self) 'auto "align-self"))))) + +(defun ebox-native-reflow--compile-flex-inner + (props items &optional fragments flex-content-min-widths) + "Compile Flex PROPS and ITEMS to one backend-neutral native IR node." + (let ((axis (ebox--flex-axis props))) + (list + :type "flex" + :direction + (ebox-native-reflow--symbol-name + (plist-get props :flex-direction) 'row + '(row row-reverse column column-reverse) "flex-direction") + :wrap + (ebox-native-reflow--symbol-name + (plist-get props :flex-wrap) 'nowrap + '(nowrap wrap wrap-reverse) "flex-wrap") + :justify + (ebox-native-reflow--compile-flex-align + (plist-get props :justify-content) 'flex-start "justify-content") + :align-items + (ebox-native-reflow--compile-flex-align + (plist-get props :align-items) 'stretch "align-items") + :align-content + (ebox-native-reflow--compile-flex-align + (plist-get props :align-content) 'stretch "align-content") + :width + (ebox-native-reflow--compile-size + (plist-get props :width) 'horizontal "auto") + :height + (ebox-native-reflow--compile-size + (plist-get props :height) 'vertical "auto") + :row-gap (or (ebox--flex-line-value (plist-get props :row-gap) 0) 0) + :column-gap + (or (ebox--horizontal-size-pixels (plist-get props :column-gap) 0) 0) + :items + (vconcat + (mapcar + (lambda (item) + (if fragments + (let* ((source item) + (missing (make-symbol "missing-content-min")) + (content-min + (if flex-content-min-widths + (gethash source flex-content-min-widths missing) + missing))) + (ebox-native-reflow--compile-flex-item + item axis + (ebox-native-reflow--compiled-scene-child fragments source) + t content-min (not (eq content-min missing)))) + (ebox-native-reflow--compile-flex-item item axis))) + items))))) (defun ebox-native-reflow--compile-flex (node &optional fragments flex-content-min-widths) - "Compile normalized flex NODE, translating its wrapper to a box node." + "Compile normalized legacy flex NODE through the shared Flex IR builder." (let* ((wrapper (plist-get node :box)) (props (ebox--flex-container-content-props (plist-get node :props) (plist-get node :raw-props) - wrapper)) - (_dynamic-wrapper-size - (when wrapper - ;; The surrounding native box supplies its resolved content width - ;; to the child for each frame. Do not freeze the compiler's - ;; ambient viewport into a multi-frame layout document. - (plist-put props :width nil) - (when (plist-member (plist-get node :raw-props) :height) - (plist-put props :height 'viewport-height)))) - (axis (ebox--flex-axis props)) - (inner - (list - :type "flex" - :direction - (ebox-native-reflow--symbol-name - (plist-get props :flex-direction) 'row - '(row row-reverse column column-reverse) "flex-direction") - :wrap - (ebox-native-reflow--symbol-name - (plist-get props :flex-wrap) 'nowrap - '(nowrap wrap wrap-reverse) "flex-wrap") - :justify - (ebox-native-reflow--compile-flex-align - (plist-get props :justify-content) 'flex-start - "justify-content") - :align-items - (ebox-native-reflow--compile-flex-align - (plist-get props :align-items) 'stretch "align-items") - :align-content - (ebox-native-reflow--compile-flex-align - (plist-get props :align-content) 'stretch "align-content") - :width - (ebox-native-reflow--compile-size - (plist-get props :width) 'horizontal "auto") - :height - (ebox-native-reflow--compile-size - (plist-get props :height) 'vertical "auto") - :row-gap - (or (ebox--flex-line-value (plist-get props :row-gap) 0) 0) - :column-gap - (or (ebox--horizontal-size-pixels - (plist-get props :column-gap) 0) - 0) - :items - (vconcat - (mapcar (lambda (item) - (if fragments - (let* ((source (ebox--flex-item-source-node item)) - (missing (make-symbol "missing-content-min")) - (content-min - (if flex-content-min-widths - (gethash source - flex-content-min-widths - missing) - missing))) - (ebox-native-reflow--compile-flex-item - item axis - (ebox-native-reflow--compiled-scene-child - fragments source) - t - content-min - (not (eq content-min missing)))) - (ebox-native-reflow--compile-flex-item item axis))) - (ebox-tree-layout-children node)))))) - (if wrapper - (ebox-native-reflow--compile-box wrapper inner t) - inner))) + wrapper))) + (when wrapper + ;; The surrounding native box supplies its resolved content width to the + ;; child for each frame. Do not freeze the compiler's ambient viewport. + (plist-put props :width nil) + (when (plist-member (plist-get node :raw-props) :height) + (plist-put props :height 'viewport-height))) + (let ((inner + (ebox-native-reflow--compile-flex-inner + props (ebox-tree-layout-children node) + fragments flex-content-min-widths))) + (if wrapper + (ebox-native-reflow--compile-box wrapper inner t) + inner)))) (defun ebox-native-reflow--compile-node (node) "Compile supported normalized Ebox NODE to strict layout IR." (pcase (and (listp node) (plist-get node :ebox-type)) - ('box (ebox-native-reflow--compile-box node)) + ('box (if (eq (plist-get node :ebox-kind) 'text) + (ebox-native-reflow--compile-text-node node) + (ebox-native-reflow--compile-box node))) ('concat (list :type "row" :children @@ -1899,7 +2082,6 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame." (vconcat (mapcar #'ebox-native-reflow--compile-node (ebox--layout-children node))))) ('flex (ebox-native-reflow--compile-flex node)) - ('flex-item (ebox-native-reflow--compile-node (plist-get node :node))) (_ (error "Native reflow unsupported node type: %S" (and (listp node) (plist-get node :ebox-type)))))) @@ -1912,9 +2094,21 @@ backend has a matching two-dimensional layout contract." ((plist-get node :ebox-child-sequence) nil) ((eq (plist-get node :ebox-type) 'grid) nil) ((eq (plist-get node :ebox-type) 'box) - (and (not (plist-member node :children)) - (ebox-native-reflow--native-node-supported-p - (plist-get node :ebox-content-node)))) + (if-let* ((config (and (eq (plist-get node :ebox-kind) 'box) + (plist-get node :ebox-layout-config)))) + (let ((kind (ebox-layout-config-kind config)) + (children (ebox-tree-node-children node))) + (and + (pcase kind + ('normal (<= (length children) 1)) + ((or 'row 'column) + t) + ('flex t) + (_ nil)) + (cl-every #'ebox-native-reflow--native-node-supported-p children))) + (and (not (plist-member node :children)) + (ebox-native-reflow--native-node-supported-p + (plist-get node :ebox-content-node))))) ((memq (plist-get node :ebox-type) '(concat stack)) (cl-every #'ebox-native-reflow--native-node-supported-p (ebox--layout-children node))) @@ -1923,16 +2117,40 @@ backend has a matching two-dimensional layout contract." (append (when-let* ((box (plist-get node :box))) (list box)) (ebox-tree-layout-children node)))) - ((eq (plist-get node :ebox-type) 'flex-item) - (ebox-native-reflow--native-node-supported-p (plist-get node :node))) (t nil))) +(defun ebox-native-reflow--compile-typed-scene-child + (box fragments flex-content-min-widths) + "Compile typed BOX child layout from already compiled FRAGMENTS." + (let* ((config (plist-get box :ebox-layout-config)) + (kind (ebox-layout-config-kind config)) + (children (ebox-tree-node-children box))) + (pcase kind + ('normal + (and children + (ebox-native-reflow--compiled-scene-child + fragments (car children)))) + ((or 'row 'column) + (ebox-native-reflow--compile-typed-axis-children + kind config children fragments)) + ('flex + (ebox-native-reflow--compile-flex-inner + (copy-sequence (ebox-layout-config-props config)) + children fragments flex-content-min-widths)) + (_ (error "Native reflow unsupported typed scene LayoutConfig: %S" + kind))))) + (defun ebox-native-reflow--compiled-scene-child (fragments node) "Return NODE's compiled fragment from FRAGMENTS, failing closed." (let ((missing (make-symbol "missing-native-scene-fragment"))) (let ((fragment (gethash node fragments missing))) (when (eq fragment missing) - (error "Native render scene is missing a child fragment")) + (error "Native render scene is missing child node %S (%S/%S)" + (plist-get node :node-id) + (plist-get node :ebox-kind) + (and (plist-get node :ebox-layout-config) + (ebox-layout-config-kind + (plist-get node :ebox-layout-config))))) fragment))) (defun ebox-native-reflow--compile-scene-node @@ -1940,12 +2158,20 @@ backend has a matching two-dimensional layout contract." "Compile one flat scene NODE from already compiled FRAGMENTS." (pcase (and (listp node) (plist-get node :ebox-type)) ('box - (let ((child (plist-get node :ebox-content-node))) - (ebox-native-reflow--compile-box - node - (and child - (ebox-native-reflow--compiled-scene-child fragments child)) - nil t))) + (if (eq (plist-get node :ebox-kind) 'text) + (ebox-native-reflow--compile-text-node node) + (if (eq (plist-get node :ebox-kind) 'box) + (ebox-native-reflow--compile-box + node + (ebox-native-reflow--compile-typed-scene-child + node fragments flex-content-min-widths) + nil t) + (let ((child (plist-get node :ebox-content-node))) + (ebox-native-reflow--compile-box + node + (and child + (ebox-native-reflow--compiled-scene-child fragments child)) + nil t))))) ('concat (list :type "row" :children @@ -1965,9 +2191,6 @@ backend has a matching two-dimensional layout contract." ('flex (ebox-native-reflow--compile-flex node fragments flex-content-min-widths)) - ('flex-item - (ebox-native-reflow--compiled-scene-child - fragments (plist-get node :node))) (_ (error "Native reflow unsupported scene node type: %S" (and (listp node) (plist-get node :ebox-type)))))) @@ -2153,12 +2376,13 @@ per call, and no call recursively visits the captured Ebox tree." (defun ebox-native-reflow--retained-layout-children (node) "Return NODE children referenced by one compiled native fragment." (pcase (plist-get node :ebox-type) - ('box (when-let* ((child (plist-get node :ebox-content-node))) - (list child))) + ('box + (if (eq (plist-get node :ebox-kind) 'box) + (ebox-tree-node-children node) + (when-let* ((child (plist-get node :ebox-content-node))) + (list child)))) ((or 'concat 'stack) (ebox--layout-children node)) - ('flex - (mapcar #'ebox--flex-item-source-node - (ebox-tree-layout-children node))) + ('flex (ebox-tree-layout-children node)) (_ nil))) (defun ebox-native-reflow--retained-layout-postorder (root) @@ -2169,8 +2393,7 @@ per call, and no call recursively visits the captured Ebox tree." (dolist (child (ebox-native-reflow--retained-layout-children node)) (visit child)) - (unless (eq (plist-get node :ebox-type) 'flex-item) - (push node nodes)))) + (push node nodes))) (visit root)) (vconcat (nreverse nodes)))) @@ -2214,7 +2437,8 @@ per call, and no call recursively visits the captured Ebox tree." (touched-ids (plist-get state :native-touched-node-ids)) (touched-set (and (plist-get state :native-topology-stable-p) - (consp touched-ids) + (plist-member state :native-touched-node-ids) + (proper-list-p touched-ids) (make-hash-table :test 'equal))) (retained-fast-p (and touched-set @@ -3646,8 +3870,11 @@ metadata together with the text." (let ((style (aref styles style-id))) (pcase (plist-get style :mode) ('set (setq faces (list (plist-get style :face)))) - ('add (setq faces - (append faces (list (plist-get style :face))))) + ('add + (let ((face (plist-get style :face))) + (unless (and (= (length faces) 1) + (equal (car faces) face)) + (setq faces (append faces (list face)))))) (_ (error "Native reflow frame has an invalid style mode"))))))) (defun ebox-native-reflow--fragment-face-baseline diff --git a/ebox-node-factory.el b/ebox-node-factory.el index 36b3dee..300e9f3 100644 --- a/ebox-node-factory.el +++ b/ebox-node-factory.el @@ -31,6 +31,29 @@ :wrap-mode word :scroll-offset 0) "Default longhand fields for the current runtime box encoding.") +(defconst ebox-node-factory--text-longhand + '(:content "" + :overflow visible + :wrap-mode word) + "Minimal runtime fields owned by canonical Text.") + +(defconst ebox-node-factory--box-longhand + '(:content "" + :box-sizing border-box + :min-width 0 :min-height 0 + :padding-left-pixel 0 :padding-right-pixel 0 + :padding-top-height 0 :padding-bottom-height 0 + :margin-left-pixel 0 :margin-right-pixel 0 + :margin-top-height 0 :margin-bottom-height 0 + :border-left-pixel 0 :border-right-pixel 0 + :text-align left :vertical-align top + :overflow scroll :visibility visible + :wrap-mode word :scroll-offset 0) + "Runtime-required defaults owned by canonical Box. +Nil paint and optional sizing fields remain sparse. Numeric frame insets are +materialized because every layout algorithm consumes them as definite values; +leaving them absent would make the canonical node violate the Box contract.") + (defun ebox--longhand-create (&optional plist) "Return a runtime box field plist overridden by PLIST." (let ((box (copy-sequence ebox--longhand))) @@ -40,23 +63,42 @@ (plist-put box key value))) box)) -;;;###autoload -(defun ebox-create (&rest plist) - "Create one legacy runtime box from PLIST. - -New framework integrations should use `ebox-text-create' and -`ebox-box-create'. This factory remains public during source-tree migration." - (let ((declarations (ebox-style-compile-declarations plist)) - (expanded (ebox-style-expand-ebox-plist plist)) - (controlled-scroll-offset-p (plist-member plist :scroll-offset))) - (let ((box (ebox--longhand-create expanded))) +(defun ebox-node-factory--create-expanded (expanded declarations) + "Create one runtime box from EXPANDED engine facts and DECLARATIONS." + (let ((controlled-scroll-offset-p + (plist-member expanded :scroll-offset))) + (let ((box (copy-sequence ebox-node-factory--box-longhand))) + (cl-loop for (property value) on expanded by #'cddr + do (plist-put box property value)) (plist-put box :ebox-type 'box) (plist-put box :display '(block flow)) - (plist-put box :ebox-style-declarations declarations) + (plist-put box :ebox-style-declarations (copy-sequence declarations)) (when controlled-scroll-offset-p (plist-put box :ebox-scroll-offset-controlled-p t)) box))) +(defun ebox-node-factory--create-text-expanded (expanded declarations) + "Create canonical Text storage from EXPANDED facts and DECLARATIONS." + (let ((node (copy-sequence ebox-node-factory--text-longhand))) + (cl-loop for (property value) on expanded by #'cddr + do (plist-put node property value)) + (plist-put node :ebox-type 'box) + (plist-put node :display '(inline flow)) + (plist-put node :ebox-style-declarations (copy-sequence declarations)) + node)) + +(defun ebox-node-factory--create (plist declarations) + "Create one runtime box from normalized PLIST and DECLARATIONS. +PLIST contains already-validated public engine inputs; DECLARATIONS are the +single canonical author fact set retained for style projection." + (ebox-node-factory--create-expanded + (ebox-style-expand-ebox-plist plist) declarations)) + +(defun ebox-node-factory--create-from-properties (&rest plist) + "Create one private runtime box from evaluated engine PLIST." + (ebox-node-factory--create + plist (ebox-style-compile-declarations plist))) + (provide 'ebox-node-factory) ;;; ebox-node-factory.el ends here diff --git a/ebox-render-context.el b/ebox-render-context.el index a58c21c..0e9e2a4 100644 --- a/ebox-render-context.el +++ b/ebox-render-context.el @@ -11,6 +11,8 @@ (defvar ebox-viewport-height nil) (defvar ebox--render-runtime-revision nil) (defvar ebox--surface-materialization-active nil) +(defvar ebox--render-root-parent-kind nil + "Formatting context owning the detached render root, or nil.") (defvar ebox--render-cache-table nil) (defvar ebox--render-cache-signature-cache nil) (defvar ebox--viewport-dependent-node-ids-cache nil) diff --git a/ebox-selector.el b/ebox-selector.el index 82d434d..2ceb51b 100644 --- a/ebox-selector.el +++ b/ebox-selector.el @@ -111,8 +111,6 @@ ('grid (when-let* ((box (plist-get node :box))) (ebox--ensure-region-id box))) - ('flex-item - (ebox-selector--node-region-id (plist-get node :node))) (_ nil))) ;;;###autoload diff --git a/ebox-style.el b/ebox-style.el index 8af23c7..5bc6d54 100644 --- a/ebox-style.el +++ b/ebox-style.el @@ -13,7 +13,8 @@ (defvar ebox--longhand) (declare-function ebox-selector-parse "ebox-selector" (selector)) -(declare-function ebox-create "ebox" (&rest plist)) +(declare-function ebox-node-factory--create-from-properties + "ebox-node-factory" (&rest plist)) (declare-function ebox--flex-normalize-container-props "ebox-flex" (props)) (declare-function ebox-grid--normalize-config-props @@ -156,8 +157,10 @@ :normalizer vertical-align) (:name :overflow :id ebox/overflow :initial scroll :contexts (box) :group structure :dirty-kind geometry :signature layout) - (:name :wrap-mode :id ebox/wrap-mode :initial word :contexts (box) - :group geometry :dirty-kind geometry :signature layout) + (:name :wrap-mode :id ebox/wrap-mode :initial word :inherits t + :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator wrap-mode) (:name :visibility :id ebox/visibility :initial visible :contexts (box) :group paint :dirty-kind paint :signature paint) (:name :display :id ebox/display :initial nil :contexts (node) @@ -303,6 +306,11 @@ every `ebox-create' needlessly copies the entire property metadata domain.") (when-let* ((property (ebox-style-property name))) (plist-get property :name))) +(defun ebox-style-property-accepted-p (tag name) + "Return non-nil when author form TAG accepts property NAME." + (when-let* ((property (ebox-style-property name))) + (ebox-style--form-property-allowed-p tag property))) + (defun ebox-style-schema-id (name) "Return namespaced ECSS schema id for Ebox property NAME." (when-let* ((property (ebox-style-property name))) @@ -614,6 +622,20 @@ every `ebox-create' needlessly copies the entire property metadata domain.") "Return non-nil when VALUE is a symbolic font attribute." (or (null value) (symbolp value))) +(defun ebox-style--wrap-mode-p (value) + "Return non-nil when VALUE is a public Box wrapping mode." + (memq value '(word char kp none))) + +(defun ebox-style-no-soft-wrap-p (value) + "Return non-nil when wrap VALUE disables soft line breaking. +`none' is the canonical author value; nil is retained only for private +generated wrappers that already contain exact line breaks." + (memq value '(nil none))) + +(defun ebox-style-soft-wrap-p (value) + "Return non-nil when wrap VALUE performs soft line breaking." + (memq value '(word char kp))) + (defun ebox-style--validator (name) "Return schema validator named NAME." (pcase name @@ -629,6 +651,7 @@ every `ebox-create' needlessly copies the entire property metadata domain.") ('font-family #'ebox-style--font-family-p) ('font-height #'ebox-style--font-height-p) ('font-attribute #'ebox-style--font-attribute-p) + ('wrap-mode #'ebox-style--wrap-mode-p) ('boolean (lambda (value) (memq value '(nil t)))) (_ (lambda (_value) t)))) @@ -673,6 +696,10 @@ every `ebox-create' needlessly copies the entire property metadata domain.") (defvar ebox-style--closed-computed-cache (make-hash-table :test #'equal) "Bounded cache of selector-free, dependency-free computed styles.") +(defvar ebox-style--closed-inheritance-cache + (make-hash-table :test #'eq :weakness 'key) + "Computed-style keyed canonical inheritance projections.") + (defconst ebox-style--closed-computed-cache-limit 512 "Maximum selector-free computed styles retained across projections.") @@ -690,6 +717,7 @@ every `ebox-create' needlessly copies the entire property metadata domain.") ebox-style-schemas (plist-get property :id) (ebox-style--schema-options property))) (clrhash ebox-style--closed-computed-cache) + (clrhash ebox-style--closed-inheritance-cache) (setq ebox-style--registered-schemas ebox-style-schemas)) (defun ebox-style--ensure-properties () @@ -729,6 +757,53 @@ cascade pass during its initial static projection." (and (symbolp property) (string-prefix-p "--" (symbol-name property)))) +(defun ebox-style--declaration-value-equal-p (left right) + "Return non-nil when declaration values LEFT and RIGHT are equivalent. +TP paint slots are stable paint addresses. Their installation flag and +current face specification belong to TP publication state, not to the Ebox +author declaration that references the slot." + (if (and (tp-paint-slot-p left) (tp-paint-slot-p right)) + (eq (tp-paint-slot-face left) (tp-paint-slot-face right)) + (equal-including-properties left right))) + +(defun ebox-style-declarations-equal-p (left right) + "Return non-nil when canonical declaration plists LEFT and RIGHT are equal." + (let ((left left) + (right right) + equal-p) + (setq equal-p t) + (while (and equal-p left right) + (setq equal-p + (and (eq (car left) (car right)) + (ebox-style--declaration-value-equal-p + (cadr left) (cadr right))) + left (cddr left) + right (cddr right))) + (and equal-p (null left) (null right)))) + +(defun ebox-style--inherited-declarations-changed-p (old new) + "Return non-nil when OLD and NEW change descendant style inputs. +The inherited property domain comes only from the canonical schema. ECSS +custom properties also participate because a descendant `var' expression may +consume them even though Ebox does not interpret their names." + (or + (cl-some + (lambda (property) + (let ((old-entry (plist-member old property)) + (new-entry (plist-member new property))) + (not (and (eq (not (null old-entry)) (not (null new-entry))) + (ebox-style--declaration-value-equal-p + (cadr old-entry) (cadr new-entry)))))) + ebox-style--inherited-properties) + (not + (ebox-style-declarations-equal-p + (cl-loop for (property value) on old by #'cddr + when (ebox-style--custom-property-p property) + append (list property value)) + (cl-loop for (property value) on new by #'cddr + when (ebox-style--custom-property-p property) + append (list property value)))))) + (defun ebox-style--valid-plist-p (plist) "Return non-nil when PLIST is a proper even-length list." (and (proper-list-p plist) (zerop (% (length plist) 2)))) @@ -816,7 +891,9 @@ not affect declaration compilation and are intentionally excluded." (and (numberp value) (not (ebox-style--positive-integer-p value)))) ('grid-placement (and (or (numberp value) (proper-list-p value)) - (not (ebox-style--grid-placement-p value)))))) + (not (ebox-style--grid-placement-p value)))) + ('wrap-mode + (not (ebox-style--wrap-mode-p value))))) (defun ebox-style--validate-declaration-values (declarations) "Return DECLARATIONS after static Ebox-owned value validation." @@ -860,6 +937,65 @@ When STRICT is non-nil, reject properties outside the Ebox style domain." (puthash cache-key compiled ebox-style--declaration-cache) (copy-sequence compiled)))))) +(defconst ebox-style--text-form-properties + '(:color :background-color + :font :font-family :font-height :font-weight :font-slant) + "Canonical property names accepted by the Text author form.") + +(defun ebox-style--form-property-allowed-p (tag property) + "Return non-nil when author TAG accepts PROPERTY metadata." + (let ((name (plist-get property :name)) + (contexts (plist-get property :contexts))) + (if (eq tag 'text) + (memq name ebox-style--text-form-properties) + (or (memq 'box contexts) + (memq 'item contexts) + (and (memq tag '(flex grid)) + (memq tag contexts)))))) + +(defun ebox-style-validate-form-declarations (tag declarations) + "Validate canonical DECLARATIONS for Text/Box author TAG." + (unless (memq tag '(text box row column flex grid)) + (error "Unknown Ebox author form: %S" tag)) + (unless (ebox-style--valid-plist-p declarations) + (error "Ebox canonical declarations must be an even plist: %S" + declarations)) + (let ((seen (make-hash-table :test #'eq))) + (cl-loop for (id _value) on declarations by #'cddr + for property = (ebox-style-property id) + unless (and property (eq id (plist-get property :id))) + do (error "Ebox declaration is not a canonical property ID: %S" + id) + do (when (gethash id seen) + (error "Duplicate canonical Ebox declaration: %S" id)) + do (puthash id t seen) + unless (ebox-style--form-property-allowed-p tag property) + do (error "Ebox %S does not accept %S" + tag (plist-get property :name)))) + (ebox-style--validate-declaration-values declarations) + declarations) + +(defun ebox-style-compile-form (tag plist) + "Compile evaluated author PLIST once and validate it for TAG." + (ebox-style-validate-form-declarations + tag (ebox-style-compile-declarations plist t))) + +(defun ebox-style-declaration-properties (declarations predicate) + "Return canonical author properties in DECLARATIONS matching PREDICATE." + (cl-loop for (id value) on declarations by #'cddr + for property = (or (ebox-style-property id) + (error "Unknown canonical Ebox property: %S" id)) + when (funcall predicate property) + append (list (plist-get property :name) value))) + +(defun ebox-style-declarations-require-projection-p + (declarations predicate) + "Return non-nil when DECLARATIONS include facts outside PREDICATE." + (cl-loop for (id _value) on declarations by #'cddr + for property = (or (ebox-style-property id) + (error "Unknown canonical Ebox property: %S" id)) + thereis (not (funcall predicate property)))) + (defun ebox-style-merge-declarations (base overrides) "Merge canonical BASE and OVERRIDES through ECSS property schemas." (ebox-style--ensure-properties) @@ -949,9 +1085,10 @@ winner is required so parent inherited geometry/paint cannot be stale." (plist-member new 'ebox/color))) (defconst ebox-style--inherited-properties - '(ebox/color ebox/font ebox/font-family ebox/font-height - ebox/font-weight ebox/font-slant) - "Inherited ECSS properties whose parent values guard Theme deltas.") + (cl-loop for property in ebox-style--property-definitions + when (plist-get property :inherits) + collect (plist-get property :id)) + "Canonical ECSS properties inherited according to the shared schema.") (defun ebox-style--inherited-style-signature (style) "Return the inherited-value fingerprint of computed STYLE, or nil. @@ -1165,13 +1302,25 @@ SNAPSHOT reuses a previously detached ECSS values snapshot when supplied." (ecss-computed-style-specified-p style property)) (defun ebox-style-node-declarations (node) - "Return canonical ECSS declarations stored on semantic Ebox NODE." + "Return NODE's immutable canonical ECSS declarations. +Callers that need to mutate the declaration spine must copy it first." (let ((declarations (plist-get node :ebox-style-declarations))) (if (memq (plist-get node :ebox-type) '(flex grid)) (ebox-style-merge-declarations declarations (plist-get (plist-get node :box) :ebox-style-overrides)) - (copy-sequence declarations)))) + declarations))) + +(defun ebox-style-node-specified-value (node name &optional missing) + "Return NODE's canonical specified value for author property NAME. +Return MISSING when NAME is valid but not specified. Aliases resolve to the +same canonical property id, so callers never inspect backend plist fields." + (let ((id (or (ebox-style-schema-id name) + (error "Unknown Ebox property: %S" name))) + (declarations (ebox-style-node-declarations node))) + (if (plist-member declarations id) + (plist-get declarations id) + missing))) (defun ebox-style--remove-style-properties (plist) "Return PLIST without Ebox style or ECSS custom properties." @@ -1195,6 +1344,38 @@ SNAPSHOT reuses a previously detached ECSS values snapshot when supplied." do (plist-put box property value))) box) +(defvar ebox-style--box-engine-longhand-cache + (make-hash-table :test #'eq :weakness 'key) + "Computed-style keyed expanded engine longhands for canonical Box nodes.") + +(defvar ebox-style--text-engine-longhand-cache + (make-hash-table :test #'eq :weakness 'key) + "Computed-style keyed expanded engine longhands for canonical Text nodes.") + +(defun ebox-style--cached-engine-longhands (style text-p snapshot) + "Return STYLE's expanded engine longhands for TEXT-P and SNAPSHOT." + (let* ((cache (if text-p + ebox-style--text-engine-longhand-cache + ebox-style--box-engine-longhand-cache)) + (missing (make-symbol "ebox-engine-longhands-missing")) + (cached (gethash style cache missing))) + (if (not (eq cached missing)) + cached + (let ((expanded + (ebox-style-expand-ebox-plist + (if text-p + (ebox-style--text-values style snapshot) + (ebox-style--box-values style snapshot))))) + (puthash style expanded cache) + expanded)))) + +(defun ebox-style--apply-engine-longhands (box longhands) + "Project already expanded LONGHANDS into BOX engine fields." + (ebox-style--reset-box-engine-style box) + (cl-loop for (property value) on longhands by #'cddr + do (plist-put box property value)) + box) + (defun ebox-style--box-values (style &optional snapshot) "Return computed box values ready for Ebox engine projection." (let* ((snapshot (or snapshot (ebox-style--computed-snapshot style))) @@ -1205,6 +1386,47 @@ SNAPSHOT reuses a previously detached ECSS values snapshot when supplied." (plist-put values :color ebox-style--default-foreground))) values)) +(defun ebox-style--text-values (style snapshot) + "Return computed STYLE values owned by canonical Text." + (cl-loop for (name value) on (ebox-style--box-values style snapshot) + by #'cddr + when (or (ebox-style-property-accepted-p 'text name) + (eq name :wrap-mode)) + append (list name value))) + +(defun ebox-style--closed-inheritance-values (style) + "Return STYLE's immutable canonical inheritance projection." + (let* ((missing (make-symbol "ebox-inheritance-missing")) + (cached (gethash style ebox-style--closed-inheritance-cache missing))) + (if (not (eq cached missing)) + cached + (let* ((snapshot (ebox-style--computed-snapshot style)) + (values (ebox-style--box-values style snapshot)) + result) + (dolist (property '(:color :font :font-family :font-height + :font-weight :font-slant :wrap-mode)) + (when (plist-member values property) + (setq result + (plist-put result property (plist-get values property))))) + (puthash style result ebox-style--closed-inheritance-cache) + result)))) + +(defun ebox-style-apply-closed-inheritance (node style) + "Apply only closed computed inheritance from STYLE to fresh canonical NODE." + (let ((values (ebox-style--closed-inheritance-values style))) + (plist-put node :ebox-computed-style style) + (cl-loop for (property value) on values by #'cddr + do (plist-put node property value))) + node) + +(defun ebox-style--text-paint-owner-p (node) + "Return non-nil when NODE materializes inherited text paint itself. +Canonical Text owns typography and foreground paint. Canonical Box retains +those computed facts for inheritance but materializes only box paint such as +background and borders. Legacy runtime boxes keep their established behavior +until their private encoding is removed." + (not (eq (plist-get node :ebox-kind) 'box))) + (defconst ebox-style--container-wrapper-neutral-properties '(:width :height :box-sizing :color :font :font-family :font-height :font-weight :font-slant) @@ -1221,8 +1443,9 @@ SNAPSHOT reuses a previously detached ECSS values snapshot when supplied." (defun ebox-style--new-container-wrapper () "Return a stable internal box for container-owned box paint." - (let ((wrapper (ebox-create :content "" :wrap-mode nil - :key 'ebox/style-wrapper))) + (let ((wrapper + (ebox-node-factory--create-from-properties + :content "" :wrap-mode nil :key 'ebox/style-wrapper))) (plist-put wrapper :ebox-style-wrapper t) (plist-put wrapper :ebox-style-generated-wrapper t) wrapper)) @@ -1349,32 +1572,16 @@ SNAPSHOT reuses a previously detached ECSS values snapshot when supplied." (ebox-style--apply-display-axes node style snapshot) (pcase type ('box - (ebox-style--apply-engine-values + (ebox-style--apply-engine-longhands node - (ebox-style--box-values style snapshot))) + (ebox-style--cached-engine-longhands + style (eq (plist-get node :ebox-kind) 'text) snapshot)) + ) ((or 'flex 'grid) (ebox-style--apply-container node style snapshot))) (ebox-style--apply-item node style snapshot)) node) -(defun ebox-style-sync-flex-item (item) - "Synchronize internal Flex ITEM props from its semantic source style." - (when (eq (plist-get item :ebox-type) 'flex-item) - (when-let* ((source (plist-get item :node))) - (let* ((source-style (plist-get source :ebox-computed-style)) - (child-style - (when-let* ((child (plist-get source :ebox-content-node))) - (plist-get child :ebox-computed-style))) - (values - (append - (and source-style - (ebox-style--context-values source-style 'item)) - (and child-style - (ebox-style--context-values child-style 'item))))) - (when (or source-style child-style) - (plist-put item :props values))))) - item) - (defun ebox-style-dirty-kind (name) "Return dirty kind for canonical property or alias NAME." (when-let* ((property (ebox-style-property name))) diff --git a/ebox-surface.el b/ebox-surface.el index 2a33507..cd9fada 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -49,15 +49,56 @@ (defvar ebox-surface--inline-style-value-cache nil "Per-projection cache for selector-free computed inline styles.") +(defun ebox-surface--mixed-range-splice-p (state) + "Return non-nil when STATE's mixed proof contains one Range splice." + (cl-some + (lambda (owner-proof) + (plist-get owner-proof :range-splice-p)) + (plist-get + (plist-get (plist-get state :mixed-owner-proof) :geometry-proof) + :owner-proofs))) + (declare-function ebox--render-layout "ebox-layout" (node)) +(declare-function ebox--maplines "ebox" (function string)) (declare-function ebox--ensure-node-id "ebox" (node)) (declare-function ebox-native-commit-render "ebox-native-commit" (state node)) (declare-function ebox-native-reflow-frame-fragments "ebox-native-reflow" (frame)) +(declare-function ebox-native-reflow-layout-ready-p + "ebox-native-reflow" ()) +(declare-function ebox-native-reflow-release-session + "ebox-native-reflow" (session)) (declare-function ebox-native-commit-projection-eligible-p "ebox-native-commit" (previous-state state-overrides stylesheet-active-p)) + +(defun ebox-surface--release-native-session (session) + "Release native SESSION when it is present." + (when (and session (require 'ebox-native-reflow nil t)) + (ebox-native-reflow-release-session session))) + +(defun ebox-surface--settle-native-session + (old-state state-overrides surface committed-p) + "Retire the losing native session after one candidate publication. +OLD-STATE owns the committed session before publication. STATE-OVERRIDES +owns the isolated candidate. When COMMITTED-P is non-nil, SURFACE identifies +the winning client state; otherwise only the candidate is disposable." + (let ((old-session (plist-get old-state :native-sync-session)) + (candidate-session + (plist-get state-overrides :native-sync-session)) + (committed-session + (and committed-p surface + (plist-get (tp-surface-client-state surface) + :native-sync-session)))) + (if committed-p + (dolist (session + (delete-dups (delq nil (list old-session candidate-session)))) + (unless (eq session committed-session) + (ebox-surface--release-native-session session))) + (when (and candidate-session + (not (eq candidate-session old-session))) + (ebox-surface--release-native-session candidate-session))))) (declare-function tp-object-reuse "tp-surface" (context object)) (declare-function tp-object-ensure-at "tp-surface" (context parent key kind position)) @@ -237,15 +278,22 @@ Scoped rules remain tree-dependent because scope distance reads ancestors." "Stable TP key for the shared rendered text leaf.") (defconst ebox-surface--inherited-style-properties - '(ebox/color ebox/font ebox/font-family ebox/font-height - ebox/font-weight ebox/font-slant) + ebox-style--inherited-properties "ECSS properties whose inline values can flow to descendants.") (defun ebox-surface--style-consuming-node-p (node) "Return non-nil when NODE owns a paintable style surface." (and (eq (plist-get node :ebox-type) 'box) - (null (ebox-tree-node-children node)) - (> (length (or (plist-get node :content) "")) 0))) + (not (eq (plist-get node :ebox-kind) 'text)))) + +(defun ebox-surface--inherited-style-consumer-p (node) + "Return non-nil when NODE materializes inherited text style. +Canonical Box passes inherited facts through to descendants; canonical Text +is their final paint/typography owner. A legacy Box without a canonical kind +continues to own its inline text directly." + (and (eq (plist-get node :ebox-type) 'box) + (or (eq (plist-get node :ebox-kind) 'text) + (null (plist-get node :ebox-kind))))) (defun ebox-surface--inline-inheritance-required-p (root) "Return non-nil when inline styles in ROOT require an ECSS cascade. @@ -261,7 +309,7 @@ construction time and can use the static projection path." (inherited (cdr entry)) (declarations (ebox-style-node-declarations node))) (when (cl-some (lambda (property) - (and (ebox-surface--style-consuming-node-p node) + (and (ebox-surface--inherited-style-consumer-p node) (memq property inherited) (not (plist-member declarations property)))) ebox-surface--inherited-style-properties) @@ -271,8 +319,8 @@ construction time and can use the static projection path." (dolist (property ebox-surface--inherited-style-properties) (when (plist-member declarations property) (cl-pushnew property next))) - (dolist (child (ebox-tree-node-children node)) - (push (cons child next) pending)))))) + (ebox-tree-for-each-direct-child + node (lambda (child) (push (cons child next) pending))))))) required)) (defun ebox-surface--signals-live-p (signals) @@ -758,6 +806,8 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil." (ebox-incremental--finalize-declarative-scroll-publication scroll-keys))) surface) + (ebox-surface--settle-native-session + old-state state-overrides surface success) (when (and signals-created-p (not success)) (ebox-surface--dispose-signals signals))))) @@ -830,6 +880,9 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil." (plist-get state :display-signature)))) (width-changed (not (equal width old-width))) (height-changed (not (equal target-height old-height))) + (style-context-stable-p + (equal (ebox-surface--stylesheet-signature) + (plist-get state :stylesheet-signature))) (axes (cond ((and width-changed height-changed) 'both) (width-changed 'width) (height-changed 'height) @@ -843,9 +896,10 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil." (ebox-incremental--notify-before-runtime-mutation buffer 'viewport) (unless (eq state (tp-surface-client-state surface)) (error "Ebox runtime changed during viewport update notification")) - (let ((commit-input + (let ((commit-input (ebox-incremental-prepare-viewport-commit - buffer width target-height axes display-signature))) + buffer width target-height axes display-signature + style-context-stable-p))) (if (plist-get commit-input :no-op) (let* ((state-overrides (plist-get commit-input :state-overrides)) @@ -884,13 +938,16 @@ PREVIOUS-STATE's node-object table." (node-root (tp-object-ensure context surface-root ebox-surface--nodes-key 'ebox/nodes)) + (range-splice-p + (and (eq projection-kind 'mixed-owner-reflow) + (ebox-surface--mixed-range-splice-p state-overrides))) (objects-by-node (if (memq projection-kind '(span-patch owner-scoped scroll-patch formatting-context-reflow mixed-owner-reflow viewport-reflow viewport-reflow-mixed-scroll native-frame)) - (if (eq projection-kind 'native-frame) + (if (or (eq projection-kind 'native-frame) range-splice-p) (make-hash-table :test 'eq) (or (plist-get previous-state :surface-node-object-table) (error "Ebox span projection has no retained node table"))) @@ -903,9 +960,10 @@ PREVIOUS-STATE's node-object table." (make-hash-table :test 'equal))) (stylesheet-signature (and style-required-p - (if (memq projection-kind + (if (and (memq projection-kind '(owner-scoped scroll-patch formatting-context-reflow mixed-owner-reflow)) + (not range-splice-p)) nil (ebox-surface--stylesheet-signature)))) (stylesheet-subject-local-p @@ -918,23 +976,26 @@ PREVIOUS-STATE's node-object table." (not (and (eq projection-kind 'native-frame) (plist-get state-overrides :native-topology-stable-p))) - (not (memq projection-kind + (not (and (memq projection-kind '(owner-scoped scroll-patch formatting-context-reflow - mixed-owner-reflow)))) + mixed-owner-reflow)) + (not range-splice-p)))) (ebox-tree-subject-index root))) (selector-tree-snapshot - (if (memq projection-kind + (if (and (memq projection-kind '(owner-scoped scroll-patch formatting-context-reflow mixed-owner-reflow native-frame)) + (not range-splice-p)) (plist-get previous-state :selector-tree-snapshot) (and subjects (ebox-surface--subject-signature (plist-get subjects :root-subject))))) (selector-tree-token - (if (memq projection-kind - '(owner-scoped scroll-patch native-frame)) + (if (and (memq projection-kind + '(owner-scoped scroll-patch native-frame)) + (not range-splice-p)) (plist-get previous-state :selector-tree-token) (and selector-tree-snapshot (if (and (equal selector-tree-snapshot @@ -943,27 +1004,37 @@ PREVIOUS-STATE's node-object table." (plist-get previous-state :selector-tree-token)) (plist-get previous-state :selector-tree-token) (cons 'ebox/selector-tree nil)))))) - (if (eq projection-kind 'native-frame) - (ebox-surface--project-native-node-delta + (cond + ((eq projection-kind 'native-frame) + (ebox-surface--project-node-delta context root previous-state state-overrides node-root objects-by-node (and subjects (plist-get subjects :node-subject-table)) bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token - stylesheet-subject-local-p) - (if (memq projection-kind + stylesheet-subject-local-p)) + (range-splice-p + (ebox-surface--project-node-delta + context root previous-state state-overrides node-root objects-by-node + (and subjects (plist-get subjects :node-subject-table)) + bindings-by-subject states-by-subject style-state-table + stylesheet-signature selector-tree-token + stylesheet-subject-local-p)) + ((memq projection-kind '(span-patch owner-scoped scroll-patch formatting-context-reflow mixed-owner-reflow viewport-reflow viewport-reflow-mixed-scroll)) - (tp-object-reuse-subtree context node-root) - (progn - (tp-object-retain context node-root) - (ebox-surface--ensure-node-tree - context node-root root objects-by-node - (and subjects (plist-get subjects :node-subject-table)) - bindings-by-subject states-by-subject style-state-table - stylesheet-signature - selector-tree-token stylesheet-subject-local-p - ebox--render-root-parent-kind)))) + (tp-object-reuse-subtree context node-root)) + (t + (tp-object-retain context node-root) + (ebox-surface--ensure-node-tree + context node-root root objects-by-node + (and subjects (plist-get subjects :node-subject-table)) + bindings-by-subject states-by-subject style-state-table + stylesheet-signature + selector-tree-token stylesheet-subject-local-p + ebox--render-root-parent-kind))) + (when (and subjects (> (ebox-tree-author-style-count root) 0)) + (ebox-tree-clear-author-style-pending root)) (list :surface-root surface-root :node-root node-root :objects-by-node objects-by-node @@ -972,7 +1043,7 @@ PREVIOUS-STATE's node-object table." :selector-tree-snapshot selector-tree-snapshot :selector-tree-token selector-tree-token))) -(defun ebox-surface--project-native-stable-node-table +(defun ebox-surface--project-stable-node-table (context nodes node-root old-objects objects-by-node) "Reuse native NODES whose object topology is proven unchanged." (tp-object-reuse-subtree context node-root) @@ -986,7 +1057,7 @@ PREVIOUS-STATE's node-object table." nodes) objects-by-node) -(defun ebox-surface--project-native-node-delta +(defun ebox-surface--project-node-delta (context root previous-state state-overrides node-root objects-by-node subject-table bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token @@ -1004,7 +1075,7 @@ PREVIOUS-STATE's node-object table." touched-ids)) (error "Ebox native projection has no candidate object delta")) (if (plist-get state-overrides :native-topology-stable-p) - (ebox-surface--project-native-stable-node-table + (ebox-surface--project-stable-node-table context nodes node-root old-objects objects-by-node) (tp-object-retain context node-root) (dolist (node-id touched-ids) (puthash node-id t touched)) @@ -1053,14 +1124,15 @@ PREVIOUS-STATE's node-object table." (ebox-surface--apply-node-style object node subject-table bindings-by-subject states-by-subject style-state-table stylesheet-signature - selector-tree-token stylesheet-subject-local-p) - (ebox-style-sync-flex-item node))) + selector-tree-token stylesheet-subject-local-p))) (plist-put node :surface-object object) (puthash node object objects-by-node) (puthash node-id object objects-by-id)))) (unless (gethash (plist-get root :node-id) objects-by-id) (error "Ebox native projection lost its root object")) - (when subject-table + (when (and subject-table + (not (plist-get state-overrides + :computed-participation-validated-p))) (ebox-tree-validate-indexed-participation nodes parents touched-ids 'computed))) objects-by-node)) @@ -1097,6 +1169,8 @@ the projection roots a second time." stylesheet-signature selector-tree-token stylesheet-subject-local-p ebox--render-root-parent-kind) + (when (> (ebox-tree-author-style-count root) 0) + (ebox-tree-clear-author-style-pending root)) projection)) (defun ebox-surface--projection-result @@ -1116,6 +1190,8 @@ the projection roots a second time." (native-retained-p (and (eq projection-kind 'native-frame) (plist-get state :native-topology-stable-p))) + (previous-owned-ranges + (plist-get state :previous-surface-owned-ranges)) (node-objects (or (and scroll-fast-p (plist-get state :surface-node-object-table)) @@ -1137,7 +1213,14 @@ the projection roots a second time." (or (and native-retained-p (ebox-surface--native-patch-result context projection state output node-objects region-objects)) - (let* ((prepared + (and (memq projection-kind + '(span-patch owner-scoped mixed-owner-reflow)) + (ebox-surface--span-coordinate-result + context projection state output previous-owned-ranges)) + (let* ((range-splice-p + (and (eq projection-kind 'mixed-owner-reflow) + (ebox-surface--mixed-range-splice-p state))) + (prepared (ebox-surface--surface-plan context surface-root output state node-objects region-objects scroll-fast-p)) @@ -1146,11 +1229,18 @@ the projection roots a second time." (owned-ranges (nth 2 prepared)) (property-contributions (plist-get state :paint-property-contributions))) - (if (or full-surface-p native-retained-p property-contributions) - (tp-surface-retained-content-result-create - context plan rendered owned-ranges state - full-surface-p property-contributions) - (tp-surface-result-create-owned context plan state)))))) + (or (and (memq projection-kind + '(span-patch owner-scoped mixed-owner-reflow)) + (ebox-surface--coordinate-commit + context state rendered owned-ranges (nth 3 prepared) + previous-owned-ranges)) + (if (and (not range-splice-p) + (or full-surface-p native-retained-p + property-contributions)) + (tp-surface-retained-content-result-create + context plan rendered owned-ranges state + full-surface-p property-contributions) + (tp-surface-result-create-owned context plan state))))))) (defun ebox-surface--native-patch-result (context projection state rendered node-objects region-objects) @@ -1257,6 +1347,169 @@ content projection order; fragment and surface records follow unchanged." (ebox-surface--commit-mount-specs mount-records owned-ranges) :client-state state)))))) +(defun ebox-surface--coordinate-commit + (context state rendered owned-ranges mount-records previous-owned-ranges) + "Return STATE's exact proven-owner coordinate commit, or nil. +PREVIOUS-OWNED-RANGES identify the committed owner intervals. The mixed +projection has already produced exact coordinate patches and final text; this +function composes those retained facts into TP's standard validated commit +batch without asking TP to rediscover the same diff from a full plan." + (let* ((proof (plist-get state :mixed-owner-proof)) + (owner-ids + (or (plist-get proof :owner-ids) + (mapcar (lambda (owner-proof) + (plist-get owner-proof :owner-id)) + (plist-get state :owner-scoped-proofs)) + (and (plist-get state :span-patch-owner-id) + (list (plist-get state :span-patch-owner-id))))) + (objects (plist-get state :surface-node-object-table)) + (coordinate-patches (plist-get state :content-coordinate-patches)) + (target-extent (and (stringp rendered) (length rendered))) + (source-extent (plist-get state :content-base-extent)) + owner-set intervals) + (when (and owner-ids (hash-table-p objects) coordinate-patches + (integerp source-extent) (integerp target-extent) + previous-owned-ranges) + (setq owner-set (make-hash-table :test #'eq)) + (dolist (owner-id owner-ids) + (when-let* ((object (gethash owner-id objects))) + (puthash object t owner-set))) + ;; Coordinate patches are the authoritative old-to-new mapping for + ;; changed content. Owner mounts supplement that mapping with + ;; property-only intervals (for example a button whose enabled face + ;; changes after a preceding Range grows). Starting from owner mounts + ;; alone loses every boundary inside a replacement by design, leaving + ;; later intervals shifted without the patch that introduced the shift. + (setq intervals + (mapcar + (lambda (patch) + (list :old-start (plist-get patch :old-start) + :old-end (plist-get patch :old-end) + :new-start (plist-get patch :new-start) + :new-end (plist-get patch :new-end))) + coordinate-patches)) + (dolist (range previous-owned-ranges) + (when (gethash (plist-get range :object) owner-set) + (when-let* ((new-start + (ebox-surface--rebase-coordinate + (plist-get range :start) coordinate-patches)) + (new-end + (ebox-surface--rebase-coordinate + (plist-get range :end) coordinate-patches))) + (push (list :old-start (plist-get range :start) + :old-end (plist-get range :end) + :new-start (car new-start) + :new-end (car new-end)) + intervals)))) + (setq intervals + (sort intervals + (lambda (left right) + (< (plist-get left :old-start) + (plist-get right :old-start))))) + (let (coalesced) + (dolist (interval intervals) + (let ((previous (car coalesced))) + (if (and previous + (<= (plist-get interval :old-start) + (plist-get previous :old-end)) + (<= (plist-get interval :new-start) + (plist-get previous :new-end))) + (progn + (plist-put previous :old-end + (max (plist-get previous :old-end) + (plist-get interval :old-end))) + (plist-put previous :new-end + (max (plist-get previous :new-end) + (plist-get interval :new-end)))) + (push (copy-sequence interval) coalesced)))) + (setq intervals (nreverse coalesced))) + (when intervals + (let* ((patches + (mapcar + (lambda (interval) + (append + interval + (list :replacement + (substring rendered + (plist-get interval :new-start) + (plist-get interval :new-end))))) + intervals)) + (delta + (cl-loop for patch in intervals + sum (- (- (plist-get patch :new-end) + (plist-get patch :new-start)) + (- (plist-get patch :old-end) + (plist-get patch :old-start))))) + (derived-base-extent (- target-extent delta)) + (base-revision (plist-get state :runtime-revision)) + (batch + (and (= source-extent derived-base-extent) + ;; TP's constructor is the canonical coordinate validator. + ;; A composed local proof may still expose overlapping + ;; owner intervals after all geometry owners are united; + ;; that is a fast-path proof miss, so preserve the normal + ;; Surface plan instead of surfacing an internal batch + ;; construction error to the application. + (condition-case nil + (tp-commit-batch-create + :base-revision base-revision + :target-revision (1+ base-revision) + :base-extent source-extent + :target-extent target-extent + :patches patches + :coordinate-patches intervals) + (tp-surface-error nil))))) + (when batch + (prog1 + (tp-commit-batch-result-create + context batch + :mount-specs + (ebox-surface--commit-mount-specs mount-records owned-ranges) + :client-state state) + (cl-remf state :content-base-extent)))))))) + +(defun ebox-surface--span-coordinate-result + (context projection state rendered previous-owned-ranges) + "Return a direct span commit from retained coordinate metadata, or nil." + (when-let* ((fragments + (or (plist-get state :mixed-owner-fragment-data) + (plist-get state :span-patch-fragment-data))) + (patches (plist-get state :content-coordinate-patches)) + (rebased + (and previous-owned-ranges + (ebox-surface--rebase-owned-ranges + previous-owned-ranges patches (length rendered)))) + (surface-root (plist-get projection :surface-root)) + (fragment-root + (tp-object-ensure context surface-root + ebox-surface--fragments-key 'ebox/fragments)) + (text-leaf + (tp-object-ensure context fragment-root + ebox-surface--text-key 'ebox/text))) + (let* ((owned-ranges (cdr rebased)) + (rendered-length (length rendered)) + (mount-records + (list (list :object text-leaf :start 0 :end rendered-length + :props nil :tags nil :leaf t) + (list :object fragment-root :start 0 :end rendered-length + :props nil :tags nil :leaf nil) + (list :object surface-root :start 0 :end rendered-length + :props nil :tags nil :leaf nil))) + (result + (ebox-surface--coordinate-commit + context state rendered owned-ranges mount-records + previous-owned-ranges))) + (when result + (plist-put state :surface-owned-ranges + (ebox-surface--snapshot-owned-ranges owned-ranges)) + (plist-put state :surface-fragments fragments) + (cl-remf state :previous-surface-owned-ranges) + (cl-remf state :mixed-owner-fragment-data) + (cl-remf state :mixed-owner-content-p) + (cl-remf state :span-patch-fragment-data) + (cl-remf state :span-patch-content-p) + result)))) + (defun ebox-surface--mounted-object-for-node (state node-id) "Return NODE-ID's nearest live retained object with mounts in STATE." (let ((objects (plist-get state :surface-node-object-table)) @@ -1353,6 +1606,8 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil." (ebox-incremental--finalize-declarative-scroll-publication scroll-keys scroll-prefetch-delay))) surface) + (ebox-surface--settle-native-session + old-state state-overrides surface success) (when (and signals-created-p (not success)) (ebox-surface--dispose-signals signals)))))))) @@ -1365,19 +1620,21 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil." (plist-put node :surface-object nil)) (when (plist-member node :render-cache) (plist-put node :render-cache nil)) - (dolist (child (ebox-tree--children-raw node)) - (visit child))))) + (ebox-tree-for-each-direct-child node #'visit)))) (visit root)) root) -(defun ebox-surface--candidate-root (source preserve-identities-p) +(defun ebox-surface--candidate-root + (source preserve-identities-p &optional validated-p) "Return an isolated runtime copy of SOURCE. When PRESERVE-IDENTITIES-P is non-nil, retain existing Ebox node and region -identities while always discarding TP handles and render-cache attachments." +identities while always discarding TP handles and render-cache attachments. +VALIDATED-P means the caller owns a current topology certificate for SOURCE." (unless (and (listp source) (not (stringp source))) (error "Ebox surface source must be an Ebox node")) - (ebox-tree-validate-declarative-root - source t ebox--render-root-parent-kind) + (unless validated-p + (ebox-tree-validate-declarative-root + source t ebox--render-root-parent-kind)) (let ((candidate (ebox-tree-copy-node-structure source))) (if preserve-identities-p (ebox-surface--clear-runtime-attachments candidate) @@ -1424,9 +1681,11 @@ published generation so a failed transaction has no cache state to restore." (not (ebox--runtime-region-id-conflict (ebox-surface--source-region-id-set source) buffer)))) -(defun ebox-surface--node-key (node) +(defun ebox-surface--node-key (node &optional positional-p) "Return NODE's namespaced sibling key, or nil for positional identity." - (when-let* ((key (plist-get node :key))) + (when-let* ((key (and (not positional-p) + (not (plist-get node :ebox-sequence-location)) + (plist-get node :key)))) (list 'ebox/key key))) (defun ebox-surface--node-kind (node) @@ -1486,12 +1745,11 @@ published generation so a failed transaction has no cache state to restore." theme-parent-delta-style)) (reused-p (and old-state + (not (plist-get node :ebox-candidate-computed-style-p)) (null theme-delta-style) selector-context-stable-p (equal declarations (plist-get old-state :declarations)) (eq parent-binding (plist-get old-state :parent-binding)) - (eq parent-style-state - (plist-get old-state :parent-style-state)) (= (if parent-binding 1 0) (or (plist-get old-state :inherited-dependency-count) @@ -1500,7 +1758,13 @@ published generation so a failed transaction has no cache state to restore." (plist-get old-state :stylesheet-signature)))) (state (if reused-p - old-state + (let ((reused-state (copy-sequence old-state))) + (plist-put reused-state :selector-tree-token + selector-tree-token) + (plist-put reused-state :subject subject) + (plist-put reused-state :parent-style-state + parent-style-state) + reused-state) (let ((new-state (list :selector-tree-token selector-tree-token :declarations (copy-tree declarations) @@ -1551,10 +1815,13 @@ published generation so a failed transaction has no cache state to restore." new-state))) (precomputed-style (and (not reused-p) - (null old-state) - (hash-table-p ebox-surface--inline-style-value-cache) - (ebox-surface--compute-node-style - subject declarations new-parent-style))) + (or (and (null old-state) + (plist-get node :ebox-candidate-computed-style-p) + (plist-get node :ebox-computed-style)) + (and (null old-state) + (hash-table-p ebox-surface--inline-style-value-cache) + (ebox-surface--compute-node-style + subject declarations new-parent-style))))) (binding (if precomputed-style (tp-bind-precomputed @@ -1564,17 +1831,25 @@ published generation so a failed transaction has no cache state to restore." object 'ebox/computed-style (plist-get state :compute))))) (unless reused-p - (plist-put state :binding binding) - (puthash object state style-state-table)) + (plist-put state :binding binding)) + (puthash object state style-state-table) (puthash subject binding bindings-by-subject) (puthash subject state states-by-subject) + (when (plist-get node :ebox-candidate-computed-style-p) + (cl-remf node :ebox-candidate-computed-style-p)) binding)) (defun ebox-surface--compute-node-style (subject declarations parent-style) "Compute one node style, reusing selector-free immutable results." (if (not (hash-table-p ebox-surface--inline-style-value-cache)) (ebox-style-compute-subject subject declarations parent-style) - (let* ((key (list declarations parent-style)) + (let* ((key (list (ecss-subject-type subject) + (ecss-subject-id subject) + (ecss-subject-classes subject) + (ecss-subject-attributes subject) + (ecss-subject-states subject) + declarations + (ebox-style--inherited-style-signature parent-style))) (missing (make-symbol "ebox-inline-style-cache-missing")) (cached (gethash key ebox-surface--inline-style-value-cache missing))) @@ -1586,6 +1861,131 @@ published generation so a failed transaction has no cache state to restore." (puthash key style ebox-surface--inline-style-value-cache) style))))) +(defun ebox-surface--closed-canonical-node-p (node) + "Return non-nil when NODE needs only closed inherited style projection." + (let ((declarations (ebox-style-node-declarations node))) + (and (eq (plist-get node :ebox-type) 'box) + (memq (plist-get node :ebox-kind) '(box text)) + (not (ebox-style-cascade-active-p)) + (ebox-style--closed-declarations-p declarations) + (cl-loop for (property _value) on declarations by #'cddr + never (ebox-style--custom-property-p property))))) + +(defun ebox-surface-prepare-inline-candidate-styles + (old-state candidate-index node-ids) + "Prepare selector-free computed styles for candidate NODE-IDS. + +OLD-STATE supplies published immutable computed styles. CANDIDATE-INDEX owns +the unpublished node and parent tables. Unchanged declaration/subject chains +reuse their exact prior style; changed nodes compute once against the prepared +candidate parent style. Stylesheet rules deliberately disable this local +path because selector scope may extend outside NODE-IDS. Return the prepared +node-to-subject table when the requested local preparation was completed." + (let ((stylesheet-signature (ebox-surface--stylesheet-signature))) + (when (and node-ids + (ebox-surface--stylesheet-subject-local-p + stylesheet-signature)) + (let ((old-nodes (plist-get old-state :node-table)) + (new-nodes (plist-get candidate-index :node-table)) + (parents (plist-get candidate-index :parent-table)) + (resolved (make-hash-table :test 'equal)) + (visiting (make-hash-table :test 'equal)) + (subjects-by-id (make-hash-table :test 'equal)) + (subjects-by-node (make-hash-table :test 'eq)) + (ebox-surface--inline-style-value-cache + (make-hash-table :test 'equal))) + (cl-labels + ((resolve + (node-id) + (let ((missing (make-symbol "ebox-style-unresolved"))) + (let ((cached (gethash node-id resolved missing))) + (if (not (eq cached missing)) + cached + (when (gethash node-id visiting) + (error "Ebox candidate style parent cycle at %S" node-id)) + (puthash node-id t visiting) + (let* ((node (gethash node-id new-nodes)) + (old-node (gethash node-id old-nodes)) + (parent-id (gethash node-id parents)) + (parent-style (and parent-id (resolve parent-id))) + (parent-subject + (and parent-id (gethash parent-id subjects-by-id))) + (old-parent-id + (and old-node + (gethash node-id + (plist-get old-state :parent-table)))) + (old-parent + (and old-parent-id + (gethash old-parent-id old-nodes))) + (old-parent-style + (and old-parent + (plist-get old-parent :ebox-computed-style))) + (old-style + (and old-node + (plist-get old-node :ebox-computed-style))) + (declarations + (and node (ebox-style-node-declarations node))) + (old-declarations + (and old-node + (ebox-style-node-declarations old-node))) + (subject + (and node + (or (gethash node-id subjects-by-id) + (let ((created + (ebox-tree-node-subject node))) + (setf (ecss-subject-parent created) + parent-subject) + (puthash node-id created subjects-by-id) + (puthash node created subjects-by-node) + created)))) + (old-subject + (and old-node + (or + (when-let* ((object + (plist-get old-node + :surface-object)) + (state + (gethash + object + (plist-get + old-state + :style-binding-states)))) + (plist-get state :subject)) + (ebox-tree-node-subject old-node)))) + (source-stable-p + (and old-node + (equal parent-id old-parent-id) + (eq parent-style old-parent-style) + (ebox-style-declarations-equal-p + declarations old-declarations) + (ebox-surface--subject-local-equal-p + subject old-subject))) + (style + (or (and source-stable-p old-style) + (and node (not source-stable-p) + (or parent-style + declarations) + (ebox-surface--compute-node-style + subject declarations + parent-style))))) + (when (and node style (not (eq node old-node))) + (cond + (source-stable-p) + ((and (null (plist-get node :ebox-computed-style)) + (ebox-surface--closed-canonical-node-p node)) + (ebox-style-apply-closed-inheritance node style)) + (t + (ebox-style-apply-computed node style))) + (plist-put node :ebox-computed-style style) + (plist-put node :ebox-candidate-computed-style-p t)) + (remhash node-id visiting) + (puthash node-id style resolved) + style)))))) + (dolist (node-id node-ids) + (when (gethash node-id new-nodes) + (resolve node-id)))) + subjects-by-node)))) + (defun ebox-surface--node-style-binding-required-p (node) "Return non-nil when selector-free NODE needs its own style binding." (let ((declarations (ebox-style-node-declarations node))) @@ -1610,15 +2010,22 @@ published generation so a failed transaction has no cache state to restore." (binding (gethash parent bindings-by-subject))) (puthash subject binding bindings-by-subject) (when-let* ((state (gethash parent states-by-subject))) - (puthash subject state states-by-subject))) - (ebox-style-apply-computed - node - (tp-binding-read - (ebox-surface--node-style-binding - object node subject bindings-by-subject states-by-subject - style-state-table - stylesheet-signature selector-tree-token - stylesheet-subject-local-p)))))) + (puthash subject state states-by-subject)) + (let ((style (tp-binding-read binding))) + (unless (equal style (plist-get node :ebox-computed-style)) + (ebox-style-apply-closed-inheritance node style)))) + (let ((style + (tp-binding-read + (ebox-surface--node-style-binding + object node subject bindings-by-subject states-by-subject + style-state-table + stylesheet-signature selector-tree-token + stylesheet-subject-local-p)))) + (unless (equal style (plist-get node :ebox-computed-style)) + (if (and (null (plist-get node :ebox-computed-style)) + (ebox-surface--closed-canonical-node-p node)) + (ebox-style-apply-closed-inheritance node style) + (ebox-style-apply-computed node style))))))) (defun ebox-surface--static-style-state-p (state &optional seen) "Return non-nil when STATE's inherited binding closure is static. @@ -1670,47 +2077,64 @@ dependencies, or any malformed index conservatively preserves root planning." (if (eq (plist-get entry :dirty-kind) 'paint) t (let* ((node-id (plist-get entry :node-id)) - (object (and node-id (gethash node-id objects))) - (style-state (and object (gethash object style-states))) - (candidate-node (and node-id - (gethash node-id candidate-nodes))) - (subject (and style-state - (plist-get style-state :subject)))) - (and style-state candidate-node subject - (or (equal (ebox-style-node-declarations candidate-node) - (plist-get style-state :declarations)) - (ebox-style--paint-declarations-equivalent-p - (plist-get style-state :declarations) - (ebox-style-node-declarations candidate-node))) - (equal (ebox-tree-node-id candidate-node) - (ecss-subject-id subject)) - (equal (ebox-tree-node-classes candidate-node) - (ecss-subject-classes subject)) - (equal (ebox-tree-metadata-string - (ebox-tree-node-selector-type candidate-node)) - (ecss-subject-type subject)) - (equal (mapcar - (lambda (attribute) - (cons (substring (symbol-name (car attribute)) 1) - (cdr attribute))) - (ebox-tree-node-attributes candidate-node)) - (ecss-subject-attributes subject)) - (equal (ebox-tree-node-state candidate-node) - (ecss-subject-states subject)) - (equal stylesheet-signature - (plist-get style-state :stylesheet-signature)) - (ebox-surface--static-style-state-p style-state))))) + (object (and node-id (gethash node-id objects))) + (style-state (and object (gethash object style-states))) + (candidate-node (and node-id + (gethash node-id candidate-nodes))) + (subject (and style-state + (plist-get style-state :subject))) + (candidate-declarations + (and candidate-node + (ebox-style-node-declarations candidate-node))) + (locally-computed-p + (and candidate-node + (plist-get candidate-node + :ebox-candidate-computed-style-p) + (ecss-computed-style-p + (plist-get candidate-node :ebox-computed-style)) + (ebox-style--closed-declarations-p + candidate-declarations)))) + (and style-state candidate-node subject + (or locally-computed-p + (and + (or (ebox-style-declarations-equal-p + candidate-declarations + (plist-get style-state :declarations)) + (ebox-style--paint-declarations-equivalent-p + (plist-get style-state :declarations) + candidate-declarations)) + (equal (ebox-tree-node-id candidate-node) + (ecss-subject-id subject)) + (equal (ebox-tree-node-classes candidate-node) + (ecss-subject-classes subject)) + (equal (ebox-tree-metadata-string + (ebox-tree-node-selector-type candidate-node)) + (ecss-subject-type subject)) + (equal + (mapcar + (lambda (attribute) + (cons + (substring (symbol-name (car attribute)) 1) + (cdr attribute))) + (ebox-tree-node-attributes candidate-node)) + (ecss-subject-attributes subject)) + (equal (ebox-tree-node-state candidate-node) + (ecss-subject-states subject)))) + (equal stylesheet-signature + (plist-get style-state :stylesheet-signature)) + (ebox-surface--static-style-state-p style-state))))) dirty-set)))) (defun ebox-surface--ensure-node-tree (context parent node table subject-table bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p - &optional parent-kind parent-node grandparent-node) + &optional parent-kind positional-p) "Ensure styled NODE descendants below PARENT in CONTEXT and fill TABLE." + (ebox--ensure-node-id node) (let ((object (tp-object-ensure context parent - (ebox-surface--node-key node) + (ebox-surface--node-key node positional-p) (ebox-surface--node-kind node)))) (tp-object-retain context object) (plist-put node :surface-object object) @@ -1726,14 +2150,13 @@ dependencies, or any malformed index conservatively preserves root planning." node (lambda (child) (ebox-surface--ensure-node-tree - context object child table subject-table bindings-by-subject + context object child table subject-table bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p - child-kind node parent-node)))) + child-kind (and (plist-get node :ebox-child-sequence) t))))) (when subject-table - (ebox-style-sync-flex-item node) (ebox-tree--validate-node-parent-participation - node parent-kind parent-node grandparent-node 'computed)) + node parent-kind 'computed)) object)) (defun ebox-surface--node-object-table (objects-by-node) @@ -1776,8 +2199,6 @@ dependencies, or any malformed index conservatively preserves root planning." ('box (plist-get node :region-id)) ((or 'flex 'grid) (plist-get (plist-get node :box) :region-id)) - ('flex-item - (ebox-surface--node-editable-region-id (plist-get node :node))) (_ nil))) (defun ebox-surface--logical-id-region-table (state region-objects) @@ -1964,8 +2385,7 @@ valid because their declarative layout and viewport did not change." (style-state (and style-states (gethash object style-states))) (binding (plist-get style-state :binding))) - (ebox-style-apply-computed node (tp-binding-read binding)) - (ebox-style-sync-flex-item node))) + (ebox-style-apply-computed node (tp-binding-read binding)))) (when owner-ids (ebox-tree-validate-indexed-participation nodes (plist-get state :parent-table) owner-ids 'computed)) @@ -2222,8 +2642,15 @@ old allocated width while the owner changes its natural content length." (and owner-id (ebox--ensure-layout-snapshot-details buffer owner-id))) (spans (and old-snapshot (plist-get old-snapshot :buffer-spans))) (node (and owner-id (gethash owner-id (plist-get state :node-table)))) + (parent-id (and owner-id + (gethash owner-id (plist-get state :parent-table)))) + (parent (and parent-id + (gethash parent-id (plist-get state :node-table)))) + (slot-sized-render-p + (and role-owned-lines-p parent + (eq (ebox-tree-display-inner parent) 'flex))) (render-node - (if role-owned-lines-p + (if slot-sized-render-p ;; The planner proved this fixed-basis owner in its published ;; Flex slot. Recreate that exact candidate-state witness for ;; publication; a natural render can have fewer owned lines and @@ -2233,7 +2660,11 @@ old allocated width while the owner changes its natural content length." (cons buffer state))) (ebox--flex-item-slot-sized-node buffer owner-id old-snapshot changed-keys))) - (if allocated-width + ;; Variable content is explicitly shaped into a retained slot. + ;; Typography-only changes must render at their natural size so + ;; the final footprint check observes any intrinsic-width change + ;; instead of hiding it behind the old allocation. + (if (and node allocated-width variable-content-p) (plist-put (copy-sequence node) :width allocated-width) node)))) (when (and old-snapshot spans node render-node) @@ -2293,22 +2724,93 @@ old allocated width while the owner changes its natural content length." (list :owner-id owner-id :snapshot old-snapshot :old-spans spans :details details)))))) -(defun ebox-surface--span-patch-output (buffer state) +(defun ebox-surface--range-patch-candidate (buffer state proof) + "Return PROOF's exact equal-line Range replacement candidate, or nil." + (let* ((owner-id (plist-get proof :owner-id)) + (old-snapshot (plist-get proof :snapshot)) + (spans (plist-get old-snapshot :buffer-spans)) + (node (gethash owner-id (plist-get state :node-table))) + (render-node + (if (and node (numberp (plist-get proof :allocated-width))) + (plist-put (copy-sequence node) + :width (plist-get proof :allocated-width)) + node)) + (rendered + (and render-node + (ebox-surface--render-candidate-node state render-node))) + (external-owners + (and rendered + (ebox-surface--retained-external-owner-suffix + buffer spans (plist-get old-snapshot :region-ids)))) + (rendered + (and external-owners + (ebox--add-content-owners rendered external-owners))) + (replacement + (and rendered + (ebox-buffer--rendered-in-existing-slots + spans rendered nil t))) + (lines (and replacement (ebox-string-lines replacement))) + (new-spans + (and lines (ebox-surface--projected-span-positions spans lines))) + (new-footprint + (and new-spans + (ebox--rendered-span-footprint-signature replacement))) + (new-external + (and new-footprint + (ebox--external-footprint-signature-from-span-footprint + new-footprint))) + (new-parent + (and new-footprint + (ebox--project-parent-slot-signature + (plist-get old-snapshot :parent-slot-signature) + new-footprint))) + (new-region-ids (and node (ebox--node-all-region-ids node))) + (new-roles + (and replacement + (ebox-surface--rendered-role-topology-signature + replacement new-region-ids))) + (new-overflow (and node (ebox--overflow-signature node)))) + (when (and owner-id old-snapshot spans node replacement new-spans + new-footprint new-region-ids new-roles + (ebox--span-footprint-compatible-p + (plist-get old-snapshot :span-footprint-signature) + new-footprint) + (ebox--external-footprint-compatible-p + (plist-get old-snapshot :external-footprint-signature) + new-external) + (ebox--parent-slot-compatible-p + (plist-get old-snapshot :parent-slot-signature) + new-parent) + (equal (plist-get old-snapshot :overflow-signature) + new-overflow)) + (let ((snapshot (copy-sequence old-snapshot))) + (plist-put snapshot :region-ids new-region-ids) + (plist-put snapshot :role-topology-signature new-roles) + (list :owner-id owner-id :snapshot snapshot :old-spans spans + :range-splice-p t + :details + (list :lines lines :spans new-spans :footprint new-footprint + :external new-external :parent new-parent :roles new-roles + :overflow new-overflow :variable-content-p nil)))))) + +(defun ebox-surface--span-patch-output (buffer previous-state state) "Return a proven single- or multi-owner local patch output, or nil." (let* ((proofs (plist-get state :owner-scoped-proofs)) (candidates (if proofs (mapcar (lambda (proof) - (ebox-surface--owner-patch-candidate - buffer state (plist-get proof :owner-id) - (plist-get proof :allocated-width) - (plist-get proof :variable-content-p) - (plist-get proof :variable-content-max-width) - (plist-get proof :role-owned-lines-p) - (plist-get proof :changed-keys) - (plist-get proof :allocation-closure-p) - (plist-get proof :retain-external-owner-suffix-p))) + (if (plist-get proof :range-splice-p) + (ebox-surface--range-patch-candidate buffer state proof) + (ebox-surface--owner-patch-candidate + buffer state (plist-get proof :owner-id) + (plist-get proof :allocated-width) + (plist-get proof :variable-content-p) + (plist-get proof :variable-content-max-width) + (plist-get proof :role-owned-lines-p) + (plist-get proof :changed-keys) + (plist-get proof :allocation-closure-p) + (plist-get proof :retain-external-owner-suffix-p)))) proofs) (list (ebox-surface--owner-patch-candidate @@ -2321,12 +2823,6 @@ old allocated width while the owner changes its natural content length." (widen) (let* ((source (buffer-substring (point-min) (point-max))) (origin (point-min)) - (variable-p - (cl-some - (lambda (candidate) - (plist-get (plist-get candidate :details) - :variable-content-p)) - candidates)) (coordinate-shift-p (cl-some (lambda (candidate) @@ -2339,12 +2835,13 @@ old allocated width while the owner changes its natural content length." (plist-get new-footprint :char-lengths))))) candidates)) (shared-generation - ;; A variable one-line slot may change glyph widths without - ;; changing buffer coordinates. Only a character-count - ;; delta shifts later spans and requires global lazy detail - ;; invalidation; otherwise retain the existing generation - ;; and let the owner snapshot update locally. - (and variable-p coordinate-shift-p + ;; Glyph-width-only changes keep every buffer coordinate. + ;; Any character-count delta, regardless of which proof + ;; admitted the owner, shifts later snapshots and therefore + ;; advances the global lazy-detail generation. Restricting + ;; this to variable-content proofs left fixed-slot and theme + ;; label updates with stale downstream coordinates. + (and coordinate-shift-p (1+ (or (plist-get state :layout-snapshot-detail-generation) 0)))) @@ -2390,28 +2887,46 @@ old allocated width while the owner changes its natural content length." (ebox-surface--replace-buffer-spans source origin (mapcar #'car pairs) (mapcar #'cdr pairs)))) + (plist-put state :content-base-extent (length source)) (plist-put state :content-coordinate-patches - (sort - (cl-loop - for (candidate . details) in rebased append - (cl-mapcar - (lambda (old-span new-span) - (list :old-start (- (car old-span) origin) - :old-end (- (cdr old-span) origin) - :new-start (- (car new-span) origin) - :new-end (- (cdr new-span) origin))) - (plist-get candidate :old-spans) - (plist-get details :spans))) - (lambda (left right) - (< (plist-get left :old-start) - (plist-get right :old-start))))) + (let ((coordinate-delta 0) + patches) + ;; PAIRS are the actual source replacements consumed by + ;; `ebox-surface--replace-buffer-spans'. Snapshot spans + ;; describe semantic ownership inside those replacement + ;; strings and may begin after wrapper padding; using them as + ;; edit coordinates creates artificial gaps and an invalid + ;; old-to-new mapping when several owners are composed. + (dolist (pair pairs (nreverse patches)) + (let* ((old-span (car pair)) + (replacement (cdr pair)) + (old-start (- (car old-span) origin)) + (old-end (- (cdr old-span) origin)) + (new-start (+ old-start coordinate-delta)) + (new-end (+ new-start (length replacement)))) + (push (list :old-start old-start :old-end old-end + :new-start new-start :new-end new-end) + patches) + (setq coordinate-delta + (+ coordinate-delta + (- (length replacement) + (- old-end old-start)))))))) (dolist (candidate candidates) (ebox-surface--install-span-patch-snapshot state (plist-get candidate :owner-id) (plist-get candidate :snapshot) (cdr (assq candidate rebased)) shared-generation)) + (when-let* ((old-fragments + (ebox-surface--materialized-fragment-ledger + previous-state)) + (fragments + (ebox-surface--incremental-patched-fragments + output old-fragments + (plist-get state :content-coordinate-patches)))) + (plist-put state :span-patch-fragment-data fragments) + (plist-put state :span-patch-content-p t)) output))))))) (defun ebox-surface--formatting-context-reflow-lines @@ -2445,6 +2960,25 @@ untouched." (with-current-buffer buffer (ebox--line-spans-cover-whole-lines-p old-spans))) (let* ((rendered (ebox-surface--render-candidate-node state node)) + (parent-table (plist-get state :parent-table)) + (ancestor-owner-ids + (let ((walk (and parent-table + (gethash owner-id parent-table))) + owners) + (while walk + (when-let* ((ancestor + (gethash walk (plist-get state :node-table))) + (region-id + (ebox-surface--node-editable-region-id + ancestor))) + (setq owners (append owners (list region-id)))) + (setq walk (gethash walk parent-table))) + owners)) + (rendered + (ebox--maplines + (lambda (line) + (ebox--add-content-owners line ancestor-owner-ids)) + rendered)) (new-footprint (ebox--rendered-span-footprint-signature rendered)) (new-max (plist-get (ebox--external-footprint-signature-from-span-footprint @@ -2485,9 +3019,13 @@ untouched." (= (aref old-block-text (1- (length old-block-text))) ?\n))) (context-rendered - (concat (if leading-newline-p "\n" "") + (concat (if leading-newline-p + "\n" + "") rendered - (if trailing-newline-p "\n" ""))) + (if trailing-newline-p + "\n" + ""))) (content-start (+ block-start (if leading-newline-p 1 0))) (new-spans (ebox-surface--formatting-context-reflow-lines @@ -2609,7 +3147,8 @@ causes the caller to use the ordinary full projection." (output (when geometry-proof (if (eq geometry-kind 'span-patch) - (ebox-surface--span-patch-output buffer state) + (ebox-surface--span-patch-output + buffer previous-state state) (plist-put state :render-cache (make-hash-table :test 'equal)) (plist-put state :render-signature-cache (make-hash-table :test 'eq)) @@ -2641,9 +3180,7 @@ causes the caller to use the ordinary full projection." old-by-local-address))))) (let ((new-fragments (or (and (eq geometry-kind 'span-patch) - (ebox-surface--incremental-patched-fragments - output old-fragments - (plist-get state :content-coordinate-patches))) + (plist-get state :span-patch-fragment-data)) (ebox-surface--rendered-fragments output)))) (catch 'mixed-owner-proof-miss (let (pieces) @@ -2680,25 +3217,50 @@ causes the caller to use the ordinary full projection." (affected-p (cl-some (lambda (node-id) (memq node-id paint-owner-ids)) - paint-chain))) + paint-chain)) + (paint-change-p + (and affected-p + (not + (equal + (and old-fragment + (ebox-surface--face-contributions + previous-state old-roles)) + (ebox-surface--face-contributions + state roles)))))) (plist-put copy :paint-node-chain paint-chain) (if (not affected-p) (progn (cl-remf copy :text) (plist-put copy :text-source-p t) (push copy pieces)) - (when direct-affected-p - (unless (and address old-fragment - (or (plist-get old-fragment - :face-baseline-known-p) - (plist-get copy - :face-baseline-known-p))) - (throw 'mixed-owner-proof-miss nil))) (when (plist-get old-fragment :face-baseline-known-p) (plist-put copy :face-baseline (copy-tree (plist-get old-fragment :face-baseline))) (plist-put copy :face-baseline-known-p t)) + ;; When Range identity changes prevent an old-address match, + ;; OUTPUT still contains the freshly rendered candidate + ;; face. Remove the candidate's own new contributions to + ;; recover the semantic baseline exactly; subsequent paint + ;; composition can then proceed without guessing from an + ;; unrelated old fragment. + (when (and (< (plist-get copy :start) + (plist-get copy :end)) + (not (plist-get copy + :face-baseline-known-p))) + (plist-put + copy :face-baseline + (ebox-surface--face-baseline + (get-text-property (plist-get copy :start) + 'face output) + (ebox-surface--face-contributions state roles))) + (plist-put copy :face-baseline-known-p t)) + (when (and direct-affected-p paint-change-p + (< (plist-get copy :start) + (plist-get copy :end)) + (not (plist-get copy + :face-baseline-known-p))) + (throw 'mixed-owner-proof-miss nil)) (plist-put copy :old-paint-role-ids (copy-tree (or old-roles roles))) (push copy pieces)))) @@ -3325,6 +3887,9 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (and (eq (plist-get state :projection-kind) 'mixed-owner-reflow) (plist-get state :mixed-owner-fragment-data)) + (and (memq (plist-get state :projection-kind) + '(span-patch owner-scoped)) + (plist-get state :span-patch-fragment-data)) (if (stringp output) (ebox-surface--rendered-fragments output) output))) @@ -3376,8 +3941,9 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." region-objects (and attach-p (not formatting-reflow-p)))) (t (let ((rebased - (and (eq (plist-get state :projection-kind) - 'mixed-owner-reflow) + (and (memq (plist-get state :projection-kind) + '(span-patch owner-scoped + mixed-owner-reflow)) (not formatting-reflow-p) (plist-get state :previous-surface-owned-ranges) (plist-get state :content-coordinate-patches) @@ -3417,7 +3983,8 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (let* ((plan-text (if (or transfer-text-p (plist-get state :native-render-p) - (plist-get state :mixed-owner-content-p)) + (plist-get state :mixed-owner-content-p) + (plist-get state :span-patch-content-p)) ;; Scroll output is a fresh renderer-owned string. The TP ;; owned-result path copies the string spine once while ;; rendering the plan; recursively snapshotting every @@ -3442,7 +4009,8 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (cond ((plist-get state :native-render-frame) (list :native-frame (plist-get state :native-render-frame))) - ((plist-get state :mixed-owner-content-p) + ((or (plist-get state :mixed-owner-content-p) + (plist-get state :span-patch-content-p)) ;; Mixed projection already produced fresh offset-only ;; fragment plists with retained paint chains. fragment-data) @@ -3450,6 +4018,8 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (cl-remf state :native-render-frame) (cl-remf state :mixed-owner-fragment-data) (cl-remf state :mixed-owner-content-p) + (cl-remf state :span-patch-fragment-data) + (cl-remf state :span-patch-content-p) (let ((rendered-length (length rendered))) (list plan rendered owned-ranges (list (list :object text-leaf :start 0 :end rendered-length @@ -3514,10 +4084,11 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (defun ebox-surface--box-face-contributions (box) "Return BOX typography and color contributions in render order." (let (faces) - (when-let* ((font (ebox-buffer--font-face box))) - (push font faces)) - (when-let* ((color (plist-get box :color))) - (push (ebox-surface--foreground-face color) faces)) + (when (ebox-style--text-paint-owner-p box) + (when-let* ((font (ebox-buffer--font-face box))) + (push font faces)) + (when-let* ((color (plist-get box :color))) + (push (ebox-surface--foreground-face color) faces))) (when-let* ((background (plist-get box :bgcolor))) (push (list :background background) faces)) (nreverse faces))) @@ -3899,7 +4470,8 @@ but cannot introduce or remove a viewport expression or node identity." (plist-put state :projection-kind projection-kind) (plist-put state :cascade-active-p stylesheet-active-p) (plist-put state :cascade-required-p cascade-required-p) - (when (and (eq projection-kind 'mixed-owner-reflow) + (when (and (memq projection-kind + '(span-patch owner-scoped mixed-owner-reflow)) (plist-get previous-state :surface-owned-ranges)) (plist-put state :previous-surface-owned-ranges (plist-get previous-state :surface-owned-ranges))) @@ -3936,6 +4508,11 @@ published runtime and must be consumed without clearing those shared nodes." :key ebox-surface--root-key :kind 'ebox/string :text source :capability 'content))) (let* ((scroll-patch-p (eq projection-kind 'scroll-patch)) + (initial-native-p + (and (null previous-state) + (null projection-kind) + (require 'ebox-native-reflow nil t) + (ebox-native-reflow-layout-ready-p))) (scroll-fast-p (and scroll-patch-p (let ((table (plist-get state-overrides @@ -3955,6 +4532,24 @@ published runtime and must be consumed without clearing those shared nodes." (if scroll-fast-p (plist-get previous-state :cascade-active-p) (ebox-style-cascade-active-p))) + (retained-runtime-source-p + (and previous-state + preserve-identities-p + (eq source (plist-get previous-state :root-node)) + (or (memq projection-kind + '(viewport-reflow + viewport-reflow-mixed-scroll)) + (and (eq projection-kind 'native-frame) + (plist-get state-overrides + :native-topology-stable-p))) + (plist-get state-overrides :runtime-index-prepared-p) + (plist-get state-overrides + :viewport-dependent-node-ids-ready) + (plist-member state-overrides + :viewport-dependent-node-id-axes))) + (retained-native-source-p + (and retained-runtime-source-p + (eq projection-kind 'native-frame))) (root (cond (source-path-copied-p source) (source-isolated-p @@ -3963,9 +4558,11 @@ published runtime and must be consumed without clearing those shared nodes." :native-topology-stable-p)) source (ebox-surface--clear-runtime-attachments source))) + (retained-native-source-p source) (t (ebox-surface--candidate-root - source preserve-identities-p)))) + source preserve-identities-p + retained-runtime-source-p)))) (state-overrides (if (and (memq projection-kind '(viewport-reflow viewport-reflow-mixed-scroll)) @@ -3974,46 +4571,71 @@ published runtime and must be consumed without clearing those shared nodes." (ebox-surface--isolated-viewport-overrides state-overrides) state-overrides)) (inline-inheritance-required-p - (if scroll-fast-p - (plist-get previous-state :cascade-required-p) + (unless (or scroll-fast-p retained-runtime-source-p) (ebox-surface--inline-inheritance-required-p root))) + (author-style-pending-p + (> (ebox-tree-author-style-count root) 0)) (cascade-required-p - (if scroll-fast-p + (if (or scroll-fast-p retained-runtime-source-p) (plist-get previous-state :cascade-required-p) - (or stylesheet-active-p inline-inheritance-required-p))) + (or stylesheet-active-p + inline-inheritance-required-p))) (style-required-p (if scroll-fast-p cascade-required-p (or cascade-required-p + author-style-pending-p (plist-get previous-state :cascade-active-p) (plist-get previous-state :cascade-required-p)))) - (projection-kind - (if (and (null projection-kind) - (require 'ebox-native-commit nil t) - (ebox-native-commit-projection-eligible-p - previous-state state-overrides stylesheet-active-p)) - 'native-frame - projection-kind)) (projection (ebox-surface--projection-start context root style-required-p projection-kind previous-state state-overrides))) - (unless (or source-isolated-p source-path-copied-p) + (unless (or source-isolated-p source-path-copied-p + retained-runtime-source-p) (ebox-surface--reconcile-candidate root previous-state)) - (let* ((reuse-context-axes-p - (and signals - (or scroll-fast-p - (and (eq projection-kind 'native-frame) - (plist-get state-overrides - :native-topology-stable-p) - (plist-get state-overrides - :viewport-dependent-node-ids-ready)) - (ebox-surface--mixed-projection-preserves-context-axes-p - previous-state state-overrides projection-kind)))) + (let* ((context-axis-facts + (and + signals + (cond + ((and scroll-fast-p + (plist-get previous-state + :viewport-dependent-node-ids-ready) + (plist-member + previous-state + :viewport-dependent-node-id-axes)) + previous-state) + ((and (memq projection-kind + '(viewport-reflow + viewport-reflow-mixed-scroll)) + (plist-get + state-overrides + :viewport-dependent-node-ids-ready) + (plist-member + state-overrides + :viewport-dependent-node-id-axes)) + ;; The viewport planner owns this topology proof and + ;; copies its exact axis partition into the candidate + ;; contract. Rewalking the same immutable tree here + ;; would only derive the identical fact a second time. + state-overrides) + ((and (eq projection-kind 'native-frame) + (plist-get state-overrides + :native-topology-stable-p) + (plist-get state-overrides + :viewport-dependent-node-ids-ready) + (plist-member + state-overrides + :viewport-dependent-node-id-axes)) + state-overrides) + ((ebox-surface--mixed-projection-preserves-context-axes-p + previous-state state-overrides projection-kind) + previous-state)))) + (reuse-context-axes-p (not (null context-axis-facts))) (axes (and signals (if reuse-context-axes-p - (plist-get previous-state + (plist-get context-axis-facts :viewport-dependent-node-id-axes) (ebox-surface--context-axes root)))) (viewport-width @@ -4041,7 +4663,7 @@ published runtime and must be consumed without clearing those shared nodes." viewport-height display-signature scroll-offsets))) (when reuse-context-axes-p (plist-put state :viewport-dependent-node-ids - (plist-get previous-state + (plist-get context-axis-facts :viewport-dependent-node-ids)) (plist-put state :viewport-dependent-node-ids-ready t)) (plist-put state :style-binding-states @@ -4050,6 +4672,8 @@ published runtime and must be consumed without clearing those shared nodes." (plist-get projection :selector-tree-snapshot)) (plist-put state :selector-tree-token (plist-get projection :selector-tree-token)) + (plist-put state :stylesheet-signature + (plist-get projection :stylesheet-signature)) (plist-put state :scroll-fast-window-p scroll-fast-p) (when (memq projection-kind '(span-patch owner-scoped scroll-patch @@ -4078,7 +4702,15 @@ published runtime and must be consumed without clearing those shared nodes." '(scroll-patch formatting-context-reflow mixed-owner-reflow)) (plist-put state :region-box-table (make-hash-table :test 'equal))) - (let ((rendered + (let* ((_initial-native + (when initial-native-p + ;; Initial mount has no incremental planner. Surface owns + ;; the full candidate after style projection and runtime + ;; indexing, so it may explicitly request one native full + ;; frame; unsupported programs still fall back through + ;; `ebox-native-commit-render' without publishing state. + (plist-put state :projection-kind 'native-frame))) + (rendered (cond ((and (eq projection-kind 'scroll-patch) (plist-get state :native-scroll-materialize-p)) @@ -4101,7 +4733,8 @@ published runtime and must be consumed without clearing those shared nodes." ((memq projection-kind '(span-patch owner-scoped)) (if-let* ((span-output (ebox-surface--span-patch-output - (ebox-surface--signals-buffer signals) state))) + (ebox-surface--signals-buffer signals) + previous-state state))) span-output (setq projection (ebox-surface--complete-projection @@ -4120,11 +4753,17 @@ published runtime and must be consumed without clearing those shared nodes." (plist-put state :selector-tree-token (plist-get projection :selector-tree-token)) + (plist-put state :stylesheet-signature + (plist-get projection + :stylesheet-signature)) (plist-put state :region-box-table (make-hash-table :test 'equal)) (ebox-surface--render-candidate state))) (t (ebox-surface--render-candidate state))))) + (when (and initial-native-p + (not (plist-get state :native-render-p))) + (plist-put state :projection-kind nil)) (when (and signals (hash-table-p (plist-get state :scroll-state-table)) (> (hash-table-count @@ -4150,6 +4789,8 @@ published runtime and must be consumed without clearing those shared nodes." (plist-put state :native-scroll-materialize-p nil) (cl-remf state :native-touched-node-ids) (cl-remf state :native-removed-node-ids) + (cl-remf state :computed-participation-validated-p) + (cl-remf state :candidate-subject-table) result))))))) (defun ebox-surface-producer diff --git a/ebox-tree.el b/ebox-tree.el index 275a0b4..77fb917 100644 --- a/ebox-tree.el +++ b/ebox-tree.el @@ -29,11 +29,6 @@ "Dynamic snapshot-detail-local node -> region ids cache. This is an optimization for snapshot capture, not buffer runtime state.") (defvar ebox--flex-item-prop-keys) -(defvar ebox--flex-item-direct-adapter-token) -(defvar ebox--flex-item-wrapper-adapter-token) -(defvar ebox-tree--legacy-flex-item-adapter-proofs - (make-hash-table :test #'eq :weakness 'key) - "Weak adapter -> construction-shape proofs for legacy Flex items.") (defun ebox-tree-computed-display (node) "Return NODE's canonical CSS-like display pair." @@ -44,7 +39,6 @@ This is an optimization for snapshot capture, not buffer runtime state.") ('stack '(block column)) ('flex '(block flex)) ('grid '(block grid)) - ('flex-item '(block flow)) (_ ebox--default-display)))) (defun ebox-tree-display-outer (node) @@ -59,16 +53,29 @@ This is an optimization for snapshot capture, not buffer runtime state.") "Return non-nil when NODE establishes a child layout context." (memq (ebox-tree-display-inner node) '(row column flex grid))) +(defun ebox-tree-layout-props (node) + "Return NODE's canonical child-layout properties. +Typed Box nodes read their LayoutConfig; legacy runtime adapters fall back to +their private props encoding. Callers receive a detached value." + (if-let* ((config (and (listp node) + (plist-get node :ebox-layout-config)))) + (copy-tree (ebox-layout-config-props config)) + (copy-tree (or (and (listp node) (plist-get node :raw-props)) + (and (listp node) (plist-get node :props)))))) + (defun ebox-tree-node-selector-type (node) "Return NODE's CSS-like selector type symbol." - (pcase (and (listp node) (plist-get node :ebox-type)) - ('box 'box) - ('concat 'row) - ('stack 'column) - ('flex 'flex) - ('grid 'grid) - ('flex-item 'item) - (_ nil))) + (or (pcase (and (listp node) (plist-get node :ebox-kind)) + ('text 'text) + ('box 'box) + (_ nil)) + (pcase (and (listp node) (plist-get node :ebox-type)) + ('box 'box) + ('concat 'row) + ('stack 'column) + ('flex 'flex) + ('grid 'grid) + (_ nil)))) (defun ebox-tree-metadata-string (value) "Return VALUE normalized to a selector metadata string." @@ -143,7 +150,20 @@ This is an optimization for snapshot capture, not buffer runtime state.") (defun ebox-tree--children-raw (node) "Return NODE's direct child nodes without assigning runtime ids." - (pcase (and (listp node) (plist-get node :ebox-type)) + (let ((type (and (listp node) (plist-get node :ebox-type)))) + (cond + ;; Flex and Grid own a visual wrapper as well as their layout children. + ;; Runtime traversals must see both through this single child contract; + ;; otherwise rendered wrapper regions have no node or surface identity. + ((memq type '(flex grid)) + (append (when-let* ((box (plist-get node :box))) (list box)) + (ebox-tree-layout-children node))) + ((and (listp node) (plist-member node :children)) + (plist-get node :children)) + ((and (listp node) (plist-get node :ebox-child-sequence)) + (ebox-child-range--flatten (plist-get node :ebox-child-sequence))) + (t + (pcase type ('box (if (plist-member node :children) (plist-get node :children) @@ -152,17 +172,7 @@ This is an optimization for snapshot capture, not buffer runtime state.") (ebox-tree-layout-children node)) ('stack (ebox-tree-layout-children node)) - ('flex - (append (when-let* ((box (plist-get node :box))) - (list box)) - (ebox-tree-layout-children node))) - ('grid - (append (when-let* ((box (plist-get node :box))) - (list box)) - (ebox-tree-layout-children node))) - ('flex-item - (delq nil (list (plist-get node :node)))) - (_ nil))) + (_ nil)))))) (defun ebox-tree-node-children (node) "Return NODE's direct declarative children without mutating them. @@ -189,7 +199,10 @@ must not modify a published runtime tree through this accessor." ('box (if (plist-member node :children) (dolist (child (plist-get node :children)) - (funcall function child)) + (if (ebox-child-range--descriptor-p child) + (dolist (item (ebox-child-range--descriptor-items child)) + (funcall function item)) + (funcall function child))) (when-let* ((child (plist-get node :ebox-content-node))) (funcall function child)))) ((or 'concat 'stack 'flex 'grid) @@ -202,19 +215,76 @@ must not modify a published runtime tree through this accessor." ('stack (delq nil (list (plist-get node :top) (plist-get node :bottom))))))) - (funcall function child))) - ('flex-item - (when-let* ((child (plist-get node :node))) - (funcall function child)))))) + (if (ebox-child-range--descriptor-p child) + (dolist (item (ebox-child-range--descriptor-items child)) + (funcall function item)) + (funcall function child)))) + (_ nil)))) + node) + +(defun ebox-tree-map-direct-children (node function) + "Return FUNCTION results for NODE's children without an intermediate list." + (let (results) + (ebox-tree-for-each-direct-child + node (lambda (child) (push (funcall function child) results))) + (nreverse results))) + +(defun ebox-tree-author-style-count (node) + "Return NODE's retained count of style-projecting canonical descendants." + (let ((count (and (listp node) + (plist-get node :ebox-author-style-count)))) + (if (and (integerp count) (>= count 0)) + count + (if (and (listp node) + (plist-get node :ebox-author-style-required-p)) + 1 + 0)))) + +(defun ebox-tree--set-author-style-count (node count) + "Set NODE's exact nonnegative author style COUNT and return NODE." + (unless (and (integerp count) (>= count 0)) + (error "Ebox author style count must be nonnegative: %S" count)) + (plist-put node :ebox-author-style-count count) + (if (> count 0) + (plist-put node :ebox-author-style-required-p t) + (cl-remf node :ebox-author-style-required-p)) + node) + +(defun ebox-tree-clear-author-style-pending (node) + "Clear projected author style facts in NODE's pending subtree." + (when (> (ebox-tree-author-style-count node) 0) + (ebox-tree-for-each-direct-child + node + (lambda (child) + (when (> (ebox-tree-author-style-count child) 0) + (ebox-tree-clear-author-style-pending child)))) + (ebox-tree--set-author-style-count node 0)) + node) + +(cl-defun ebox-tree--put-child-sequence + (node sequence &optional (children nil children-p)) + "Set NODE's authoritative child SEQUENCE and retained material CHILDREN. +When CHILDREN is omitted, invalidate the retained material list without +flattening; the next layout consumer may materialize it exactly once." + (plist-put node :ebox-child-sequence sequence) + (if children-p + (plist-put node :children children) + (cl-remf node :children)) node) (defun ebox-tree-layout-children (node) "Return layout container children for NODE in render order. New row/column containers store flat `:children'. Legacy `ebox-concat' and `ebox-stack' nodes may still carry `:left'/`:right' or `:top'/`:bottom'." - (if (plist-member node :ebox-child-sequence) - (ebox-child-range--flatten (plist-get node :ebox-child-sequence)) - (or (plist-get node :children) + (if (plist-member node :children) + (plist-get node :children) + (if (plist-member node :ebox-child-sequence) + (let ((children + (ebox-child-range--flatten + (plist-get node :ebox-child-sequence)))) + (plist-put node :children children) + children) + (or (plist-get node :children) (pcase (and (listp node) (plist-get node :ebox-type)) ('concat (delq nil (list (plist-get node :left) @@ -222,7 +292,7 @@ New row/column containers store flat `:children'. Legacy `ebox-concat' and ('stack (delq nil (list (plist-get node :top) (plist-get node :bottom)))) - (_ nil))))) + (_ nil)))))) (defun ebox-tree--replace-direct-child (child replacements) "Return CHILD's identity replacement from REPLACEMENTS, when present. @@ -232,24 +302,27 @@ child and is therefore never used as a replacement key." (cdr replacement) child)) -(defun ebox-tree--replace-direct-child-list (children replacements) - "Replace CHILDREN found by identity in REPLACEMENTS. -Return CHILDREN itself when no child changes. Otherwise return one new list -whose untouched child objects remain shared. A nil replacement removes the -matched child from the returned sequence." +(defun ebox-tree--replace-direct-child-list-result (children replacements) + "Return `(CHILDREN . STYLE-DELTA)' after identity REPLACEMENTS." (if (null replacements) - children - (let (changed replaced) + (cons children 0) + (let ((style-delta 0) changed replaced) (dolist (child children) (let ((replacement (ebox-tree--replace-direct-child child replacements))) (unless (eq replacement child) - (setq changed t)) + (setq changed t) + (cl-incf style-delta + (- (ebox-tree-author-style-count replacement) + (ebox-tree-author-style-count child)))) (when replacement (push replacement replaced)))) - (if changed - (nreverse replaced) - children)))) + (cons (if changed (nreverse replaced) children) + style-delta)))) + +(defun ebox-tree--replace-direct-child-list (children replacements) + "Replace CHILDREN found by identity in REPLACEMENTS." + (car (ebox-tree--replace-direct-child-list-result children replacements))) (defun ebox-tree--replace-direct-child-property (node property replacements) @@ -280,14 +353,15 @@ still copied while every nested value remains shared." (error "Ebox can only path-copy a node plist, got %S" node)) (let* ((type (plist-get node :ebox-type)) (copy (copy-sequence node)) + (sequence (plist-get copy :ebox-child-sequence)) + (style-delta 0) (scalar-properties (pcase type ('box '(:ebox-content-node)) ('concat '(:left :right)) ('stack '(:top :bottom)) ('flex '(:box)) - ('grid '(:box)) - ('flex-item '(:node))))) + ('grid '(:box))))) (dolist (property scalar-properties) (setq copy (ebox-tree--replace-direct-child-property @@ -296,13 +370,16 @@ still copied while every nested value remains shared." (and (eq type 'box) (plist-member copy :children))) (plist-member copy :children)) (let* ((children (plist-get copy :children)) - (replaced - (ebox-tree--replace-direct-child-list children replacements))) + (result + (ebox-tree--replace-direct-child-list-result + children replacements)) + (replaced (car result))) (unless (eq replaced children) - (setq copy (plist-put copy :children replaced))))) - (when-let* ((sequence (and (memq type '(concat stack flex grid)) - (plist-get copy :ebox-child-sequence)))) - (let ((updated sequence) changed fallback) + (setq copy (plist-put copy :children replaced))) + (unless sequence + (setq style-delta (cdr result))))) + (when sequence + (let ((updated sequence) changed fallback identity-locations) (dolist (replacement replacements) (let* ((old (car replacement)) (new (cdr replacement)) (location (and old (plist-get old :ebox-sequence-location))) @@ -325,9 +402,28 @@ still copied while every nested value remains shared." (list :parent-node-id (plist-get node :node-id) :segment-index (car key-location) :offset (cdr key-location)))))) + (unless location + (unless identity-locations + (setq identity-locations (make-hash-table :test #'eq)) + (dotimes (segment-index + (ebox-child-range--sequence-count sequence)) + (let ((payload + (ebox-child-range--segment-payload + (ebox-child-range--segment-at + sequence segment-index)))) + (dotimes (offset (length payload)) + (puthash + (aref payload offset) + (list :parent-node-id (plist-get node :node-id) + :segment-index segment-index :offset offset) + identity-locations))))) + (setq location (gethash old identity-locations))) (when (and location (equal (plist-get location :parent-node-id) (plist-get node :node-id))) + (cl-incf style-delta + (- (ebox-tree-author-style-count new) + (ebox-tree-author-style-count old))) (if new (setq updated (ebox-child-range--replace-item-at @@ -345,10 +441,19 @@ still copied while every nested value remains shared." (ebox-tree--replace-direct-child-list items replacements)) segments))) - (setq updated (ebox-child-range--build (nreverse segments)) + (setq updated (ebox-child-range--build + (nreverse segments) nil t) changed t))) (when changed - (setq copy (plist-put copy :ebox-child-sequence updated))))) + (setq copy + (if (plist-member copy :children) + (ebox-tree--put-child-sequence + copy updated (plist-get copy :children)) + (ebox-tree--put-child-sequence copy updated)))))) + (when (eq (plist-get node :ebox-kind) 'box) + (setq copy + (ebox-tree--set-author-style-count + copy (+ (ebox-tree-author-style-count node) style-delta)))) copy)) (defun ebox-tree-copy-node-structure (root) @@ -365,7 +470,8 @@ original object identity." (if (or (not (listp node)) (stringp node)) node (let* ((type (plist-get node :ebox-type)) - (material-p (memq type '(concat stack flex grid))) + (material-p (or (memq type '(concat stack flex grid)) + (plist-get node :ebox-child-sequence))) (raw (and material-p (plist-get node :children))) (sequence (and material-p (plist-get node :ebox-child-sequence))) @@ -393,7 +499,7 @@ original object identity." (ebox-child-range--descriptor-items entry))) segments) (push (cons nil (list (copy-node entry))) segments)))) - (setq copy (without copy :children)) + (setq segments (nreverse segments)) (pcase type ((or 'flex 'grid) (when-let* ((box (plist-get node :box))) @@ -402,19 +508,19 @@ original object identity." (setq copy (without (without copy :left) :right))) ('stack (setq copy (without (without copy :top) :bottom)))) - (plist-put copy :ebox-child-sequence - (ebox-child-range--build (nreverse segments)))) + (ebox-tree--put-child-sequence + copy + (ebox-child-range--build segments nil t) + (cl-mapcan (lambda (segment) + (copy-sequence (cdr segment))) + segments))) (let ((copy (ebox-tree-copy-with-direct-child-replacements node (mapcar (lambda (child) (cons child (copy-node child))) (ebox-tree--children-raw node))))) - ;; Only this exact deep-copy traversal has proven every copied - ;; child derives from the authentic source tree. Generic - ;; path-copy replacements must not launder adapter provenance. - (ebox-tree--remint-legacy-flex-item-adapter-proof - node copy))))))) + copy)))))) (copy-node root))) (defun ebox-tree--runtime-identity-node-shell @@ -447,13 +553,14 @@ Record its identities in NODE-ID-SET and REGION-ID-SET." node node-id-set region-id-set))) (pcase (plist-get node :ebox-type) ('box - (if (plist-member node :children) + (if (or (plist-member node :children) + (plist-get node :ebox-child-sequence)) (plist-put shell :children - (mapcar + (ebox-tree-map-direct-children + node (lambda (child) (ebox-tree--runtime-identity-skeleton - child node-id-set region-id-set)) - (plist-get node :children))) + child node-id-set region-id-set)))) (when-let* ((child (plist-get node :ebox-content-node))) (plist-put shell :ebox-content-node (ebox-tree--runtime-identity-skeleton @@ -475,12 +582,7 @@ Record its identities in NODE-ID-SET and REGION-ID-SET." (lambda (child) (ebox-tree--runtime-identity-skeleton child node-id-set region-id-set)) - (ebox-tree-layout-children node)))) - ('flex-item - (when-let* ((child (plist-get node :node))) - (plist-put shell :node - (ebox-tree--runtime-identity-skeleton - child node-id-set region-id-set))))) + (ebox-tree-layout-children node))))) shell)) (defun ebox-tree-runtime-identity-snapshot (root) @@ -530,11 +632,7 @@ Host references are root-global opaque metadata and are compared with `equal'." (unless (gethash node seen) (puthash node t seen) (puthash node t active) - (dolist (child (ebox-tree--children-raw node)) - (if (ebox-child-range--descriptor-p child) - (dolist (item (ebox-child-range--descriptor-items child)) - (visit item)) - (visit child))) + (ebox-tree-for-each-direct-child node #'visit) (remhash node active)))))) (visit root)) root)) @@ -570,28 +668,13 @@ Host references are root-global opaque metadata and are compared with `equal'." (defun ebox-tree--author-specifies-participation-p (node key) "Return non-nil when NODE's author source specifies participation KEY." - (if (eq (plist-get node :ebox-type) 'flex-item) - (let* ((adapter-source (plist-get node :node)) - (content (and (listp adapter-source) - (plist-get adapter-source :ebox-content-node))) - (projected-p - (or (and (listp adapter-source) - (plist-get adapter-source :ebox-computed-style)) - (and (listp content) - (plist-get content :ebox-computed-style))))) - (or (ebox-tree--declarations-specify-participation-p - adapter-source key) - (ebox-tree--declarations-specify-participation-p content key) - (and (not projected-p) - (plist-member (plist-get node :props) key)))) - (or (ebox-tree--declarations-specify-participation-p node key) - (and (null (plist-get node :ebox-computed-style)) - (plist-member node key))))) + (or (ebox-tree--declarations-specify-participation-p node key) + (and (null (plist-get node :ebox-computed-style)) + (plist-member node key)))) (defun ebox-tree--node-participation-keys (node source) "Return NODE participation keys from AUTHOR or COMPUTED SOURCE." - (let ((flex-item-p (eq (plist-get node :ebox-type) 'flex-item)) - (style (plist-get node :ebox-computed-style)) + (let ((style (plist-get node :ebox-computed-style)) keys) (dolist (key ebox-tree--participation-keys) (when @@ -599,33 +682,16 @@ Host references are root-global opaque metadata and are compared with `equal'." ('author (ebox-tree--author-specifies-participation-p node key)) ('computed - (if flex-item-p - (let* ((adapter-source (plist-get node :node)) - (adapter-style - (and (listp adapter-source) - (plist-get adapter-source - :ebox-computed-style))) - (content (and (listp adapter-source) - (plist-get adapter-source - :ebox-content-node))) - (content-style - (and (listp content) - (plist-get content :ebox-computed-style)))) - (and (plist-member (plist-get node :props) key) - (or (ebox-tree--style-specifies-participation-p - adapter-style key) - (ebox-tree--style-specifies-participation-p - content-style key)))) - (and (plist-member node key) - (ebox-tree--style-specifies-participation-p style key)))) + (and (plist-member node key) + (ebox-tree--style-specifies-participation-p style key))) (_ (error "Unknown Ebox participation source: %S" source))) (push key keys))) (nreverse keys))) (defun ebox-tree--child-layout-kind (node parent-kind) - "Return layout kind governing NODE children, preserving transparent adapters." + "Return the layout kind governing NODE's direct children." + (ignore parent-kind) (pcase (plist-get node :ebox-type) - ('flex-item parent-kind) ('flex 'flex) ('grid 'grid) (_ (pcase (if (and (eq (plist-get node :ebox-kind) 'box) @@ -636,112 +702,34 @@ Host references are root-global opaque metadata and are compared with `equal'." ('flow 'normal) (kind kind))))) -(defun ebox-tree--legacy-flex-item-adapter-p (node token) - "Return non-nil when NODE has the exact legacy Flex adapter TOKEN shape." - (let ((proof (gethash node ebox-tree--legacy-flex-item-adapter-proofs)) - (source (plist-get node :node))) - (and token - (eq (plist-get node :ebox-type) 'flex-item) - (vectorp proof) - (= (length proof) 3) - (eq (aref proof 0) token) - (eq (aref proof 1) source) - (listp source) - (not (stringp source)) - (let ((children (ebox-tree--children-raw node))) - (and (eq (car children) source) - (null (cdr children)))) - (if (eq token ebox--flex-item-wrapper-adapter-token) - (let ((content (plist-get source :ebox-content-node))) - (and (eq (plist-get source :ebox-type) 'box) - (listp content) - (not (stringp content)) - (eq (aref proof 2) content))) - (null (aref proof 2)))))) - -(defun ebox-tree--register-legacy-flex-item-adapter - (adapter token source content) - "Register ADAPTER's exact TOKEN, SOURCE, and optional CONTENT identities." - (puthash adapter (vector token source content) - ebox-tree--legacy-flex-item-adapter-proofs) - adapter) - -(defun ebox-tree--remint-legacy-flex-item-adapter-proof (old new) - "Bind NEW adapter provenance to copied children when OLD is authentic." - (cond - ((ebox-tree--legacy-flex-item-adapter-p - old ebox--flex-item-direct-adapter-token) - (ebox-tree--register-legacy-flex-item-adapter - new ebox--flex-item-direct-adapter-token (plist-get new :node) nil)) - ((ebox-tree--legacy-flex-item-adapter-p - old ebox--flex-item-wrapper-adapter-token) - (let ((wrapper (plist-get new :node))) - (ebox-tree--register-legacy-flex-item-adapter - new ebox--flex-item-wrapper-adapter-token - wrapper (plist-get wrapper :ebox-content-node)))) - (t - (remhash new ebox-tree--legacy-flex-item-adapter-proofs) - new))) - -(defun ebox-tree--legacy-flex-item-duplicate-p (node parent grandparent) - "Return non-nil when NODE is an exact duplicate inside a legacy adapter." - (or - (and (ebox-tree--legacy-flex-item-adapter-p - parent ebox--flex-item-direct-adapter-token) - (eq node (plist-get parent :node))) - (and (ebox-tree--legacy-flex-item-adapter-p - parent ebox--flex-item-wrapper-adapter-token) - (let ((wrapper (plist-get parent :node))) - (and (eq node wrapper) - (eq (plist-get wrapper :ebox-type) 'box) - (listp (plist-get wrapper :ebox-content-node))))) - (and (ebox-tree--legacy-flex-item-adapter-p - grandparent ebox--flex-item-wrapper-adapter-token) - (let ((wrapper (plist-get grandparent :node))) - (and (eq parent wrapper) - (eq (plist-get wrapper :ebox-type) 'box) - (eq node (plist-get wrapper :ebox-content-node))))))) - (defun ebox-tree--validate-node-parent-participation - (node parent-kind parent grandparent source) + (node parent-kind source) "Validate NODE participation under PARENT-KIND from AUTHOR or COMPUTED SOURCE." - (unless (ebox-tree--legacy-flex-item-duplicate-p node parent grandparent) - (let ((keys (ebox-tree--node-participation-keys node source))) - (when keys - (when (eq (plist-get node :ebox-kind) 'text) - (error "Ebox Text cannot carry participation properties: %S" keys)) - (pcase parent-kind - ('flex - (when (cl-intersection - keys ebox-tree--grid-participation-keys :test #'eq) - (error "Grid participation requires a direct Grid parent: %S" - keys))) - ('grid - (when (cl-intersection - keys ebox-tree--flex-participation-keys :test #'eq) - (error "Flex participation requires a direct Flex parent: %S" - keys))) - (_ (error "Ebox participation requires a Flex or Grid parent: %S" - keys)))))) + (let ((keys (ebox-tree--node-participation-keys node source))) + (when keys + (when (eq (plist-get node :ebox-kind) 'text) + (error "Ebox Text cannot carry participation properties: %S" keys)) + (pcase parent-kind + ('flex + (when (cl-intersection + keys ebox-tree--grid-participation-keys :test #'eq) + (error "Grid participation requires a direct Grid parent: %S" + keys))) + ('grid + (when (cl-intersection + keys ebox-tree--flex-participation-keys :test #'eq) + (error "Flex participation requires a direct Flex parent: %S" + keys))) + (_ (error "Ebox participation requires a Flex or Grid parent: %S" + keys))))) node) (defun ebox-tree--indexed-parent-context (node-id node-table parent-table) - "Return (PARENT-KIND PARENT GRANDPARENT) for NODE-ID in indexed tree." + "Return the direct parent layout kind for NODE-ID in indexed tree." (let* ((parent-id (gethash node-id parent-table)) - (parent (and parent-id (gethash parent-id node-table))) - (grandparent-id (and parent-id (gethash parent-id parent-table))) - (grandparent (and grandparent-id - (gethash grandparent-id node-table))) - (layout-owner parent)) - (while (and layout-owner - (eq (plist-get layout-owner :ebox-type) 'flex-item)) - (let ((owner-id (plist-get layout-owner :node-id))) - (setq owner-id (and owner-id (gethash owner-id parent-table)) - layout-owner (and owner-id (gethash owner-id node-table))))) - (list (and layout-owner - (ebox-tree--child-layout-kind layout-owner nil)) - parent grandparent))) + (parent (and parent-id (gethash parent-id node-table)))) + (and parent (ebox-tree--child-layout-kind parent nil)))) (defun ebox-tree-validate-indexed-participation (node-table parent-table node-ids &optional source) @@ -749,11 +737,10 @@ Host references are root-global opaque metadata and are compared with `equal'." (setq source (or source 'author)) (dolist (node-id (delete-dups (copy-sequence node-ids))) (when-let* ((node (gethash node-id node-table))) - (pcase-let ((`(,parent-kind ,parent ,grandparent) - (ebox-tree--indexed-parent-context - node-id node-table parent-table))) - (ebox-tree--validate-node-parent-participation - node parent-kind parent grandparent source)))) + (ebox-tree--validate-node-parent-participation + node + (ebox-tree--indexed-parent-context node-id node-table parent-table) + source))) node-table) (defun ebox-tree-validate-declarative-root @@ -771,7 +758,7 @@ and all explicit identities are compared with `equal'. Return ROOT on success." (active (make-hash-table :test 'eq)) (range-refs (make-hash-table :test 'equal))) (cl-labels - ((visit (node parent-kind parent grandparent) + ((visit (node parent-kind) (when (and (listp node) (not (stringp node))) (when (gethash node active) (error "Ebox declarative tree contains a cycle")) @@ -781,37 +768,52 @@ and all explicit identities are compared with `equal'. Return ROOT on success." (puthash node t active) (when validate-participation-p (ebox-tree--validate-node-parent-participation - node parent-kind parent grandparent 'author)) + node parent-kind 'author)) (let* ((keys (make-hash-table :test 'equal)) (type (plist-get node :ebox-type)) - (raw (and (memq type '(concat stack flex grid)) + (raw (and (memq type '(box concat stack flex grid)) (plist-get node :children))) children) (when (and raw (not (proper-list-p raw))) (error "Ebox material children must be a proper list")) - (dolist (entry (ebox-tree--children-raw node)) - (if (ebox-child-range--descriptor-p entry) - (progn - (unless (and (proper-list-p raw) (memq entry raw)) - (error "Ebox child Range descriptor is invalid in a scalar slot")) - (let ((ref (ebox-child-range--descriptor-ref entry)) - (items (ebox-child-range--descriptor-items entry))) - (unless (and ref (proper-list-p items)) - (error "Ebox child Range descriptor is invalid")) - (when (gethash ref range-refs) - (error "Ebox child Range ref is not unique: %S" ref)) - (puthash ref t range-refs) - (dolist (item items) - (when (ebox-child-range--descriptor-p item) - (error "Nested child Range descriptors are reserved")) - (unless (and (listp item) (not (stringp item))) - (error "Ebox child Range item must be a node")) - (push item children)))) - (progn - (when (ebox-child-range--descriptor-p entry) - (error "Ebox child Range descriptor is invalid here")) + (when-let* ((sequence (plist-get node :ebox-child-sequence))) + (dotimes (index + (ebox-child-range--sequence-count sequence)) + (when-let* ((ref + (ebox-child-range--segment-ref + (ebox-child-range--segment-at sequence index)))) + (when (gethash ref range-refs) + (error "Ebox child Range ref is not unique: %S" ref)) + (puthash ref t range-refs)))) + (if (plist-get node :ebox-child-sequence) + (ebox-tree-for-each-direct-child + node (lambda (child) (push child children))) + (dolist (entry (ebox-tree--children-raw node)) + (if (ebox-child-range--descriptor-p entry) + (progn + (unless (and (proper-list-p raw) (memq entry raw)) + (error "Ebox child Range descriptor is invalid in a scalar slot")) + (let ((ref (ebox-child-range--descriptor-ref entry)) + (items (ebox-child-range--descriptor-items entry))) + (unless (and ref (proper-list-p items)) + (error "Ebox child Range descriptor is invalid")) + (when (gethash ref range-refs) + (error "Ebox child Range ref is not unique: %S" ref)) + (puthash ref t range-refs) + (dolist (item items) + (when (ebox-child-range--descriptor-p item) + (error "Nested child Range descriptors are reserved")) + (unless (and (listp item) (not (stringp item))) + (error "Ebox child Range item must be a node")) + (push item children)))) (push entry children)))) (dolist (child (nreverse children)) + (when (and (eq (plist-get node :ebox-kind) 'box) + (not (memq (plist-get child :ebox-kind) + '(text box)))) + (error + "Canonical Ebox Box child must be Text or Box: %S" + child)) (when-let* ((key (and (listp child) (not (stringp child)) (plist-get child :key)))) @@ -820,10 +822,9 @@ and all explicit identities are compared with `equal'. Return ROOT on success." key)) (puthash key t keys)) (visit child - (ebox-tree--child-layout-kind node parent-kind) - node parent))) - (remhash node active)))) - (visit root root-parent-kind nil nil)) + (ebox-tree--child-layout-kind node parent-kind))) + (remhash node active))))) + (visit root root-parent-kind)) root)) (defun ebox-tree-clear-runtime-identities (root) @@ -840,14 +841,13 @@ and all explicit identities are compared with `equal'. Return ROOT on success." (plist-put node :surface-object nil)) (when (plist-member node :ebox-sequence-location) (plist-put node :ebox-sequence-location nil)) - (dolist (child (ebox-tree--children-raw node)) - (visit child))))) + (ebox-tree-for-each-direct-child node #'visit)))) (visit root)) root) (defconst ebox-tree--runtime-source-keys '(:node-id :region-id :surface-object :render-cache :ebox-sequence-location - :ebox-content-width-exact-p) + :ebox-content-width-exact-p :ebox-content-layout-complete-p) "Runtime-owned plist keys excluded from declarative source signatures.") (defconst ebox-tree--child-source-keys @@ -858,13 +858,17 @@ and all explicit identities are compared with `equal'. Return ROOT on success." (defconst ebox-tree--excluded-source-keys (append ebox-tree--runtime-source-keys ebox-tree--child-source-keys - '(:ebox-style-declarations :ebox-style-overrides + '(:ebox-style-overrides :ebox-grid-config :ebox-computed-style :ebox-style-wrapper - :ebox-style-generated-wrapper)) - "Non-rendering keys excluded from node-local source comparison. -Cascade declarations and computed-style records are side state; their -projected Ebox longhands carry the actual paint and layout consequences.") + :ebox-style-generated-wrapper + :ebox-author-style-count :ebox-author-style-required-p + :ebox-text-value :ebox-candidate-computed-style-p)) + "Derived keys excluded from node-local source comparison. +Canonical declarations remain source facts and therefore participate in diff; +their property-level delta is normalized by the incremental candidate boundary. +Computed styles, projection counters, duplicate Text storage, and legacy wrapper +overrides are derived side state.") (defconst ebox-tree-metadata-source-keys '(:key :id :class :host-ref) @@ -1073,12 +1077,6 @@ candidate runtime index agree." (setq props (append props (list key (plist-get node key))))))) props)) -(defun ebox-tree-flex-item-source-node (node) - "Return the renderable source node for a flex item candidate." - (if (and (listp node) (eq (plist-get node :ebox-type) 'flex-item)) - (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)) @@ -1086,24 +1084,22 @@ candidate runtime index agree." (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 - (if (plist-member node :children) - (plist-get node :children) + (if (plist-get node :ebox-child-sequence) + (ebox-tree-layout-children node) + (if (plist-member node :children) + (plist-get node :children) (when-let* ((content-node (plist-get node :ebox-content-node))) - (ebox-tree--semantic-layout-leaves 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 (ebox-tree-layout-children node))) + ('flex (ebox-tree-layout-children node)) ('grid (ebox-tree-layout-children node)) - ('flex-item (delq nil (list (plist-get node :node)))) (_ (ebox-tree-children node)))) (defun ebox-tree-selector-index (root) @@ -1184,14 +1180,8 @@ candidate runtime index agree." (defun ebox-tree-flex-participation-props (node) "Return raw flex item participation props attached to NODE." - (cond - ((and (listp node) (eq (plist-get node :ebox-type) 'flex-item)) - (append (plist-get node :props) - (ebox-tree-flex-direct-participation-props - (plist-get node :node)))) - ((listp node) - (ebox-tree-flex-direct-participation-props node)) - (t nil))) + (and (listp node) + (ebox-tree-flex-direct-participation-props node))) (defun ebox-tree-node-all-region-ids (node) "Return every box region id contained by NODE. @@ -1238,8 +1228,6 @@ not only the public update ids." (apply #'append (mapcar #'ebox-tree-node-all-region-ids (ebox-tree-layout-children node))))) - ('flex-item - (ebox-tree-node-all-region-ids (plist-get node :node))) (_ nil)))) (when ebox--node-region-ids-cache (puthash node region-ids ebox--node-region-ids-cache)) @@ -1284,9 +1272,6 @@ cannot be inspected and reports no conflict." (cl-some (lambda (child) (ebox-tree-node-paint-conflict-p child paint-key t)) (ebox-tree-layout-children node)))) - ('flex-item - (ebox-tree-node-paint-conflict-p - (plist-get node :node) paint-key inherited)) (_ nil))))) (defun ebox-tree-node-path-to-region (node region-id) @@ -1341,14 +1326,16 @@ wrapper's visible content is produced by the flex renderer." (when-let* ((path (ebox-tree-node-path-to-region child region-id))) (throw 'found (append path (list node))))) nil)))) - ('flex-item - (ebox-tree-node-path-to-region (plist-get node :node) region-id)) (_ nil))))) (defun ebox-tree-node-visible-overflow-p (node) "Return non-nil when NODE contains visible overflow that cannot be span patched." (cond ((or (stringp node) (not (listp node))) nil) + ;; Canonical Text temporarily shares the legacy `ebox-type' storage tag so + ;; the old renderer can paint it. It is nevertheless a leaf, not a Box + ;; formatting context, and therefore cannot own geometric overflow. + ((eq (plist-get node :ebox-kind) 'text) nil) (t (pcase (plist-get node :ebox-type) ('box @@ -1371,20 +1358,18 @@ wrapper's visible content is produced by the flex renderer." (ebox--box-visible-overflow-p (plist-get node :box))) (cl-some #'ebox-tree-node-visible-overflow-p (ebox-tree-layout-children node)))) - ('flex-item - (ebox-tree-node-visible-overflow-p (plist-get node :node))) (_ nil))))) (defalias 'ebox--computed-display #'ebox-tree-computed-display) (defalias 'ebox--display-outer #'ebox-tree-display-outer) (defalias 'ebox--display-inner #'ebox-tree-display-inner) (defalias 'ebox--formatting-context-p #'ebox-tree-formatting-context-p) +(defalias 'ebox--layout-props #'ebox-tree-layout-props) (defalias 'ebox--node-selector-type #'ebox-tree-node-selector-type) (defalias 'ebox--layout-children #'ebox-tree-layout-children) (defalias 'ebox--node-children #'ebox-tree-children) (defalias 'ebox--flex-direct-participation-props #'ebox-tree-flex-direct-participation-props) -(defalias 'ebox--flex-item-source-node #'ebox-tree-flex-item-source-node) (defalias 'ebox--flex-participation-props #'ebox-tree-flex-participation-props) (defalias 'ebox--node-all-region-ids #'ebox-tree-node-all-region-ids) diff --git a/ebox.el b/ebox.el index f8593ac..84a6164 100644 --- a/ebox.el +++ b/ebox.el @@ -432,6 +432,7 @@ publication." (defun ebox--ensure-region-id (box) "Return BOX's stable region id, creating one if needed." + (ebox--ensure-node-id box) (or (ebox-get box :region-id) (let ((id (ebox--next-region-id))) (ebox-put box :region-id id) @@ -632,18 +633,7 @@ ALIGN can be `top', `center', or `bottom'." "Return NODE's region ids in document order. NODE can be an unrendered layout tree or an already-rendered string. For layout trees, missing ids are assigned to box plists and remain stable -for later renders and dynamic updates. - -Use this to capture ids *before* inserting into a buffer: - - (let* ((layout (ebox-stack info-box (ebox-concat left right))) - (ids (ebox-region-ids layout)) - (info-id (nth 0 ids)) - (left-id (nth 1 ids)) - (right-id (nth 2 ids))) - (ebox-render-to-buffer \"*my-ui*\" layout) - ;; now use info-id / left-id / right-id for dynamic updates - )" +for later renders and dynamic updates." (cond ((stringp node) (ebox--string-region-ids node)) @@ -677,8 +667,6 @@ Use this to capture ids *before* inserting into a buffer: (apply #'append (mapcar #'ebox-region-ids (ebox-tree-layout-children node))))) - ((eq type 'flex-item) - (ebox-region-ids (plist-get node :node))) (t nil)))))) (defun ebox--runtime-node-ids (node) @@ -3796,9 +3784,10 @@ With prefix ARG, scroll by that many content lines." (defun ebox--clear-buffer-runtime-state (&optional buffer) "Remove all runtime caches owned by BUFFER. Defaults to the current buffer." - (let ((buffer (or buffer (current-buffer)))) + (let* ((buffer (or buffer (current-buffer))) + (state (ebox--buffer-render-state buffer))) (ebox--cancel-buffer-runtime-prewarm buffer) - (when-let* ((root (ebox--buffer-root-node buffer))) + (when-let* ((root (plist-get state :root-node))) (ebox--clear-region-runtime-caches (ebox--node-all-region-ids root))) (ebox--clear-buffer-render-state buffer))) @@ -3849,6 +3838,19 @@ Defaults to the current buffer." (:background-color :bgcolor) (_ property))) +(defun ebox--region-update-direct-text-node (box) + "Return BOX's sole direct canonical Text child, or nil." + (let ((children (and (ebox-box-node-p box) + (ebox-box-node-children box)))) + (and (= (length children) 1) + (ebox-text-node-p (car children)) + (car children)))) + +(defun ebox--region-update-content-value (box) + "Return the mutable region content represented by canonical BOX." + (when-let* ((text (ebox--region-update-direct-text-node box))) + (ebox-text-node-value text))) + (defun ebox--region-update-analysis (box expanded declarations) "Return change analysis for BOX, EXPANDED fields, and DECLARATIONS." (let (content-seen @@ -3867,12 +3869,18 @@ Defaults to the current buffer." (setq style-changed-p t))) (t (push key unknown-keys)))) + (when (and content-seen (not (stringp content-value))) + (user-error "Ebox content update requires a string")) + (when (and content-seen + (not (ebox--region-update-direct-text-node box))) + (user-error "Ebox content update requires one direct Text child")) (list :changed-p (or (not (equal declarations (plist-get box :ebox-style-declarations))) style-changed-p (and content-seen - (not (equal (ebox-get box :content) content-value)))) + (not (equal (ebox--region-update-content-value box) + content-value)))) :unknown-keys (nreverse unknown-keys)))) (defun ebox--schedule-runtime-prewarm-after-batch-flush @@ -3900,7 +3908,7 @@ cannot be changed through the unpublished batch root." (ebox-tree-copy-node-structure batch-root)) (and path-copy-p (ebox-incremental-surface-region-candidate-root - buffer region-id)) + buffer region-id t)) (ebox-tree-copy-node-structure (plist-get state :root-node))))) (defun ebox--surface-region-apply-props @@ -3910,18 +3918,28 @@ cannot be changed through the unpublished batch root." (unless box (user-error "Ebox region handle no longer resolves to a box")) (let ((declarations-changed - (not (equal declarations - (plist-get box :ebox-style-declarations))))) + (not (ebox-style-declarations-equal-p + declarations + (plist-get box :ebox-style-declarations))))) (when declarations-changed (plist-put box :ebox-style-declarations declarations)) (cl-loop for (key value) on expanded by #'cddr for target-key = (ebox--region-update-normalize-property key) when (or (eq target-key :content) (memq target-key ebox--region-update-longhand-props)) - when (or (not (equal (ebox-get box target-key) value)) + when (or (not (equal (if (eq target-key :content) + (ebox--region-update-content-value box) + (ebox-get box target-key)) + value)) (and declarations-changed (ebox-style-property key))) - do (plist-put box target-key value) + do (if (eq target-key :content) + (if-let* ((text + (ebox--region-update-direct-text-node box))) + (plist-put text :ebox-text-value value) + (user-error + "Ebox content update requires one direct Text child")) + (plist-put box target-key value)) and do (push target-key changed-keys))) (nreverse changed-keys))) @@ -4176,7 +4194,7 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." :content) (stringp (cadr expanded)) (numberp (ebox-get candidate-box :width)) - (null (ebox-tree-node-children candidate-box)) + (ebox--region-update-direct-text-node candidate-box) (not (ebox-style-cascade-active-p)) (not (plist-get state :cascade-required-p)) (null (plist-get state :scroll-region-ids)))) @@ -4296,8 +4314,7 @@ Examples: ;;;###autoload (defun ebox-render-to-buffer (buffer-or-name node) "Render layout NODE into BUFFER-OR-NAME and return the buffer. -NODE can be any box/concat/stack node created by `ebox-create', -`ebox-concat', or `ebox-stack', or a pre-rendered string." +NODE is a typed TextNode/BoxNode or a pre-rendered string." (declare (indent 1)) (unless (stringp node) ;; Validate opaque host identities before preparing a target generation. @@ -4591,22 +4608,15 @@ through dirty-set and patch-set execution before falling back to root rerender." ebox-candidate-replace-host-ref ebox-candidate-patch-host-paint ebox-clear-cache - ebox-column ebox-column-layout-create ebox-normal-layout-create ebox-row-layout-create ebox-text-create ebox-box-create ebox-commit - ebox-concat - ebox-create ebox-display-buffer ebox-display-signature - ebox-flex ebox-flex-layout-create - ebox-flex-item - ebox-grid - ebox-grid-fr ebox-grid-layout-create ebox-host-ref-bounds ebox-host-ref-position @@ -4620,7 +4630,6 @@ through dirty-set and patch-set execution before falling back to root rerender." ebox-render-burst-end ebox-render-to-buffer ebox-rerender-buffer-with-context - ebox-row ebox-scroll-down ebox-scroll-map ebox-scroll-page-down @@ -4633,8 +4642,6 @@ through dirty-set and patch-set execution before falling back to root rerender." ebox-selector-query-all ebox-selector-query-buffer ebox-selector-update-buffer - ebox-spacer - ebox-stack ebox-string-pixel-width ebox-update-selector ebox-viewport-window-width @@ -4684,29 +4691,10 @@ and are documented separately in the public API reference.") ;; ebox-normal-layout-create ;; Create the typed Normal LayoutConfig. ;; ebox-box-create :layout config :children nodes &rest geometry-props -;; Create one typed canonical BoxNode. The current vertical slice -;; accepts Normal layout with zero or one canonical child. -;; ebox-create &rest plist -;; Legacy untyped runtime constructor retained during migration. -;; Key options: :content :width :height -;; :padding :margin :border :border-color :bgcolor -;; :text-align :wrap-mode :box-sizing :surface-properties :host-ref +;; Create one typed canonical BoxNode. ;; ebox-build dsl -;; Compile ETML-style (box/row/column/flex/spacer ...) forms into -;; normal ebox layout nodes. -;; -;; ── Layout ──────────────────────────────────────────────────── -;; ebox-concat left right → horizontal side-by-side -;; ebox-stack top bottom → vertical stacking (nest for 3+) -;; ebox-row &rest nodes → horizontal layout from many nodes -;; ebox-column &rest nodes → vertical layout from many nodes -;; ebox-spacer &rest plist → blank box node -;; ebox-flex &rest items → flex layout from boxes/items -;; Container may also carry normal ebox-create box properties. -;; ebox-flex-item node &rest plist -;; Attach :order :flex-grow :flex-shrink :flex-basis :flex -;; and :align-self metadata to a child node; non-flex properties -;; wrap the child in an ebox-create box. +;; Compile String/text/box/row/column/flex/grid author forms into +;; typed TextNode/BoxNode values. ;; ;; ── Rendering ───────────────────────────────────────────────── ;; ebox-render box → propertized string (no buffer side-effect) diff --git a/native/core/src/lib.rs b/native/core/src/lib.rs index c3045c4..8f9cca8 100644 --- a/native/core/src/lib.rs +++ b/native/core/src/lib.rs @@ -230,14 +230,15 @@ fn push_edit( }); } -fn coordinate_edits(old: &[T], new: &[T], text_equal: &F) -> Vec -where - F: Fn(&T, &T) -> bool, -{ +fn coordinate_edits( + old_len: usize, + new_len: usize, + text_matches: &[(usize, usize, usize)], +) -> Vec { let mut old_position = 0; let mut new_position = 0; let mut edits = Vec::new(); - for (matched_old, matched_new, length) in matches(old, new, text_equal) { + for &(matched_old, matched_new, length) in text_matches { if old_position < matched_old || new_position < matched_new { push_edit( &mut edits, @@ -253,28 +254,26 @@ where push_edit( &mut edits, old_position, - old.len(), + old_len, new_position, - new.len(), + new_len, ); edits } -fn semantic_edits( +fn semantic_edits( old: &[T], new: &[T], - text_equal: &FText, + text_matches: &[(usize, usize, usize)], semantic_equal: &FSemantic, ) -> Vec where - FText: Fn(&T, &T) -> bool, FSemantic: Fn(&T, &T) -> bool, { - let text_matches = matches(old, new, text_equal); let mut old_position = 0; let mut new_position = 0; let mut edits = Vec::new(); - for (matched_old, matched_new, length) in text_matches { + for &(matched_old, matched_new, length) in text_matches { if old_position < matched_old || new_position < matched_new { push_edit( &mut edits, @@ -343,13 +342,14 @@ where target: target_revision, }); } + let text_matches = matches(old, new, &text_equal); Ok(CommitBatch { base_revision, target_revision, base_extent: old.len(), target_extent: new.len(), - semantic_edits: semantic_edits(old, new, &text_equal, &semantic_equal), - coordinate_edits: coordinate_edits(old, new, &text_equal), + semantic_edits: semantic_edits(old, new, &text_matches, &semantic_equal), + coordinate_edits: coordinate_edits(old.len(), new.len(), &text_matches), }) } diff --git a/native/src/layout.rs b/native/src/layout.rs index 6be0278..bb7e995 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -121,6 +121,14 @@ enum LayoutNode { Box { region_id: i64, content: Option>, + #[serde(default)] + content_region_id: Option, + #[serde(default)] + content_typography_style: Option, + #[serde(default)] + content_foreground_style: Option, + #[serde(default)] + content_surface_template_id: Option, child: Option>, content_width_exact: bool, #[serde(default)] @@ -142,6 +150,8 @@ enum LayoutNode { margin_bottom: i64, border_left: i64, border_right: i64, + #[serde(default)] + typography_style: Option, foreground_style: Option, background_style: Option, border_left_style: Option, @@ -156,6 +166,16 @@ enum LayoutNode { wrap_mode: WrapMode, scroll_offset: i64, }, + Text { + region_id: i64, + content: Box, + #[serde(default)] + typography_style: Option, + foreground_style: Option, + #[serde(default)] + surface_template_id: Option, + wrap_mode: WrapMode, + }, Row { children: Vec, }, @@ -1249,6 +1269,9 @@ fn composed_face( list_value = false; } StyleMode::Add => { + if faces.len() == 1 && styles[faces[0]].face == style.face { + continue; + } if !faces.is_empty() { list_value = true; } @@ -2441,6 +2464,7 @@ fn encode_patch_combined_payload( /// Emacs adapter payload built on the editor-independent core batch. struct EmacsCommitBatch { core: CommitBatch, + publication_edits: Vec, combined_payload: Vec, metadata_records: usize, fragment_bytes: Vec, @@ -2451,6 +2475,24 @@ struct EmacsCommitBatch { fragment_style_delta: Option, } +fn coalesce_publication_edits(edits: &[SpanEdit]) -> Vec { + const MAX_UNCHANGED_GAP: usize = 1; + let mut result: Vec = Vec::with_capacity(edits.len()); + for &edit in edits { + if let Some(previous) = result.last_mut() { + let old_gap = edit.old_start.saturating_sub(previous.old_end); + let new_gap = edit.new_start.saturating_sub(previous.new_end); + if old_gap == new_gap && old_gap <= MAX_UNCHANGED_GAP { + previous.old_end = edit.old_end; + previous.new_end = edit.new_end; + continue; + } + } + result.push(edit); + } + result +} + fn build_emacs_commit_batch( old: &FlatLayoutTape, target: &FlatLayoutTape, @@ -2460,8 +2502,8 @@ fn build_emacs_commit_batch( max_bytes: usize, ) -> Result { let core = tape_commit_batch(&old.characters, &target.characters); - let descriptor_bytes = core - .semantic_edits + let publication_edits = coalesce_publication_edits(&core.semantic_edits); + let descriptor_bytes = publication_edits .len() .checked_add(core.coordinate_edits.len()) .ok_or_else(|| "Native layout patch descriptor count overflow".to_owned())? @@ -2523,7 +2565,7 @@ fn build_emacs_commit_batch( }; let replacements = encode_patch_replacement_body( &target.characters, - &core.semantic_edits, + &publication_edits, compiled_styles, payload_limit, )?; @@ -2552,6 +2594,7 @@ fn build_emacs_commit_batch( }; Ok(EmacsCommitBatch { core, + publication_edits, combined_payload, metadata_records, fragment_bytes, @@ -2588,7 +2631,7 @@ pub(crate) fn encode_layout_patch_tape( root_metadata, max_bytes, )?; - let patch_count = count_u32(batch.core.semantic_edits.len(), "patches")?; + let patch_count = count_u32(batch.publication_edits.len(), "patches")?; let coordinate_patch_count = count_u32(batch.core.coordinate_edits.len(), "coordinate patches")?; let target_character_count = u64::try_from(target.characters.len()) @@ -2598,9 +2641,10 @@ pub(crate) fn encode_layout_patch_tape( let mut writer = TapeWriter::new(max_bytes)?; writer.push_u64(base_character_count)?; writer.push_u32(patch_count)?; - // Publication remains a minimal text/property patch. The compact target - // role sidecar lets Emacs swap exact runtime indexes without synchronously - // rescanning the page or rebuilding thousands of markers per frame. + // Publication coalesces only one-character equal-coordinate gaps. The + // pure core retains its exact minimal semantic edits, while the compact + // target role sidecar lets Emacs swap exact runtime indexes without + // synchronously rescanning the page or rebuilding thousands of markers. writer.push_u32( u32::from(batch.reuse_fragment_template) | (u32::from(batch.reuse_ownership_template) << 1) @@ -2625,7 +2669,7 @@ pub(crate) fn encode_layout_patch_tape( )?; writer.push_u32(coordinate_patch_count)?; writer.push_u32(0)?; - for patch in &batch.core.semantic_edits { + for patch in &batch.publication_edits { writer.push_u64(patch.old_start as u64)?; writer.push_u64(patch.old_end as u64)?; writer.push_u64(patch.new_start as u64)?; @@ -2991,6 +3035,7 @@ fn add_context_work( work_units: &mut usize, ) -> Result<(), String> { match node { + LayoutNode::Text { .. } => {} LayoutNode::Box { child, height, @@ -3063,9 +3108,59 @@ fn validate_node( } add_work_units(work_units, 1)?; match node { + LayoutNode::Text { + region_id, + content, + typography_style, + foreground_style, + surface_template_id, + .. + } => { + if *region_id <= 0 { + return Err("Native layout Text region id must be positive".to_owned()); + } + for style_id in [typography_style, foreground_style].into_iter().flatten() { + if *style_id >= style_count { + return Err("Native layout Text style id exceeds the style table".to_owned()); + } + } + if let Some(template_id) = surface_template_id { + if *template_id >= property_template_count { + return Err( + "Native layout Text property template id exceeds the property template table" + .to_owned(), + ); + } + } + if content.lines.is_empty() { + return Err("Native layout Text must contain one line".to_owned()); + } + add_work_units(work_units, content.lines.len())?; + for line in &content.lines { + add_work_units(work_units, line.clusters.len())?; + for cluster in &line.clusters { + if cluster.text.is_empty() { + return Err("Native layout Text cluster cannot be empty".to_owned()); + } + validate_dimension("Text cluster width", cluster.width)?; + if let Some(template_id) = cluster.source_template_id { + if template_id >= property_template_count { + return Err( + "Native layout Text property template id exceeds the property template table" + .to_owned(), + ); + } + } + } + } + } LayoutNode::Box { region_id, content, + content_region_id, + content_typography_style, + content_foreground_style, + content_surface_template_id, child, content_width_exact: _, content_min_width, @@ -3085,6 +3180,7 @@ fn validate_node( margin_bottom, border_left, border_right, + typography_style, foreground_style, background_style, border_left_style, @@ -3106,6 +3202,14 @@ fn validate_node( "Native layout box must contain exactly one text or child value".to_owned(), ); } + if content_region_id.is_some() && content.is_none() { + return Err("Native layout box content identity requires measured text".to_owned()); + } + if let Some(content_region_id) = content_region_id { + if *content_region_id <= 0 { + return Err("Native layout box content region id must be positive".to_owned()); + } + } for (name, size) in [ ("width", width), ("min-width", min_width), @@ -3140,6 +3244,9 @@ fn validate_node( .unwrap_or(usize::MAX), )?; for style_id in [ + content_typography_style, + content_foreground_style, + typography_style, foreground_style, background_style, border_left_style, @@ -3162,6 +3269,14 @@ fn validate_node( ); } } + if let Some(template_id) = content_surface_template_id { + if *template_id >= property_template_count { + return Err( + "Native layout box content property template id exceeds the property template table" + .to_owned(), + ); + } + } if let Some(text) = content { if text.lines.is_empty() { return Err("Native layout measured text must contain one line".to_owned()); @@ -4660,6 +4775,7 @@ fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered { fn exact_rendered_height(node: &LayoutNode, context: LayoutContext) -> Option { match node { + LayoutNode::Text { content, .. } => i64::try_from(content.lines.len()).ok(), LayoutNode::Box { height, min_height, @@ -5158,6 +5274,10 @@ fn render_node_with_override( LayoutNode::Box { region_id, content, + content_region_id, + content_typography_style, + content_foreground_style, + content_surface_template_id, child, content_width_exact, content_min_width: _, @@ -5178,6 +5298,7 @@ fn render_node_with_override( margin_bottom, border_left, border_right, + typography_style, foreground_style, background_style, border_left_style, @@ -5439,7 +5560,16 @@ fn render_node_with_override( } let mut formatted = if let Some(text) = content { - measured_lines(text, content_width, *wrap_mode) + let mut content_lines = measured_lines(text, content_width, *wrap_mode); + if let Some(content_region_id) = content_region_id { + for (index, line) in content_lines.iter_mut().enumerate() { + line.own_content(*content_region_id, index as i64); + line.apply_style(*content_typography_style); + line.apply_style(*content_foreground_style); + line.apply_property_template(*content_surface_template_id); + } + } + content_lines .into_iter() .map(|line| line.padded(content_width, *text_align)) .collect::>() @@ -5580,6 +5710,7 @@ fn render_node_with_override( *padding_right, region_properties(RegionRole::PaddingRight, *region_id, None), ); + line.apply_style(*typography_style); line.apply_style(*foreground_style); line.apply_style(*background_style); line.prepend_space_with_properties( @@ -5648,6 +5779,24 @@ fn render_node_with_override( } Ok(rendered) } + LayoutNode::Text { + region_id, + content, + typography_style, + foreground_style, + surface_template_id, + wrap_mode, + } => { + let width = measured_max_width(content); + let mut lines = measured_lines(content, width, *wrap_mode); + for (index, line) in lines.iter_mut().enumerate() { + line.own_content(*region_id, index as i64); + line.apply_style(*typography_style); + line.apply_style(*foreground_style); + line.apply_property_template(*surface_template_id); + } + Ok(Rendered::from_lines(lines)) + } LayoutNode::Row { children } => { let child_context = LayoutContext { inline_auto_width_intrinsic: true, @@ -5801,6 +5950,10 @@ mod tests { LayoutNode::Box { region_id, content: Some(Box::new(content)), + content_region_id: None, + content_typography_style: None, + content_foreground_style: None, + content_surface_template_id: None, child: None, content_width_exact: false, content_min_width: None, @@ -5821,6 +5974,7 @@ mod tests { margin_bottom: 0, border_left: 0, border_right: 0, + typography_style: None, foreground_style: None, background_style: None, border_left_style: None, @@ -5857,6 +6011,10 @@ mod tests { LayoutNode::Box { region_id, content: None, + content_region_id: None, + content_typography_style: None, + content_foreground_style: None, + content_surface_template_id: None, child: Some(Box::new(child)), content_width_exact: true, content_min_width: None, @@ -5877,6 +6035,7 @@ mod tests { margin_bottom: 0, border_left: 0, border_right: 0, + typography_style: None, foreground_style: None, background_style: None, border_left_style: None, @@ -5956,6 +6115,80 @@ mod tests { } } + #[test] + fn typed_column_lowering_stretches_centered_child() { + let mut text = auto_text_box(3, measured_text(vec![vec![cluster("x", 10, None)]]), None); + let LayoutNode::Box { width, .. } = &mut text else { + unreachable!(); + }; + *width = Size::MaxContent; + let mut label = child_box(2, text, None); + let LayoutNode::Box { + width, + min_width, + content_width_exact, + text_align, + .. + } = &mut label + else { + unreachable!(); + }; + *width = Size::Auto; + *min_width = Size::MaxContent; + *content_width_exact = false; + *text_align = HorizontalAlign::Center; + let column = LayoutNode::Flex { + direction: FlexDirection::Column, + wrap: FlexWrap::Nowrap, + justify: FlexAlign::FlexStart, + align_items: FlexAlign::Stretch, + align_content: FlexAlign::Stretch, + width: Size::Stretch, + height: Size::Auto, + row_gap: 0, + column_gap: 0, + items: vec![FlexItem { + node: label, + order: 0, + grow: 0.0, + shrink: 0.0, + basis: Size::Auto, + align_self: FlexAlign::Stretch, + }], + }; + let mut outer = child_box(1, column, None); + let LayoutNode::Box { + width, + content_width_exact, + .. + } = &mut outer + else { + unreachable!(); + }; + *width = Size::Auto; + *content_width_exact = true; + let rendered = render_node_with_override( + &outer, + test_context(), + false, + Some(BoxOverride { + content_width: Some(100), + content_height: None, + declared_width: None, + }), + ) + .expect("typed column render"); + assert_eq!(rendered.lines[0].width, 100); + assert!( + matches!( + rendered.lines[0].atoms.first(), + Some(Atom::Space { width: 45, .. }) + ), + "{:?}", + rendered.lines[0].atoms + ); + } + fn literal_from_full_tape(tape: &[u8]) -> &str { let literal_length = u64::from_le_bytes(tape[112..120].try_into().unwrap()) as usize; let line_count = u32::from_le_bytes(tape[120..124].try_into().unwrap()) as usize; @@ -7151,7 +7384,7 @@ mod tests { assert_eq!(u64::from_le_bytes(encoded[92..100].try_into().unwrap()), 7); assert_eq!(u64::from_le_bytes(encoded[112..120].try_into().unwrap()), 6); let patch_count = u32::from_le_bytes(encoded[120..124].try_into().unwrap()) as usize; - assert_eq!(patch_count, 3); + assert_eq!(patch_count, 1); let reserved = u32::from_le_bytes(encoded[124..128].try_into().unwrap()); let metadata_records = u64::from_le_bytes(encoded[128..136].try_into().unwrap()); let payload_length = u64::from_le_bytes(encoded[136..144].try_into().unwrap()) as usize; @@ -7168,9 +7401,9 @@ mod tests { assert!(fragment_length > 0); assert!(fragment_records > 0); assert_eq!(encoded.len(), payload_end + fragment_length); - assert!(payload.starts_with("[#(\"Xe \"")); - assert!(payload.contains("1 2 (ebox-content 9 ebox-native-property-template-ids (4))")); - assert!(payload.contains("2 3 (display (space :width (7)))")); + assert!(payload.starts_with("[#(\"Xdef \"")); + assert!(payload.contains("2 3 (ebox-content 9 ebox-native-property-template-ids (4))")); + assert!(payload.contains("4 5 (display (space :width (7)))")); assert!(payload.contains("(:prepared-p t")); assert!(payload.contains("(9 content) ((5 . 6))")); assert!(payload.contains("data (9 (5 . 6))")); diff --git a/scripts/ebox-performance-evaluator.el b/scripts/ebox-performance-evaluator.el index 222a6f2..902e367 100644 --- a/scripts/ebox-performance-evaluator.el +++ b/scripts/ebox-performance-evaluator.el @@ -286,7 +286,7 @@ (let (cells) (dotimes (column 4) (push - (apply #'ebox-create + (apply #'ebox-test-box (append (list :key (list 'cell row column) :content (format "row-%d cell-%d\ndetail-%d-%d" @@ -299,24 +299,23 @@ '(:id target)) (when styled '(:class card)))) cells)) - (push (apply #'ebox-row (nreverse cells)) rows))) - (apply #'ebox-column (nreverse rows)))) + (push (apply #'ebox-test-row (nreverse cells)) rows))) + (apply #'ebox-test-column (nreverse rows)))) (defun ebox-performance-evaluator--resize-fixture () "Return a viewport-dependent wrapped flex fixture." (let (items) (dotimes (index 12) (push - (ebox-flex-item - (ebox-create - :key (list 'resize-item index) - :content (format "resize-%02d" index) - :width '(70) - :padding '(0 1) - :color "#172554" - :bgcolor "#DBEAFE")) + (ebox-test-box + :key (list 'resize-item index) + :content (format "resize-%02d" index) + :width '(70) + :padding '(0 1) + :color "#172554" + :bgcolor "#DBEAFE") items)) - (apply #'ebox-flex + (apply #'ebox-test-flex :key 'resize-root :width '(viewport) :flex-wrap 'wrap diff --git a/scripts/ebox-visual-check.el b/scripts/ebox-visual-check.el index cebe80a..cf4664e 100644 --- a/scripts/ebox-visual-check.el +++ b/scripts/ebox-visual-check.el @@ -28,7 +28,7 @@ (defun ebox-visual-check-layout () "Return the fixed layout used by visual verification." - (let* ((title (ebox-create + (let* ((title (ebox-test-box :content "Ebox Visual Check" :box-sizing 'border-box :width 74 @@ -37,7 +37,7 @@ :border-color "#4A90D9" :color "#EAF6FF" :bgcolor "#203040")) - (hidden (ebox-create + (hidden (ebox-test-box :content "Hidden: visible line\nHIDDEN-CLIPPED-LINE" :box-sizing 'border-box :width 24 @@ -48,7 +48,7 @@ :color "#F0FFF4" :bgcolor "#17351F" :overflow 'hidden)) - (scroll (ebox-create + (scroll (ebox-test-box :content "Scroll: first\nSCROLL-SECOND\nSCROLL-THIRD" :box-sizing 'border-box :width 24 @@ -59,7 +59,7 @@ :color "#FFF4E6" :bgcolor "#3A2410" :overflow 'scroll)) - (sized (ebox-create + (sized (ebox-test-box :content "border-box max-height\nline 2\nSIZED-CLIPPED" :box-sizing 'border-box :width 26 @@ -70,7 +70,7 @@ :color "#F8EEFF" :bgcolor "#2F1B3A" :overflow 'hidden)) - (footer (ebox-create + (footer (ebox-test-box :content "Checks: text properties + display specs + GUI screenshot" :box-sizing 'border-box :width 74 @@ -79,7 +79,7 @@ :border-color "#7F8C8D" :color "#F8F9F9" :bgcolor "#2C3E50"))) - (ebox-column title (ebox-row hidden scroll sized) footer))) + (ebox-test-column title (ebox-test-row hidden scroll sized) footer))) (defun ebox-visual-check--max-line-pixel-width (string) "Return maximum pixel width among STRING lines." diff --git a/tests/ebox-child-range-tests.el b/tests/ebox-child-range-tests.el index 8d9f600..64647e0 100644 --- a/tests/ebox-child-range-tests.el +++ b/tests/ebox-child-range-tests.el @@ -65,7 +65,7 @@ (ert-deftest ebox-child-range-render-signature-snapshots-item-identities () "A cached Range signature must not follow later runtime identity mutation." - (let* ((item (ebox-create :content "item" :key 'item)) + (let* ((item (ebox-test-box :content "item" :key 'item)) (sequence (ebox-child-range--build (list (cons 'range (list item))))) (published (aref (ebox-child-range--segment-payload @@ -270,17 +270,17 @@ (ert-deftest ebox-child-range-integration-is-transparent-and-indexed () "Compiled Range segments render as direct children and retain empty refs." - (let* ((static (ebox-create :key 'a :class "a" :content "A" :width '(40))) - (one (ebox-create :key 'b :class "b" :content "B" :width '(40))) - (two (ebox-create :key 'c :class "c" :content "C" :width '(40))) + (let* ((static (ebox-test-box :key 'a :class "a" :content "A" :width '(40))) + (one (ebox-test-box :key 'b :class "b" :content "B" :width '(40))) + (two (ebox-test-box :key 'c :class "c" :content "C" :width '(40))) (descriptor (ebox-child-range--descriptor-create 'items (list one two))) (empty (ebox-child-range--descriptor-create 'empty nil)) (raw-items (ebox-child-range--descriptor-items descriptor)) - (ranged (ebox-column static empty descriptor)) - (direct (ebox-column - (ebox-create :key 'a :class "a" :content "A" :width '(40)) - (ebox-create :key 'b :class "b" :content "B" :width '(40)) - (ebox-create :key 'c :class "c" :content "C" :width '(40)))) + (ranged (ebox-test-column static empty descriptor)) + (direct (ebox-test-column + (ebox-test-box :key 'a :class "a" :content "A" :width '(40)) + (ebox-test-box :key 'b :class "b" :content "B" :width '(40)) + (ebox-test-box :key 'c :class "c" :content "C" :width '(40)))) (left (generate-new-buffer " *ebox-range-direct*")) (right (generate-new-buffer " *ebox-range-compiled*"))) (unwind-protect @@ -335,32 +335,33 @@ (ert-deftest ebox-child-range-integration-rejects-invalid-placement-atomically () "Range refs, keys, nesting, roots, and scalar slots fail before publication." + (should-error + (ebox-test-column + (ebox-child-range--descriptor-create + 'outer (list (ebox-child-range--descriptor-create 'nested nil))))) + (should-error + (ebox-test-column + (ebox-test-box :key 'duplicate :content "a") + (ebox-child-range--descriptor-create + 'range (list (ebox-test-box :key 'duplicate :content "b"))))) (let ((buffer (generate-new-buffer " *ebox-range-invalid*"))) (unwind-protect (progn - (ebox-render-to-buffer buffer (ebox-create :key 'root :content "old")) + (ebox-render-to-buffer buffer (ebox-test-box :key 'root :content "old")) (let ((before (with-current-buffer buffer (buffer-string))) (state (ebox--buffer-render-state buffer))) (dolist (root (list (ebox-child-range--descriptor-create - 'root (list (ebox-create :key 'x :content "x"))) + 'root (list (ebox-test-box :key 'x :content "x"))) (list :ebox-type 'box :ebox-content-node (ebox-child-range--descriptor-create 'scalar nil)) - (ebox-column - (ebox-child-range--descriptor-create - 'outer - (list (ebox-child-range--descriptor-create 'nested nil)))) - (ebox-column + (ebox-test-column (ebox-child-range--descriptor-create 'same nil) - (ebox-column - (ebox-child-range--descriptor-create 'same nil))) - (ebox-column - (ebox-create :key 'duplicate :content "a") - (ebox-child-range--descriptor-create - 'range (list (ebox-create :key 'duplicate :content "b")))))) + (ebox-test-column + (ebox-child-range--descriptor-create 'same nil))))) (should-error (ebox-commit buffer root)) (should (eq (ebox--buffer-render-state buffer) state)) (should (equal (with-current-buffer buffer (buffer-string)) @@ -383,24 +384,24 @@ "Ranges add no ECSS subject and preserve child/sibling cascade semantics." (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) (direct-left - (ebox-create :key 'left :host-ref 'left :class "left" + (ebox-test-box :key 'left :host-ref 'left :class "left" :content "L" :width '(40))) (direct-right - (ebox-create :key 'right :host-ref 'right :class "right" + (ebox-test-box :key 'right :host-ref 'right :class "right" :content "R" :width '(40))) (range-left - (ebox-create :key 'left :host-ref 'left :class "left" + (ebox-test-box :key 'left :host-ref 'left :class "left" :content "L" :width '(40))) (range-right - (ebox-create :key 'right :host-ref 'right :class "right" + (ebox-test-box :key 'right :host-ref 'right :class "right" :content "R" :width '(40))) (left-range (ebox-child-range--descriptor-create 'left-range (list range-left))) (right-range (ebox-child-range--descriptor-create 'right-range (list range-right))) - (direct (plist-put (ebox-column direct-left direct-right) + (direct (plist-put (ebox-test-column direct-left direct-right) :class "parent")) - (ranged (plist-put (ebox-column left-range right-range) + (ranged (plist-put (ebox-test-column left-range right-range) :class "parent")) (direct-buffer (generate-new-buffer " *ebox-range-ecss-direct*")) (range-buffer (generate-new-buffer " *ebox-range-ecss-range*")) @@ -450,13 +451,13 @@ (ert-deftest ebox-child-range-path-copy-replaces-compiled-material-child () "Tree path copying cannot ignore a child stored in a compiled sequence." (let* ((source - (ebox-column - (ebox-create :key 'static :content "static") + (ebox-test-column + (ebox-test-box :key 'static :content "static") (ebox-child-range--descriptor-create - 'items (list (ebox-create :key 'old :content "old"))))) + 'items (list (ebox-test-box :key 'old :content "old"))))) (compiled (ebox-tree-copy-node-structure source)) (old-child (cadr (ebox-tree-layout-children compiled))) - (new-child (ebox-create :key 'new :content "new")) + (new-child (ebox-test-box :key 'new :content "new")) (unchanged (ebox-tree-copy-with-direct-child-replacements compiled nil)) (copy (ebox-tree-copy-with-direct-child-replacements compiled (list (cons old-child new-child))))) @@ -467,20 +468,49 @@ '(static new))) (should (equal (mapcar (lambda (node) (plist-get node :key)) (ebox-tree-layout-children compiled)) - '(static old))))) + '(static old))) + (let* ((original (ebox-text-create :value "old")) + (range (ebox-child-range 'typed original)) + (typed (ebox-box-create + :layout (ebox-column-layout-create) + :children (list range))) + (owned (car (ebox-box-node-children typed))) + (replacement (ebox-text-create :value "new")) + (replaced + (ebox-tree-copy-with-direct-child-replacements + typed (list (cons owned replacement)))) + direct) + (ebox-tree-for-each-direct-child + replaced (lambda (child) (push child direct))) + (setq direct (nreverse direct)) + (dolist (view (list (ebox-box-node-children replaced) + direct + (ebox-tree-layout-children replaced) + (ebox-tree-semantic-children replaced))) + (should (= (length view) 1)) + (should (eq (car view) replacement))) + (should (eq (ebox-tree-validate-declarative-root replaced) replaced)) + (let ((removed + (ebox-tree-copy-with-direct-child-replacements + typed (list (cons owned nil))))) + (should-not (ebox-box-node-children removed)) + (should-not (ebox-tree-layout-children removed)) + (should-not (ebox-tree-semantic-children removed)) + (should (eq (ebox-tree-validate-declarative-root removed) + removed)))))) (ert-deftest ebox-child-range-empty-sequence-is-authoritative () "An all-empty sequence cannot fall through to legacy alias children." (let* ((sequence (ebox-child-range--build (list (cons 'empty nil)))) - (legacy (ebox-create :key 'legacy :content "legacy")) + (legacy (ebox-test-box :key 'legacy :content "legacy")) (node (list :ebox-type 'stack :ebox-child-sequence sequence :top legacy :bottom legacy))) (should-not (ebox-tree-layout-children node)))) (ert-deftest ebox-child-range-validator-covers-wrapper-and-scalar-slots () "Flat Range compilation cannot hide wrappers or admit scalar descriptors." - (let* ((wrapper (ebox-create :key 'wrapper :host-ref 'wrapper :content "W")) - (item (ebox-create :key 'item :host-ref 'item :content "I")) + (let* ((wrapper (ebox-test-box :key 'wrapper :host-ref 'wrapper :content "W")) + (item (ebox-test-box :key 'item :host-ref 'item :content "I")) (descriptor (ebox-child-range--descriptor-create 'items (list item))) (source (list :ebox-type 'flex :display '(block flex) :box wrapper :children (list descriptor)))) @@ -498,23 +528,23 @@ (ebox-tree-validate-declarative-root (list :ebox-type 'stack :display '(block column) :top (ebox-child-range--descriptor-create 'bad nil) - :bottom (ebox-create :key 'bottom :content "B")))) + :bottom (ebox-test-box :key 'bottom :content "B")))) (should-error (ebox-tree-validate-declarative-root (list :ebox-type 'flex :display '(block flex) - :box (ebox-create :key 'same :host-ref 'same :content "W") + :box (ebox-test-box :key 'same :host-ref 'same :content "W") :children (list (ebox-child-range--descriptor-create 'items - (list (ebox-create :key 'item :host-ref 'same + (list (ebox-test-box :key 'item :host-ref 'same :content "I"))))))))) (ert-deftest ebox-child-range-public-candidate-splices-and-reports () "Public child Ranges support empty and repeated last-wins candidate splices." (let* ((buffer (generate-new-buffer " *ebox-public-range*")) (source - (ebox-column - (ebox-create :key 'static :content "S") + (ebox-test-column + (ebox-test-box :key 'static :content "S") (ebox-child-range 'items))) report) (unwind-protect @@ -522,10 +552,10 @@ (ebox-render-to-buffer buffer source) (dolist (items (list - (list (ebox-create :key 'one :content "1")) - (list (ebox-create :key 'two :content "2") - (ebox-create :key 'three :content "3")) - (list (ebox-create :key 'final :content "F")) + (list (ebox-test-box :key 'one :content "1")) + (list (ebox-test-box :key 'two :content "2") + (ebox-test-box :key 'three :content "3")) + (list (ebox-test-box :key 'final :content "F")) nil)) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'items items) @@ -545,23 +575,23 @@ ((rows (selected) (cl-loop for id from 1 to 12 collect - (ebox-create + (ebox-test-box :key id :content (format "row-%02d" id) :width '(420) :color (and (= id selected) "#2F6B43")))) (root () - (ebox-create + (ebox-test-box :width '(900) :color "#172033" :wrap-mode nil :ebox-content-node - (ebox-flex + (ebox-test-flex :width '(900) - (ebox-create + (ebox-test-box :key 'list :width 'stretch :min-width 0 :wrap-mode nil :min-height 24 :padding '(2 2) :border "#687386" :flex-grow 2 :flex-shrink 1 :flex-basis '(600) :ebox-content-node - (ebox-column + (ebox-test-column (apply #'ebox-child-range 'items (rows 1)))) - (ebox-create :key 'detail :host-ref 'detail + (ebox-test-box :key 'detail :host-ref 'detail :width 'stretch :min-width 0 :min-height 24 :padding '(2 2) :border "#687386" :flex-grow 1 :flex-shrink 1 :flex-basis '(300) @@ -576,7 +606,7 @@ candidate 'items (rows selected)) (ebox-candidate-replace-host-ref candidate 'detail - (ebox-create :key 'detail :host-ref 'detail + (ebox-test-box :key 'detail :host-ref 'detail :width 'stretch :min-width 0 :min-height 24 :padding '(2 2) :border "#687386" @@ -592,7 +622,8 @@ (should (memq (plist-get report :strategy) '(span-patch owner-rerender mixed-owner-reflow))) - (should (= (plist-get report :tp-scope-count) 3)) + (should (> (plist-get report :tp-scope-count) 0)) + (should-not (plist-get report :tp-full-root)) (should (= (plist-get report :created-objects) 0)) (should (= (plist-get report :removed-objects) 0)))))))) (when (buffer-live-p buffer) (kill-buffer buffer)))))) @@ -603,7 +634,7 @@ (cl-labels ((rows (keys) (mapcar (lambda (key) - (ebox-create :key key + (ebox-test-box :key key :content (format "row-%s" key))) keys)) (row-id (key) @@ -624,7 +655,7 @@ (progn (ebox-render-to-buffer buffer - (ebox-column + (ebox-test-column (apply #'ebox-child-range 'items (rows '(1 2 3 4))))) (let ((initial-1 (row-id 1)) (initial-4 (row-id 4))) @@ -643,24 +674,180 @@ (ert-deftest ebox-child-range-sole-child-keeps-public-layout-parent () "Keep row/column material parents for one empty or populated Range." - (dolist (constructor '(ebox-row ebox-column)) - (dolist (items (list nil (list (ebox-create :key 'one :content "1")))) + (dolist (case '((ebox-test-row row) (ebox-test-column column))) + (dolist (items (list nil (list (ebox-test-box :key 'one :content "1")))) (let* ((range (apply #'ebox-child-range 'items items)) - (parent (funcall constructor range))) + (parent (funcall (car case) range))) (should-not (ebox-child-range--descriptor-p parent)) - (should (eq (plist-get parent :ebox-type) - (if (eq constructor 'ebox-row) 'concat 'stack))) - (should (equal (plist-get parent :children) (list range))) + (should (ebox-box-node-p parent)) + (should (eq (ebox-layout-config-kind (ebox-box-node-layout parent)) + (cadr case))) + (should (equal (ebox-box-node-children parent) items)) + (should (equal (ebox-box-node-range-anchors parent) + (list (list :ref 'items :before 0 + :after (length items))))) (should (stringp (ebox-render parent))))))) -(ert-deftest ebox-child-range-dsl-layout-child-keeps-props-wrapper () - "Pass a Range through row/column DSL children while retaining properties." - (dolist (tag '(row column)) - (let* ((range (ebox-child-range 'items)) - (node (ebox-build (list tag :color "red" range)))) - (should (equal "red" (plist-get node :color))) - (should (not (ebox-child-range--descriptor-p node))) - (should-error (ebox-build range))))) +(ert-deftest ebox-child-range-typed-box-preserves-transparent-range () + "Typed Box should retain Range identity outside its visual child model." + (let* ((before (ebox-text-create :value "before" :key 'before)) + (first (ebox-text-create :value "A" :key 'first)) + (second (ebox-text-create :value "B" :key 'second)) + (after (ebox-text-create :value "after" :key 'after)) + (range (ebox-child-range 'items first second)) + (range-items (ebox-child-range--descriptor-items range)) + (box (ebox-box-create + :layout (ebox-column-layout-create) + :children (list before range after))) + (buffer (generate-new-buffer " *ebox-typed-range*"))) + (unwind-protect + (progn + (should (equal (ebox-box-node-children box) + (list before first second after))) + (cl-mapc (lambda (actual expected) + (should (eq actual expected))) + (ebox-box-node-children box) + (list before (car range-items) (cadr range-items) after)) + (should (equal (ebox-box-node-range-anchors box) + '((:ref items :before 1 :after 3)))) + (let* ((empty (ebox-child-range 'empty)) + (empty-box + (ebox-box-create + :layout (ebox-column-layout-create) + :children (list before empty after)))) + (should (equal (ebox-box-node-children empty-box) + (list before after))) + (should (equal (ebox-box-node-range-anchors empty-box) + '((:ref empty :before 1 :after 1))))) + (should + (equal + (mapcar (lambda (line) + (string-trim-right (substring-no-properties line))) + (ebox-string-lines (ebox-render box))) + '("before" "A" "B" "after"))) + (ebox-render-to-buffer buffer box) + (let* ((runtime-root (ebox--buffer-root-node buffer)) + (snapshot + (ebox-tree-runtime-identity-snapshot runtime-root))) + (should (= (plist-get snapshot :node-count) 5)) + (should (= (length (plist-get + (plist-get snapshot :root) :children)) + 4))) + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-range-ref + candidate 'items + (list (ebox-text-create :value "C" :key 'replacement))) + (ebox-commit buffer candidate)) + (should + (equal + (with-current-buffer buffer + (mapcar (lambda (line) + (string-trim-right + (substring-no-properties line))) + (ebox-string-lines (buffer-string)))) + '("before" "C" "after"))) + (let ((before-style-state (ebox--buffer-render-state buffer)) + (before-style-text + (with-current-buffer buffer (buffer-string))) + (candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-range-ref + candidate 'items + (list (ebox-test-build '(text :color "red" "B")))) + (cl-letf (((symbol-function 'accept-change-group) + (lambda (_group) (error "reject styled Range")))) + (should-error (ebox-commit buffer candidate) :type 'error)) + (should (eq before-style-state + (ebox--buffer-render-state buffer))) + (should (equal before-style-text + (with-current-buffer buffer (buffer-string))))) + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-range-ref + candidate 'items + (list (ebox-test-build '(text :color "red" "B")))) + (ebox-commit buffer candidate)) + (let* ((state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (rendered (with-current-buffer buffer (buffer-string))) + (position (let ((case-fold-search nil)) + (string-match "B" rendered))) + (face (and position + (get-text-property position 'face rendered)))) + (should (zerop (ebox-tree-author-style-count root))) + (should-not (plist-get state :cascade-required-p)) + (should + (or (and (listp face) + (keywordp (car face)) + (equal (plist-get face :foreground) "red")) + (cl-some (lambda (entry) + (and (listp entry) + (equal (plist-get entry :foreground) "red"))) + face)))) + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-range-ref + candidate 'items + (list (ebox-text-create :value "D" :key 'unstyled))) + (ebox-commit buffer candidate)) + (let* ((state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (rendered (with-current-buffer buffer (buffer-string))) + (position (string-match "D" rendered)) + (face (and position + (get-text-property position 'face rendered)))) + (should (zerop (ebox-tree-author-style-count root))) + (should-not + (or (and (listp face) + (keywordp (car face)) + (equal (plist-get face :foreground) "red")) + (cl-some (lambda (entry) + (and (listp entry) + (equal (plist-get entry :foreground) "red"))) + face)))) + (let ((before-invalid + (with-current-buffer buffer (buffer-string))) + (candidate (ebox-candidate-begin buffer))) + (should-error + (ebox-candidate-replace-range-ref + candidate 'items + (list (list :ebox-type 'box :content "legacy"))) + :type 'error) + (should (equal before-invalid + (with-current-buffer buffer (buffer-string)))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-child-range-typed-render-reuses-material-child-list () + "Typed Range rendering should not repeatedly flatten its child sequence." + (let* ((items + (cl-loop for index below 100 + collect (ebox-text-create + :value (number-to-string index) :key index))) + (range (apply #'ebox-child-range 'items items)) + (box (ebox-box-create + :layout (ebox-column-layout-create) + :children (list range))) + (original (symbol-function 'ebox-child-range--flatten)) + (calls 0)) + (cl-letf (((symbol-function 'ebox-child-range--flatten) + (lambda (sequence) + (cl-incf calls) + (funcall original sequence)))) + (ebox-render box)) + (should (zerop calls)) + (let* ((replacement (ebox-text-create :value "replacement" :key 'new)) + (updated + (car (ebox-child-range--replace + (plist-get box :ebox-child-sequence) + 'items (list replacement)))) + (candidate (copy-sequence box))) + (ebox-tree--put-child-sequence candidate updated) + (setq calls 0) + (cl-letf (((symbol-function 'ebox-child-range--flatten) + (lambda (sequence) + (cl-incf calls) + (funcall original sequence)))) + (ebox-tree-subject-index candidate) + (ebox-tree-layout-children candidate)) + (should (= calls 1))))) (ert-deftest ebox-child-range-empty-owner-falls-back-atomically () "Publish empty sole Range through full surface fallback and rollback safely." @@ -668,17 +855,17 @@ (unwind-protect (progn (ebox-render-to-buffer buffer - (ebox-column (ebox-child-range 'items))) + (ebox-test-column (ebox-child-range 'items))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref - candidate 'items (list (ebox-create :key 'one :content "1"))) + candidate 'items (list (ebox-test-box :key 'one :content "1"))) (cl-letf (((symbol-function 'accept-change-group) (lambda (&rest _) (error "injected empty accept")))) (should-error (ebox-commit buffer candidate) :type 'error))) (should (equal "" (with-current-buffer buffer (buffer-string)))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref - candidate 'items (list (ebox-create :key 'one :content "1"))) + candidate 'items (list (ebox-test-box :key 'one :content "1"))) (let ((report (ebox-commit buffer candidate))) (should (plist-get report :empty-range-owner-fallback)))) (should (string-match-p "1" (with-current-buffer buffer @@ -694,20 +881,20 @@ (let* ((buffer (generate-new-buffer " *ebox-range-no-flatten*")) (static (cl-loop for index below 100 - collect (ebox-create :key (list 'static index) + collect (ebox-test-box :key (list 'static index) :content (number-to-string index)))) - (root (apply #'ebox-column + (root (apply #'ebox-test-column (append static (list (ebox-child-range 'items - (ebox-create :key 'old :content "old")))))) + (ebox-test-box :key 'old :content "old")))))) candidate logical trace deltas) (unwind-protect (progn (ebox-render-to-buffer buffer root) (setq candidate (ebox-candidate-begin buffer)) (ebox-candidate-replace-range-ref - candidate 'items (list (ebox-create :key 'new :content "new"))) + candidate 'items (list (ebox-test-box :key 'new :content "new"))) (setq trace (make-hash-table :test 'equal)) (cl-letf (((symbol-function 'ebox-child-range--flatten) (lambda (&rest _) (error "candidate flattened Range")))) @@ -730,15 +917,15 @@ (progn (ebox-render-to-buffer buffer - (ebox-column - (ebox-child-range 'left (ebox-create :key 'move :content "L")) - (ebox-child-range 'right (ebox-create :key 'stay :content "R")))) + (ebox-test-column + (ebox-child-range 'left (ebox-test-box :key 'move :content "L")) + (ebox-child-range 'right (ebox-test-box :key 'stay :content "R")))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'left nil) (ebox-candidate-replace-range-ref candidate 'right - (list (ebox-create :key 'move :content "M") - (ebox-create :key 'stay :content "R"))) + (list (ebox-test-box :key 'move :content "M") + (ebox-test-box :key 'stay :content "R"))) (ebox-commit buffer candidate)) (should (string-match-p "M" (with-current-buffer buffer (buffer-string))))) @@ -749,13 +936,13 @@ (dolist (static-count '(10 100 500)) (let* ((buffer (generate-new-buffer " *ebox-range-public-gate*")) (root - (apply #'ebox-column + (apply #'ebox-test-column (append (cl-loop for index below static-count - collect (ebox-create :key (list 'static index) + collect (ebox-test-box :key (list 'static index) :content "s")) (list (ebox-child-range - 'items (ebox-create :key 'old :content "o"))))))) + 'items (ebox-test-box :key 'old :content "o"))))))) (unwind-protect (progn (ebox-render-to-buffer buffer root) @@ -768,8 +955,8 @@ report) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-create :key 'new-a :content "a") - (ebox-create :key 'new-b :content "b"))) + (list (ebox-test-box :key 'new-a :content "a") + (ebox-test-box :key 'new-b :content "b"))) (cl-letf (((symbol-function 'ebox--runtime-index) (lambda (&rest _) (error "unexpected full index")))) (setq report (ebox-commit buffer candidate))) @@ -814,13 +1001,13 @@ (progn (ebox-render-to-buffer buffer - (ebox-column (ebox-create :key 'a :host-ref 'a :content "a") - (ebox-create :key 'b :content "b"))) + (ebox-test-column (ebox-test-box :key 'a :host-ref 'a :content "a") + (ebox-test-box :key 'b :content "b"))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'a - (ebox-column - (ebox-create :key 'a :host-ref 'a :content "a") + (ebox-test-column + (ebox-test-box :key 'a :host-ref 'a :content "a") (ebox-child-range 'introduced))) (should-error (ebox-candidate-replace-range-ref candidate 'introduced nil)) @@ -828,7 +1015,7 @@ (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'introduced - (list (ebox-create :key 'later :content "later"))) + (list (ebox-test-box :key 'later :content "later"))) (ebox-commit buffer candidate)) (should (string-match-p "later" (with-current-buffer buffer (buffer-string))))) @@ -842,15 +1029,15 @@ (progn (ebox-render-to-buffer buffer - (ebox-column + (ebox-test-column (plist-put - (ebox-column - (ebox-create :key 'static :content "s") + (ebox-test-column + (ebox-test-box :key 'static :content "s") (ebox-child-range - 'items (ebox-create :key 'child :host-ref 'child + 'items (ebox-test-box :key 'child :host-ref 'child :content "old"))) :host-ref 'parent) - (ebox-create :key 'peer :content "peer"))) + (ebox-test-box :key 'peer :content "peer"))) (let ((candidate (ebox-candidate-begin buffer))) (dolist (operation (if (eq order 'host-first) '(host range) '(range host))) @@ -858,11 +1045,11 @@ ('host (ebox-candidate-replace-host-ref candidate 'parent - (ebox-create :key 'parent-new :content "HOST-WINS"))) + (ebox-test-box :key 'parent-new :content "HOST-WINS"))) ('range (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-create :key 'range-new + (list (ebox-test-box :key 'range-new :content "range-loses")))))) (ebox-commit buffer candidate)) (should (string-match-p "HOST-WINS" @@ -877,15 +1064,15 @@ (progn (ebox-render-to-buffer buffer - (ebox-column - (ebox-create :key 'static :content "s") + (ebox-test-column + (ebox-test-box :key 'static :content "s") (ebox-child-range - 'items (ebox-create :key 'child :host-ref 'child :content "0")))) + 'items (ebox-test-box :key 'child :host-ref 'child :content "0")))) (dotimes (turn 2) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'child - (ebox-create :key 'child :host-ref 'child + (ebox-test-box :key 'child :host-ref 'child :content (number-to-string (1+ turn)))) (ebox-commit buffer candidate)) (let* ((state (ebox--buffer-render-state buffer)) @@ -905,31 +1092,31 @@ (progn (ebox-render-to-buffer buffer - (ebox-column + (ebox-test-column (plist-put - (ebox-column - (ebox-create :key 'static :content "s") + (ebox-test-column + (ebox-test-box :key 'static :content "s") (ebox-child-range - 'items (ebox-create :key 'child :host-ref 'child + 'items (ebox-test-box :key 'child :host-ref 'child :content "old"))) :host-ref 'parent) - (ebox-create :key 'peer :content "peer"))) + (ebox-test-box :key 'peer :content "peer"))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'parent (plist-put - (ebox-column - (ebox-create :key 'static :content "s2") + (ebox-test-column + (ebox-test-box :key 'static :content "s2") (ebox-child-range - 'items (ebox-create :key 'child :host-ref 'child + 'items (ebox-test-box :key 'child :host-ref 'child :content "ancestor"))) :host-ref 'parent)) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-create :key 'range :content "RANGE-LOSES"))) + (list (ebox-test-box :key 'range :content "RANGE-LOSES"))) (ebox-candidate-replace-host-ref candidate 'child - (ebox-create :key 'child :host-ref 'child + (ebox-test-box :key 'child :host-ref 'child :content "DESCENDANT-WINS")) (ebox-commit buffer candidate)) (let ((text (with-current-buffer buffer (buffer-string)))) @@ -941,10 +1128,10 @@ "M=64 remains an affected-payload local delta under 500 static segments." (let* ((buffer (generate-new-buffer " *ebox-range-large-payload*")) (root - (apply #'ebox-column + (apply #'ebox-test-column (append (cl-loop for index below 500 - collect (ebox-create :key (list 'static index) + collect (ebox-test-box :key (list 'static index) :content "s")) (list (ebox-child-range 'items))))) report) @@ -955,7 +1142,7 @@ (ebox-candidate-replace-range-ref candidate 'items (cl-loop for index below 64 - collect (ebox-create :key (list 'new index) + collect (ebox-test-box :key (list 'new index) :content "n"))) (cl-letf (((symbol-function 'ebox-incremental--candidate-full-index-preparation) @@ -977,10 +1164,10 @@ (progn (ebox-render-to-buffer buffer - (ebox-column - (ebox-create :key 'static :content "s") + (ebox-test-column + (ebox-test-box :key 'static :content "s") (ebox-child-range - 'items (ebox-create :key 'child :host-ref 'child + 'items (ebox-test-box :key 'child :host-ref 'child :content "old")))) (let ((candidate (ebox-candidate-begin buffer))) (dolist (operation (if (eq order 'host-first) @@ -988,11 +1175,11 @@ (if (eq operation 'host) (ebox-candidate-replace-host-ref candidate 'child - (ebox-create :key 'child :host-ref 'child + (ebox-test-box :key 'child :host-ref 'child :content "HOST-LOSES")) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-create :key 'range :content "RANGE-WINS"))))) + (list (ebox-test-box :key 'range :content "RANGE-WINS"))))) (ebox-commit buffer candidate)) (let ((text (with-current-buffer buffer (buffer-string)))) (should (string-match-p "RANGE-WINS" text)) @@ -1006,29 +1193,29 @@ (cl-labels ((child (static-text range-ref range-key range-text) (let ((node - (ebox-column - (ebox-create :key 'child-static :content static-text) + (ebox-test-column + (ebox-test-box :key 'child-static :content static-text) (if range-key (ebox-child-range range-ref - (ebox-create :key range-key :content range-text)) + (ebox-test-box :key range-key :content range-text)) (ebox-child-range range-ref))))) (plist-put node :key 'child) (plist-put node :host-ref 'child) node)) (ancestor (child-node peer-text) (let ((node - (ebox-column + (ebox-test-column child-node - (ebox-create :key 'ancestor-peer :content peer-text)))) + (ebox-test-box :key 'ancestor-peer :content peer-text)))) (plist-put node :key 'ancestor) (plist-put node :host-ref 'ancestor) node))) (ebox-render-to-buffer buffer - (ebox-column + (ebox-test-column (ancestor (child "s" 'old-ref nil nil) "p") - (ebox-create :key 'root-peer :content "r"))) + (ebox-test-box :key 'root-peer :content "r"))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'ancestor @@ -1054,20 +1241,20 @@ (progn (ebox-render-to-buffer buffer - (ebox-column - (ebox-create :key 'host :host-ref 'host :content "old-host") + (ebox-test-column + (ebox-test-box :key 'host :host-ref 'host :content "old-host") (ebox-child-range - 'items (ebox-create :key 'old-range :content "old-range")))) + 'items (ebox-test-box :key 'old-range :content "old-range")))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (revision (tp-surface-revision surface)) (candidate (ebox-candidate-begin buffer)) report) (ebox-candidate-replace-host-ref candidate 'host - (ebox-create :key 'host :host-ref 'host :content "new-host")) + (ebox-test-box :key 'host :host-ref 'host :content "new-host")) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-create :key 'new-range :content "new-range"))) + (list (ebox-test-box :key 'new-range :content "new-range"))) (setq report (ebox-commit buffer candidate (lambda (_report) (cl-incf callbacks)))) @@ -1088,10 +1275,10 @@ (progn (ebox-render-to-buffer buffer - (ebox-column - (ebox-create :key 'static :content "s") + (ebox-test-column + (ebox-test-box :key 'static :content "s") (ebox-child-range - 'items (ebox-create :key 'old :host-ref 'old :content "old")))) + 'items (ebox-test-box :key 'old :host-ref 'old :content "old")))) (let* ((old-state (ebox--buffer-render-state buffer)) (old-root (plist-get old-state :root-node)) (old-table (plist-get old-state :range-ref-table)) @@ -1100,7 +1287,7 @@ (candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-create :key 'new :host-ref 'new :content "new"))) + (list (ebox-test-box :key 'new :host-ref 'new :content "new"))) (cl-letf (((symbol-function 'accept-change-group) (lambda (_group) (error "reject final accept")))) (should-error @@ -1119,7 +1306,7 @@ (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-create :key 'new :content "new"))) + (list (ebox-test-box :key 'new :content "new"))) (ebox-commit buffer candidate))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -1130,10 +1317,10 @@ (progn (ebox-render-to-buffer buffer - (ebox-column - (ebox-create :key 'static :content "s") + (ebox-test-column + (ebox-test-box :key 'static :content "s") (ebox-child-range - 'items (ebox-create :key 'old :content "old")))) + 'items (ebox-test-box :key 'old :content "old")))) (let* ((old-state (ebox--buffer-render-state buffer)) (old-root (plist-get old-state :root-node)) (old-range-table (plist-get old-state :range-ref-table)) @@ -1142,7 +1329,7 @@ trace) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-create :key 'new :content "new"))) + (list (ebox-test-box :key 'new :content "new"))) (should-error (ebox-commit buffer candidate @@ -1157,7 +1344,7 @@ (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-create :key 'new :content "new"))) + (list (ebox-test-box :key 'new :content "new"))) (ebox-commit buffer candidate)) (should (string-match-p "new" (with-current-buffer buffer (buffer-string))))) diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index f857895..43828ea 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -18,6 +18,22 @@ (widen) (buffer-substring (point-min) (point-max))))) +(defun ebox-commit-test--face-value (face key) + "Return KEY from FACE whether FACE is one plist or a face stack." + (cond + ((null face) nil) + ((and (listp face) (keywordp (car face))) + (plist-get face key)) + ((listp face) + (cl-some (lambda (entry) + (ebox-commit-test--face-value entry key)) + face)))) + +(defun ebox-commit-test--owner-proofs (proof) + "Return the uniform leaf-owner proof list represented by PROOF." + (or (plist-get proof :owner-proofs) + (and proof (list proof)))) + (ert-deftest ebox-native-fragment-style-delta-copies-only-changed-records () "A native style delta keeps the retained template immutable." (let* ((first [0 1 0 nil nil nil nil (1)]) @@ -34,15 +50,15 @@ (ert-deftest ebox-native-object-delta-orders-moved-and-new-nodes () "A topology delta names parents before moved and introduced children." (let* ((old-root - (ebox-column (ebox-create :key 'a :content "A") - (ebox-create :key 'b :content "B"))) + (ebox-test-column (ebox-test-box :key 'a :content "A") + (ebox-test-box :key 'b :content "B"))) (_old-ids (ebox--runtime-node-ids old-root)) (old-index (ebox--runtime-index old-root t)) (old-objects (make-hash-table :test 'equal)) (new-root - (ebox-column (ebox-create :key 'b :content "B") - (ebox-create :key 'a :content "A") - (ebox-create :key 'c :content "C"))) + (ebox-test-column (ebox-test-box :key 'b :content "B") + (ebox-test-box :key 'a :content "A") + (ebox-test-box :key 'c :content "C"))) (_reconciled (ebox-tree-reconcile-runtime old-root new-root)) (new-index (ebox--runtime-index new-root t)) (root-id (plist-get old-root :node-id))) @@ -58,26 +74,29 @@ old-state new-state (list :touched-node-ids (list root-id) :removed-node-ids nil))) - (children (ebox-tree--children-raw new-root))) + (children (ebox-tree--children-raw new-root)) + (introduced (car (last children)))) (should (equal delta - (cons root-id (mapcar (lambda (node) - (plist-get node :node-id)) - children))))))) + (append + (list root-id + (plist-get (car children) :node-id) + (plist-get (cadr children) :node-id)) + (ebox--runtime-node-ids introduced))))))) (ert-deftest ebox-native-object-delta-requires-complete-removal-proof () "An unreported disappeared object rejects the native topology delta." (let* ((old-root - (ebox-column (ebox-create :key 'a :content "A") - (ebox-create :key 'tail :content "T") - (ebox-create :key 'b :content "B"))) + (ebox-test-column (ebox-test-box :key 'a :content "A") + (ebox-test-box :key 'tail :content "T") + (ebox-test-box :key 'b :content "B"))) (_old-ids (ebox--runtime-node-ids old-root)) (old-index (ebox--runtime-index old-root t)) (old-objects (make-hash-table :test 'equal)) (removed-node (car (last (ebox-tree--children-raw old-root)))) (new-root - (ebox-column (ebox-create :key 'a :content "A") - (ebox-create :key 'tail :content "T"))) + (ebox-test-column (ebox-test-box :key 'a :content "A") + (ebox-test-box :key 'tail :content "T"))) (_reconciled (ebox-tree-reconcile-runtime old-root new-root)) (new-index (ebox--runtime-index new-root t)) (root-id (plist-get old-root :node-id))) @@ -100,7 +119,7 @@ old-state new-state (list :touched-node-ids (list root-id) :removed-node-ids - (list (plist-get removed-node :node-id))))))))) + (ebox--runtime-node-ids removed-node)))))))) (ert-deftest ebox-style-schema-registration-is-not-per-node-copy () "Repeated node construction must not copy the whole ECSS schema domain." @@ -111,7 +130,7 @@ (cl-incf calls) (apply original arguments)))) (dotimes (_ 24) - (ebox-create :content "schema-hot-path" :color "#111111"))) + (ebox-test-box :content "schema-hot-path" :color "#111111"))) (should (= calls 0)))) (ert-deftest ebox-style-declaration-compilation-is-memoized () @@ -132,10 +151,10 @@ "A declarative commit should publish changed content." (let* ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-content*") - (ebox-create :key 'root :content "Before" :width '(80)))) + (ebox-test-box :key 'root :content "Before" :width '(80)))) (report (ebox-commit buffer - (ebox-create :key 'root :content "After" :width '(80))))) + (ebox-test-box :key 'root :content "After" :width '(80))))) (unwind-protect (progn (should (string-prefix-p @@ -152,15 +171,15 @@ "Keyed siblings should remain addressable after a reorder commit." (let* ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-keyed*") - (ebox-column - (ebox-create :key 'a :host-ref 'a :content "A" :width '(40)) - (ebox-create :key 'b :host-ref 'b :content "B" :width '(40))))) + (ebox-test-column + (ebox-test-box :key 'a :host-ref 'a :content "A" :width '(40)) + (ebox-test-box :key 'b :host-ref 'b :content "B" :width '(40))))) (old-b (ebox-host-ref-position buffer 'b)) (report (ebox-commit buffer - (ebox-column - (ebox-create :key 'b :host-ref 'b :content "B2" :width '(40)) - (ebox-create :key 'a :host-ref 'a :content "A" :width '(40)))))) + (ebox-test-column + (ebox-test-box :key 'b :host-ref 'b :content "B2" :width '(40)) + (ebox-test-box :key 'a :host-ref 'a :content "A" :width '(40)))))) (unwind-protect (progn (should old-b) @@ -177,25 +196,25 @@ (let* ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-root-candidate*") - (ebox-column - (ebox-create :key 'a :host-ref 'a :content "A" :width '(40)) - (ebox-create :key 'b :host-ref 'b :content "B" :width '(40))))) + (ebox-test-column + (ebox-test-box :key 'a :host-ref 'a :content "A" :width '(40)) + (ebox-test-box :key 'b :host-ref 'b :content "B" :width '(40))))) (surface (with-current-buffer buffer ebox-surface--buffer-surface)) (revision (tp-surface-revision surface)) (candidate (ebox-candidate-begin buffer))) (unwind-protect (progn (ebox-candidate-replace-host-ref - candidate 'a (ebox-create :key 'a :content "ignored-before")) + candidate 'a (ebox-test-box :key 'a :content "ignored-before")) (ebox-candidate-replace-root candidate - (ebox-create :key 'root :host-ref 'a :content "first-root" + (ebox-test-box :key 'root :host-ref 'a :content "first-root" :width '(80))) (ebox-candidate-replace-host-ref - candidate 'b (ebox-create :key 'b :content "ignored-after")) + candidate 'b (ebox-test-box :key 'b :content "ignored-after")) (ebox-candidate-replace-root candidate - (ebox-create :key 'root :host-ref 'b :content "final-root" + (ebox-test-box :key 'root :host-ref 'b :content "final-root" :width '(80))) (should (= (length (ebox-candidate--replacements candidate)) 1)) (let ((report (ebox-commit buffer candidate))) @@ -211,7 +230,7 @@ (should (plist-get report :runtime-published))) (should-error (ebox-candidate-replace-root - candidate (ebox-create :key 'root :content "sealed")))) + candidate (ebox-test-box :key 'root :content "sealed")))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-commit-framework-participant-completes-and-rolls-back () @@ -220,11 +239,11 @@ (unwind-protect (progn (ebox-render-to-buffer - buffer (ebox-create :key 'root :content "old" :width '(80))) + buffer (ebox-test-box :key 'root :content "old" :width '(80))) (let (trace) (let ((report (ebox-commit - buffer (ebox-create :key 'root :content "new" :width '(80)) + buffer (ebox-test-box :key 'root :content "new" :width '(80)) (lambda (_report) (push 'publish trace)) (lambda (_report) (push 'rollback trace))))) (should (equal trace '(publish))) @@ -245,7 +264,7 @@ (condition-case condition (ebox-commit buffer - (ebox-create :key 'root :content "rejected" + (ebox-test-box :key 'root :content "rejected" :width '(80)) (lambda (report) (setq captured report) @@ -274,23 +293,23 @@ (progn (ebox-render-to-buffer buffer - (ebox-column - (ebox-create :key 'a :host-ref 'a :content "old-a" :width '(40)) - (ebox-create :key 'b :host-ref 'b :content "old-b" :width '(40)))) + (ebox-test-column + (ebox-test-box :key 'a :host-ref 'a :content "old-a" :width '(40)) + (ebox-test-box :key 'b :host-ref 'b :content "old-b" :width '(40)))) (let ((before (ebox-commit-test--buffer-string buffer)) (candidate (ebox-candidate-begin buffer)) trace captured) (when (eq mode 'scoped) (ebox-candidate-replace-host-ref candidate 'a - (ebox-create :key 'a :host-ref 'a :content "new-a" + (ebox-test-box :key 'a :host-ref 'a :content "new-a" :width '(40)))) (should-error (ebox-commit buffer (if (eq mode 'scoped) candidate - (ebox-create :key 'root :content "new-root" :width '(80))) + (ebox-test-box :key 'root :content "new-root" :width '(80))) (lambda (report) (setq captured report) (push 'publish trace) @@ -306,7 +325,7 @@ (ert-deftest ebox-commit-framework-argument-validation () "Four-argument framework callbacks have an exact paired contract." (let ((buffer (generate-new-buffer " *ebox-framework-validation*")) - (root (ebox-create :key 'root :content "x"))) + (root (ebox-test-box :key 'root :content "x"))) (unwind-protect (progn (ebox-render-to-buffer buffer root) @@ -322,23 +341,23 @@ "A column content growth must publish shifted later styled siblings." (let* ((buffer (generate-new-buffer-name " *ebox-commit-column-scope*")) (old-root - (ebox-column - (ebox-create :key 'panel :padding '(1 (2)) + (ebox-test-column + (ebox-test-box :key 'panel :padding '(1 (2)) :border "#687386" :bgcolor "#FFFDF8" :content "Panel") - (ebox-create :key 'payload :padding '(0 (1)) + (ebox-test-box :key 'payload :padding '(0 (1)) :border "#AAA" :content "No payload yet.") - (ebox-create :key 'later :padding '(0 (1)) + (ebox-test-box :key 'later :padding '(0 (1)) :border "#BBB" :content "Later sibling"))) (new-root - (ebox-column - (ebox-create :key 'panel :padding '(1 (2)) + (ebox-test-column + (ebox-test-box :key 'panel :padding '(1 (2)) :border "#687386" :bgcolor "#FFFDF8" :content "Panel") - (ebox-create :key 'payload :padding '(0 (1)) + (ebox-test-box :key 'payload :padding '(0 (1)) :border "#AAA" :content "Payload received: payload=42") - (ebox-create :key 'later :padding '(0 (1)) + (ebox-test-box :key 'later :padding '(0 (1)) :border "#BBB" :content "Later sibling"))) (report nil)) (unwind-protect @@ -368,10 +387,10 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-style-cache*") - (ebox-column - (ebox-create :key 'first :class "card" :content "Before" + (ebox-test-column + (ebox-test-box :key 'first :class "card" :content "Before" :width '(40)) - (ebox-create :key 'second :class "card" :content "Stable" + (ebox-test-box :key 'second :class "card" :content "Stable" :width '(40))))) (setq calls 0) (cl-letf (((symbol-function 'ecss-compute-style) @@ -380,10 +399,10 @@ (apply original arguments)))) (ebox-commit buffer - (ebox-column - (ebox-create :key 'first :class "card" :content "After" + (ebox-test-column + (ebox-test-box :key 'first :class "card" :content "After" :width '(40)) - (ebox-create :key 'second :class "card" :content "Stable" + (ebox-test-box :key 'second :class "card" :content "Stable" :width '(40)))) (should (= calls 0)))) (when (buffer-live-p buffer) @@ -401,10 +420,10 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-selector-snapshot*") - (ebox-column - (ebox-create :key 'first :class "card" :content "Before" + (ebox-test-column + (ebox-test-box :key 'first :class "card" :content "Before" :width '(40)) - (ebox-create :key 'second :class "card" :content "Stable" + (ebox-test-box :key 'second :class "card" :content "Stable" :width '(40))))) (cl-letf (((symbol-function 'ebox-surface--subject-signature) (lambda (&rest arguments) @@ -412,17 +431,17 @@ (apply original arguments)))) (ebox-commit buffer - (ebox-column - (ebox-create :key 'first :class "card" :content "After" + (ebox-test-column + (ebox-test-box :key 'first :class "card" :content "After" :width '(40)) - (ebox-create :key 'second :class "card" :content "Stable" + (ebox-test-box :key 'second :class "card" :content "Stable" :width '(40)))) (should (= calls 1)))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-commit-reuses-local-selector-styles-across-tree-change () - "A subject-local stylesheet should compute only newly inserted node styles." + "A subject-local stylesheet should compute only the new Box and Text facts." (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) (calls 0) (original (symbol-function 'ecss-compute-style)) @@ -433,10 +452,10 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-local-selector-reuse*") - (ebox-column - (ebox-create :key 'first :class "card" :content "First" + (ebox-test-column + (ebox-test-box :key 'first :class "card" :content "First" :width '(40)) - (ebox-create :key 'second :class "card" :content "Second" + (ebox-test-box :key 'second :class "card" :content "Second" :width '(40))))) (cl-letf (((symbol-function 'ecss-compute-style) (lambda (&rest arguments) @@ -444,80 +463,24 @@ (apply original arguments)))) (ebox-commit buffer - (ebox-column - (ebox-create :key 'first :class "card" :content "First" + (ebox-test-column + (ebox-test-box :key 'first :class "card" :content "First" :width '(40)) - (ebox-create :key 'second :class "card" :content "Second" + (ebox-test-box :key 'second :class "card" :content "Second" :width '(40)) - (ebox-create :key 'third :class "card" :content "Third" + (ebox-test-box :key 'third :class "card" :content "Third" :width '(40))))) - (should (= calls 1)) + (should (= calls 2)) (let* ((text (ebox-commit-test--buffer-string buffer)) (position (string-match "Third" text))) (should position) - (should (equal (plist-get (get-text-property position 'face text) - :foreground) + (should (equal (ebox-commit-test--face-value + (get-text-property position 'face text) + :foreground) "#2255AA")))) (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-commit-reuses-successful-allocation-certificate () - "A repeated fixed-slot update must not rescan unchanged ancestor spans." - (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) - (buffer nil) - (ancestor-scans 0)) - (ebox-style-add-rule ".card" '(:color "#111111") :layer 'components) - (unwind-protect - (progn - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-allocation-certificate*") - (ebox-build - '(grid :width (100) :height 2 - :grid-template-columns (1fr 1fr) - (column :key card-shell :width stretch :height 2 - (box :key card :class "card" :host-ref target - :content "Before" :width (40))) - (box :key stable :content "Stable" - :width (40) :height 1))))) - (let ((first (ebox-candidate-begin buffer))) - (ebox-candidate-replace-host-ref - first 'target - (ebox-create :key 'card :class "card" :host-ref 'target - :content "Middle" :width '(40))) - (should (eq (plist-get (ebox-commit buffer first) - :projection-kind) - 'owner-scoped))) - (let* ((state (ebox--buffer-render-state buffer)) - (certificates - (plist-get state :retained-allocation-certificates))) - (should (hash-table-p certificates)) - (should (= 1 (hash-table-count certificates)))) - (let ((second (ebox-candidate-begin buffer)) - (original - (symbol-function - 'ebox-incremental--compute-ancestor-slot-signature))) - (ebox-candidate-replace-host-ref - second 'target - (ebox-create :key 'card :class "card" :host-ref 'target - :content "After" :width '(40))) - (cl-letf - (((symbol-function - 'ebox-incremental--compute-ancestor-slot-signature) - (lambda (&rest arguments) - (cl-incf ancestor-scans) - (apply original arguments)))) - (should (eq (plist-get (ebox-commit buffer second) - :projection-kind) - 'owner-scoped)))) - (should (= ancestor-scans 0)) - (should - (string-match-p - "After" - (substring-no-properties - (ebox-commit-test--buffer-string buffer))))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) (ert-deftest ebox-commit-invalidates-selector-tree-token-for-sibling-change () "A sibling metadata change must invalidate retained selector computations." @@ -530,10 +493,10 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-selector-change*") - (ebox-column - (ebox-create :key 'state :class "inactive" :content "State" + (ebox-test-column + (ebox-test-box :key 'state :class "inactive" :content "State" :width '(40)) - (ebox-create :key 'target :class "target" :content "Target" + (ebox-test-box :key 'target :class "target" :content "Target" :width '(40))))) (let* ((before (ebox-commit-test--buffer-string buffer)) (position (string-match "Target" before))) @@ -541,16 +504,17 @@ (should-not (get-text-property position 'face before))) (ebox-commit buffer - (ebox-column - (ebox-create :key 'state :class "active" :content "State" + (ebox-test-column + (ebox-test-box :key 'state :class "active" :content "State" :width '(40)) - (ebox-create :key 'target :class "target" :content "Target" + (ebox-test-box :key 'target :class "target" :content "Target" :width '(40)))) (let* ((after (ebox-commit-test--buffer-string buffer)) (position (string-match "Target" after))) (should position) - (should (equal (plist-get (get-text-property position 'face after) - :foreground) + (should (equal (ebox-commit-test--face-value + (get-text-property position 'face after) + :foreground) "#2255AA")))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -565,18 +529,20 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-style-rule*") - (ebox-create :key 'card :class "card" :content "Stable" + (ebox-test-box :key 'card :class "card" :content "Stable" :width '(40)))) - (should (equal (plist-get (get-text-property - (point-min) 'face buffer) :foreground) + (should (equal (ebox-commit-test--face-value + (get-text-property (point-min) 'face buffer) + :foreground) "#111111")) (ebox-style-add-rule ".card" '(:color "#222222") :layer 'components) (ebox-commit buffer - (ebox-create :key 'card :class "card" :content "Stable" + (ebox-test-box :key 'card :class "card" :content "Stable" :width '(40))) - (should (equal (plist-get (get-text-property - (point-min) 'face buffer) :foreground) + (should (equal (ebox-commit-test--face-value + (get-text-property (point-min) 'face buffer) + :foreground) "#222222"))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -590,7 +556,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-cascade-activation*") - (ebox-create :key 'card :class "card" :content "Before" + (ebox-test-box :key 'card :class "card" :content "Before" :width '(40)))) (should-not (get-text-property (point-min) 'face buffer)) (ebox-style-add-rule ".card" '(:color "#2255AA") @@ -598,7 +564,7 @@ (let ((report (ebox-commit buffer - (ebox-create :key 'card :class "card" :content "After" + (ebox-test-box :key 'card :class "card" :content "After" :width '(40))))) (should (string-prefix-p "After" @@ -606,94 +572,13 @@ (substring-no-properties (ebox-commit-test--buffer-string buffer))))) (should-not (eq (plist-get report :strategy) 'span-patch)) - (should (equal (plist-get (get-text-property - (point-min) 'face buffer) :foreground) + (should (equal (ebox-commit-test--face-value + (get-text-property (point-min) 'face buffer) + :foreground) "#2255AA")))) (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-commit-uses-static-cascade-owner-proof () - "A static cascade content commit may plan its local owner." - (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) - (calls 0) - (ensured 0) - (rendered nil) - (buffer nil)) - (ebox-style-add-rule ".card" '(:color "#111111") :layer 'components) - (unwind-protect - (progn - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-commit-proof-cache*") - (ebox-build - '(column :width (100) :height 4 - (column :key main :width (100) :height 2 - (grid :key grid :width (100) :height 2 - :grid-template-columns (1fr 1fr) - (column :key card-shell :width stretch :height 2 - (box :key card :class "card" :host-ref target - :content "Before")) - (box :key stable :content "Stable" - :width (40) :height 1))) - (box :key footer :content "Footer" - :width (100) :height 1))))) - (let* ((state (ebox--buffer-render-state buffer)) - (style-states (plist-get state :style-binding-states)) - (binding-state - (cl-loop for value being the hash-values of style-states - when (and (plist-get value :declarations) - (plist-get value :parent-style-state) - (ebox-surface--static-style-state-p value)) - return value))) - (should binding-state) - (should (tp-binding-live-p (plist-get binding-state :binding))) - (should (plist-get binding-state :parent-style-state)) - (should (= 1 (plist-get binding-state - :inherited-dependency-count))) - (should (= 1 (tp-binding-dependency-count - (plist-get binding-state :binding)))) - (should (ebox-surface--static-style-state-p binding-state))) - (let ((original - (symbol-function 'ebox-incremental--layout-owner-plan)) - (original-ensure - (symbol-function 'ebox-surface--ensure-node-tree)) - (original-render (symbol-function 'ebox--render-layout))) - (cl-letf (((symbol-function 'ebox-incremental--layout-owner-plan) - (lambda (&rest arguments) - (cl-incf calls) - (apply original arguments))) - ((symbol-function 'ebox-surface--ensure-node-tree) - (lambda (&rest arguments) - (cl-incf ensured) - (apply original-ensure arguments))) - ((symbol-function 'ebox--render-layout) - (lambda (node) - (push (plist-get node :node-id) rendered) - (funcall original-render node)))) - (let* ((candidate (ebox-candidate-begin buffer)) - (_replacement - (ebox-candidate-replace-host-ref - candidate 'target - (ebox-create :key 'card :class "card" - :host-ref 'target :content "After"))) - (report (ebox-commit buffer candidate))) - (should (eq (plist-get report :projection-kind) - 'owner-scoped)) - ;; The nested 1fr Grid now carries a strict local allocation - ;; certificate; the proof no longer rescans every normal-flow - ;; ancestor just to establish the same slot width. - (should (= 1 (plist-get report :ancestor-slot-count))) - (should (= 0 (plist-get report - :ancestor-slot-generation)))) - (should (string-match-p - "After" - (substring-no-properties - (ebox-commit-test--buffer-string buffer)))) - (should (= calls 1)) - (should (= ensured 0)) - (should (= (length rendered) 1)))) - (when (buffer-live-p buffer) - (kill-buffer buffer)))))) (ert-deftest ebox-commit-keeps-root-owner-for-dynamic-cascade-source () "A TP-dependent cascade binding must preserve root-owner fallback." @@ -709,10 +594,10 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-dynamic-style-proof*") - (ebox-column - (ebox-create :key 'card :class "card" :content "Before" + (ebox-test-column + (ebox-test-box :key 'card :class "card" :content "Before" :width '(40)) - (ebox-create :key 'stable :content "Stable" + (ebox-test-box :key 'stable :content "Stable" :width '(40))))) (let* ((state (ebox--buffer-render-state buffer)) (style-states (plist-get state :style-binding-states)) @@ -735,223 +620,50 @@ (apply original arguments)))) (ebox-commit buffer - (ebox-column - (ebox-create :key 'card :class "card" :content "After" + (ebox-test-column + (ebox-test-box :key 'card :class "card" :content "After" :width '(40)) - (ebox-create :key 'stable :content "Stable" + (ebox-test-box :key 'stable :content "Stable" :width '(40)))) (should (= calls 0))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-owner-scoped-proof-rejects-unstable-allocation () - "Auto intrinsic, line-count, and topology changes must keep root fallback." - (let ((ebox-style-stylesheet (ecss-stylesheet-create))) - (ebox-style-add-rule ".card" '(:color "#111111") :layer 'components) - (dolist - (case - (list - (list :name "auto" - :old (ebox-create :key 'card :class "card" :host-ref 'target - :content "Before") - :new (ebox-create :key 'card :class "card" :host-ref 'target - :content "After, intrinsically wider")) - (list :name "lines" - :old (ebox-create :key 'card :class "card" :host-ref 'target - :content "Before" :width '(40)) - :new (ebox-create :key 'card :class "card" :host-ref 'target - :content "After\nsecond" :width '(40))) - (list :name "topology" - :old (ebox-create :key 'card :class "card" :host-ref 'target - :content "Before" :width '(40) :height 1) - :new (ebox-column - (ebox-create :key 'first :content "After" :width '(40)) - (ebox-create :key 'second :content "Second" - :width '(40)))))) - (let ((buffer nil)) - (unwind-protect - (progn - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name - (format " *ebox-owner-proof-%s*" (plist-get case :name))) - (ebox-column - (plist-get case :old) - (ebox-create :key 'stable :content "Stable" - :width '(40))))) - (let ((candidate (ebox-candidate-begin buffer))) - (ebox-candidate-replace-host-ref - candidate 'target (plist-get case :new)) - (let ((report (ebox-commit buffer candidate))) - (should-not (eq (plist-get report :projection-kind) - 'owner-scoped))))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - (let ((buffer nil)) - (unwind-protect - (progn - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-owner-proof-auto-grid*") - (ebox-build - '(grid :width (100) :height 1 - :grid-template-columns (auto (40)) - (box :key card :class "card" :host-ref target - :content "Before" :height 1) - (box :key stable :content "Stable" - :width (40) :height 1))))) - (let ((candidate (ebox-candidate-begin buffer))) - (ebox-candidate-replace-host-ref - candidate 'target - (ebox-create :key 'card :class "card" :host-ref 'target - :content "After" :height 1)) - (let ((report (ebox-commit buffer candidate))) - (should-not (eq (plist-get report :projection-kind) - 'owner-scoped))))) - (when (buffer-live-p buffer) - (kill-buffer buffer)))))) -(ert-deftest ebox-commit-owner-scoped-multi-owner-is-atomic () - "Two disjoint fixed-slot owners render locally in one publication." - (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) - (buffer nil) - (ensured 0) - (ancestor-proofs 0) - (rendered nil) - (published 0)) - (ebox-style-add-rule ".card" '(:color "#111111") :layer 'components) - (unwind-protect - (progn - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-owner-multi*") - (ebox-build - '(grid :width (100) - :grid-template-columns (1fr 1fr) - (column :key card-shell :width stretch - :border "#687386" :padding (1 2) - (box :key first :class "card" :host-ref first - :content "First" ) - (box :key second :class "card" :host-ref second - :content "Second")) - (box :key stable :content "Stable" - :width (40) :height 2))))) - (let* ((surface (with-current-buffer buffer - ebox-surface--buffer-surface)) - (revision (tp-surface-revision surface)) - (candidate (ebox-candidate-begin buffer)) - (original-ensure - (symbol-function 'ebox-surface--ensure-node-tree)) - (original-ancestor-proof - (symbol-function - 'ebox-incremental--compute-ancestor-slot-signature)) - (original-render (symbol-function 'ebox--render-layout))) - (ebox-candidate-replace-host-ref - candidate 'first - (ebox-create :key 'first :class "card" :host-ref 'first - :content "Alpha")) - (ebox-candidate-replace-host-ref - candidate 'second - (ebox-create :key 'second :class "card" :host-ref 'second - :content "Bravo!")) - (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) - (lambda (&rest arguments) - (cl-incf ensured) - (apply original-ensure arguments))) - ((symbol-function 'ebox--render-layout) - (lambda (node) - (push (plist-get node :node-id) rendered) - (funcall original-render node))) - ((symbol-function - 'ebox-incremental--compute-ancestor-slot-signature) - (lambda (&rest arguments) - (cl-incf ancestor-proofs) - (apply original-ancestor-proof arguments)))) - (let ((report - (ebox-commit - buffer candidate - (lambda (_report) (cl-incf published))))) - (should (eq (plist-get report :projection-kind) - 'owner-scoped)) - (should (= 2 (length (plist-get report - :rendered-owner-ids)))) - (should (= 1 published)) - (should (= 1 (- (tp-surface-revision surface) revision))) - (should (= 0 ensured)) - (should (= 2 ancestor-proofs)) - (should (= 2 (length rendered))) - (should (string-match-p - "Alpha" (ebox-commit-test--buffer-string buffer))) - (should (string-match-p - "Bravo!" (ebox-commit-test--buffer-string buffer))) - (let* ((text (ebox-commit-test--buffer-string buffer)) - (position (string-match "Alpha" text))) - (should position) - (should (equal - (plist-get (get-text-property position 'face text) - :foreground) - "#111111"))) - (should (< (plist-get report :reconciled-objects) - (plist-get (tp-surface-inspect surface) - :object-count))))) - (let* ((fallback-revision (tp-surface-revision surface)) - (fallback (ebox-candidate-begin buffer))) - (ebox-candidate-replace-host-ref - fallback 'first - (ebox-create :key 'first :class "card" :host-ref 'first - :content "Gamma")) - (ebox-candidate-replace-host-ref - fallback 'second - (ebox-create :key 'second :class "card" :host-ref 'second - :content "Much much much much much much longer beta content")) - (let ((report (ebox-commit buffer fallback))) - (should-not (eq (plist-get report :projection-kind) - 'owner-scoped)) - (should (or (plist-get report :tp-scope-fallback) - (plist-get report :tp-full-root))) - (should (= 1 (- (tp-surface-revision surface) - fallback-revision))) - (let ((text (ebox-commit-test--buffer-string buffer))) - (should (string-match-p - "Much much much much much much longer beta" - text)) - (should (string-match-p "content" text))))))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) (ert-deftest ebox-commit-formatting-context-reflow-owns-variable-line-siblings () "Two variable-line owners should publish through their nearest stack context. The context owns the complete local block; the root and untouched header/footer remain retained identities." (let* ((old-root - (ebox-column - (ebox-create :key 'header :content "Header" :width '(160)) - (ebox-create + (ebox-test-column + (ebox-test-box :key 'header :content "Header" :width '(160)) + (ebox-test-box :key 'shell :width '(160) :ebox-content-node - (ebox-column - (ebox-create :key 'message :host-ref 'message + (ebox-test-column + (ebox-test-box :key 'message :host-ref 'message :content "Callback action pending") - (ebox-flex :width '(120) :height 1 - (ebox-create :key 'toggle :host-ref 'toggle + (ebox-test-flex :width '(120) :height 1 + (ebox-test-box :key 'toggle :host-ref 'toggle :content "Behavior: off")))) - (ebox-create :key 'footer :content "Footer" :width '(160)))) + (ebox-test-box :key 'footer :content "Footer" :width '(160)))) (new-message - (ebox-create :key 'message :host-ref 'message + (ebox-test-box :key 'message :host-ref 'message :content "Behavior toggle: on / callback active / a longer status line")) (new-toggle - (ebox-create :key 'toggle :host-ref 'toggle + (ebox-test-box :key 'toggle :host-ref 'toggle :content "Behavior: on")) (new-root - (ebox-column - (ebox-create :key 'header :content "Header" :width '(160)) - (ebox-create + (ebox-test-column + (ebox-test-box :key 'header :content "Header" :width '(160)) + (ebox-test-box :key 'shell :width '(160) :ebox-content-node - (ebox-column + (ebox-test-column new-message - (ebox-flex :width '(120) :height 1 new-toggle))) - (ebox-create :key 'footer :content "Footer" :width '(160)))) + (ebox-test-flex :width '(120) :height 1 new-toggle))) + (ebox-test-box :key 'footer :content "Footer" :width '(160)))) (buffer nil) (fresh nil)) (unwind-protect @@ -994,11 +706,23 @@ remain retained identities." (let* ((region-node-table (plist-get state :region-node-table)) (node-table (plist-get state :node-table)) + (parent-table (plist-get state :parent-table)) (node-id (and region-node-table (gethash value region-node-table))) - (node (and node-id - (gethash node-id node-table)))) - (or (and node (ebox-tree-node-key node)) value)) + (source-node-id node-id) + (root-node-id + (plist-get (plist-get state :root-node) + :node-id)) + key) + (while (and node-id (not key)) + (when-let* ((node (gethash node-id node-table))) + (setq key (ebox-tree-node-key node))) + (setq node-id + (and (not key) + (gethash node-id parent-table)))) + (or key + (and (equal source-node-id root-node-id) 'root) + value)) value))) (semantic-properties (lambda (state text position) @@ -1040,17 +764,17 @@ remain retained identities." "Formatting-context reflow keeps one rollback boundary and can retry." (cl-labels ((root (message toggle) - (ebox-column - (ebox-create :key 'header :content "Header" :width '(160)) - (ebox-create + (ebox-test-column + (ebox-test-box :key 'header :content "Header" :width '(160)) + (ebox-test-box :key 'shell :width '(160) :ebox-content-node - (ebox-column - (ebox-create :key 'message :host-ref 'message :content message) - (ebox-flex :width '(120) :height 1 - (ebox-create :key 'toggle :host-ref 'toggle + (ebox-test-column + (ebox-test-box :key 'message :host-ref 'message :content message) + (ebox-test-flex :width '(120) :height 1 + (ebox-test-box :key 'toggle :host-ref 'toggle :content toggle)))) - (ebox-create :key 'footer :content "Footer" :width '(160))))) + (ebox-test-box :key 'footer :content "Footer" :width '(160))))) (dolist (failure-kind '(client-state final-accept)) (let ((buffer (generate-new-buffer (format " *ebox-formatting-context-%S*" failure-kind)))) @@ -1068,12 +792,12 @@ remain retained identities." (candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'message - (ebox-create + (ebox-test-box :key 'message :host-ref 'message :content "Behavior toggle: on / callback active / a longer status line")) (ebox-candidate-replace-host-ref candidate 'toggle - (ebox-create :key 'toggle :host-ref 'toggle + (ebox-test-box :key 'toggle :host-ref 'toggle :content "Behavior: on")) (if (eq failure-kind 'client-state) (let ((tp--surface-publication-step-function @@ -1094,12 +818,12 @@ remain retained identities." (let ((retry (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref retry 'message - (ebox-create + (ebox-test-box :key 'message :host-ref 'message :content "Behavior toggle: on / callback active / a longer status line")) (ebox-candidate-replace-host-ref retry 'toggle - (ebox-create :key 'toggle :host-ref 'toggle + (ebox-test-box :key 'toggle :host-ref 'toggle :content "Behavior: on")) (let ((report (ebox-commit buffer retry))) (should (eq (plist-get report :projection-kind) @@ -1112,12 +836,12 @@ remain retained identities." (ert-deftest ebox-commit-multi-owner-variable-content-keeps-fixed-slots () "Two fixed Grid slots accept unequal one-line content in one publication." (let* ((new-left - (ebox-create :key 'left :host-ref 'left + (ebox-test-box :key 'left :host-ref 'left :content "L")) (new-right - (ebox-create :key 'right :host-ref 'right :content "R")) + (ebox-test-box :key 'right :host-ref 'right :content "R")) (old-root - (ebox-build + (ebox-test-build '(grid :key grid :width (80) :grid-template-columns ((36) (36)) :column-gap (0 (8)) @@ -1148,7 +872,7 @@ remain retained identities." (ebox-render-to-buffer (generate-new-buffer-name " *ebox-multi-owner-variable-slots-fresh*") - (ebox-build + (ebox-test-build '(grid :key grid :width (80) :grid-template-columns ((36) (36)) :column-gap (0 (8)) @@ -1168,19 +892,19 @@ remain retained identities." "Variable multi-owner spans restore old state at both TP failure points." (cl-labels ((root (left right) - (ebox-grid :key 'grid :width '(80) + (ebox-test-grid :key 'grid :width '(80) :grid-template-columns '((36) (36)) :column-gap '(0 (8)) - (ebox-create :key 'left :host-ref 'left :content left) - (ebox-create :key 'right :host-ref 'right :content right))) + (ebox-test-box :key 'left :host-ref 'left :content left) + (ebox-test-box :key 'right :host-ref 'right :content right))) (candidate (buffer) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'left - (ebox-create :key 'left :host-ref 'left :content "L")) + (ebox-test-box :key 'left :host-ref 'left :content "L")) (ebox-candidate-replace-host-ref candidate 'right - (ebox-create :key 'right :host-ref 'right :content "R")) + (ebox-test-box :key 'right :host-ref 'right :content "R")) candidate))) (dolist (failure-kind '(client-state final-accept)) (let ((buffer (generate-new-buffer @@ -1217,27 +941,33 @@ remain retained identities." (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." +(ert-deftest ebox-commit-updates-canonical-runtime-type-counts () + "A structural candidate should publish exact Text and Box 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))))))) + (box :key child :width (40) "A"))))) (before (gethash buffer ebox--buffer-render-state-table))) (unwind-protect (progn (should (= (gethash - 'item (plist-get before :runtime-type-count-table)) + 'box (plist-get before :runtime-type-count-table)) + 2)) + (should (= (gethash + 'text (plist-get before :runtime-type-count-table)) 1)) (ebox-commit - buffer (ebox-create :key 'root :content "B" :width '(80))) + buffer (ebox-test-box :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)) + (should (= (gethash + 'text (plist-get after :runtime-type-count-table)) 1)))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -1246,11 +976,11 @@ remain retained identities." "A failed candidate must leave the previously published buffer intact." (let ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-rollback*") - (ebox-create :key 'root :content "Stable" :width '(80))))) + (ebox-test-box :key 'root :content "Stable" :width '(80))))) (unwind-protect (progn (should-error - (ebox-commit buffer (ebox-create :key 'root :content nil + (ebox-commit buffer (ebox-test-box :key 'root :content nil :width 'invalid))) (should (string-prefix-p "Stable" @@ -1265,8 +995,8 @@ remain retained identities." (let* ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-participation-rollback*") - (ebox-column - (ebox-create :key 'target :host-ref 'target + (ebox-test-column + (ebox-test-box :key 'target :host-ref 'target :content "Stable" :width '(80))))) (surface (with-current-buffer buffer ebox-surface--buffer-surface)) (revision (tp-surface-revision surface)) @@ -1278,7 +1008,7 @@ remain retained identities." ;; replacement is legal. The final Column graft is authoritative. (ebox-candidate-replace-host-ref candidate 'target - (ebox-create :key 'target :host-ref 'target + (ebox-test-box :key 'target :host-ref 'target :content "Invalid" :width '(80) :flex-grow 1)) (should-error (ebox-commit buffer candidate) :type 'error) (should (= (tp-surface-revision surface) revision)) @@ -1286,62 +1016,39 @@ remain retained identities." (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-candidate-cannot-launder-legacy-adapter-provenance () - "A public path copy must not authenticate a replacement as adapter output." - (let* ((adapter - (ebox-flex-item - (ebox-create :host-ref 'target :content "Stable" :width '(80)))) - (buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-adapter-proof-rollback*") - (ebox-column adapter))) - (surface (with-current-buffer buffer ebox-surface--buffer-surface)) - (revision (tp-surface-revision surface)) - (before (ebox-commit-test--buffer-string buffer)) - (candidate (ebox-candidate-begin buffer)) - (replacement - (ebox-create :host-ref 'target :content "Invalid" :width '(80)))) - (plist-put replacement :flex-grow 1) - (unwind-protect - (progn - (ebox-candidate-replace-host-ref candidate 'target replacement) - (should-error (ebox-commit buffer candidate) :type 'error) - (should (= (tp-surface-revision surface) revision)) - (should (equal (ebox-commit-test--buffer-string buffer) before))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - (ert-deftest ebox-candidate-participation-validation-stays-changed-local () "One replacement must not participation-validate every sibling." (let* ((children (cl-loop for index below 200 collect (if (= index 99) - (ebox-create :key index :host-ref 'target + (ebox-test-box :key index :host-ref 'target :content (number-to-string index)) - (ebox-create :key index + (ebox-test-box :key index :content (number-to-string index))))) (buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-participation-local*") - (apply #'ebox-column children))) + (apply #'ebox-test-column children))) (candidate (ebox-candidate-begin buffer)) (original (symbol-function 'ebox-tree-validate-indexed-participation)) - author-node-counts) + validation-frontiers) (unwind-protect (progn (ebox-candidate-replace-host-ref candidate 'target - (ebox-create :key 99 :host-ref 'target :content "changed")) + (ebox-test-box :key 99 :host-ref 'target :content "changed")) (cl-letf (((symbol-function 'ebox-tree-validate-indexed-participation) (lambda (node-table parent-table node-ids &optional source) - (when (eq source 'author) - (push (length node-ids) author-node-counts)) + (push (cons source (length node-ids)) + validation-frontiers) (funcall original node-table parent-table node-ids source)))) (ebox-commit buffer candidate)) - (should (equal author-node-counts '(2)))) + ;; The changed Box, its Text leaf, and direct parent context are the + ;; complete validation frontier; 197 siblings remain untouched. + (should (equal validation-frontiers '((computed . 3))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -1351,20 +1058,20 @@ remain retained identities." (second (generate-new-buffer " *ebox-root-valid-second*"))) (unwind-protect (progn - (ebox-render-to-buffer first (ebox-create :key 'root :content "one")) - (ebox-render-to-buffer second (ebox-create :key 'root :content "two")) + (ebox-render-to-buffer first (ebox-test-box :key 'root :content "one")) + (ebox-render-to-buffer second (ebox-test-box :key 'root :content "two")) (let ((candidate (ebox-candidate-begin first))) (should-error (ebox-candidate-replace-root candidate "invalid")) (ebox-candidate-replace-root - candidate (ebox-create :key 'root :content "candidate")) + candidate (ebox-test-box :key 'root :content "candidate")) (should-error (ebox-commit second candidate)) (should-error (ebox-candidate-replace-root - candidate (ebox-create :key 'root :content "sealed")))) + candidate (ebox-test-box :key 'root :content "sealed")))) (let ((candidate (ebox-candidate-begin first))) (ebox-candidate-replace-root - candidate (ebox-create :key 'root :content "stale")) - (ebox-commit first (ebox-create :key 'root :content "new-base")) + candidate (ebox-test-box :key 'root :content "stale")) + (ebox-commit first (ebox-test-box :key 'root :content "new-base")) (should-error (ebox-commit first candidate)))) (dolist (buffer (list first second)) (when (buffer-live-p buffer) (kill-buffer buffer)))))) @@ -1374,15 +1081,15 @@ remain retained identities." (let ((buffer (generate-new-buffer " *ebox-framework-compat*"))) (unwind-protect (progn - (ebox-render-to-buffer buffer (ebox-create :key 'root :content "0")) + (ebox-render-to-buffer buffer (ebox-test-box :key 'root :content "0")) (should (eq (plist-get - (ebox-commit buffer (ebox-create :key 'root :content "1")) + (ebox-commit buffer (ebox-test-box :key 'root :content "1")) :framework-participant-state) 'completed)) (let ((calls 0) seen) (let ((report (ebox-commit - buffer (ebox-create :key 'root :content "2") + buffer (ebox-test-box :key 'root :content "2") (lambda (value) (cl-incf calls) (setq seen value))))) (should (= calls 1)) (should (eq seen report)) @@ -1398,14 +1105,14 @@ remain retained identities." (progn (ebox-render-to-buffer buffer - (ebox-column - (ebox-create :key 'a :host-ref 'a :content "old-a") - (ebox-create :key 'b :host-ref 'b :content "old-b"))) + (ebox-test-column + (ebox-test-box :key 'a :host-ref 'a :content "old-a") + (ebox-test-box :key 'b :host-ref 'b :content "old-b"))) (let* ((before (ebox-commit-test--buffer-string buffer)) (candidate (ebox-candidate-begin buffer)) trace captured failure) (ebox-candidate-replace-host-ref - candidate 'a (ebox-create :key 'a :host-ref 'a :content "new-a")) + candidate 'a (ebox-test-box :key 'a :host-ref 'a :content "new-a")) (cl-letf (((symbol-function 'accept-change-group) (lambda (_group) (error "accept failed")))) (setq failure @@ -1414,7 +1121,7 @@ remain retained identities." buffer (if (eq mode 'scoped) candidate - (ebox-create :key 'root :content "new-root")) + (ebox-test-box :key 'root :content "new-root")) (lambda (report) (setq captured report) (push 'publish trace)) @@ -1434,7 +1141,7 @@ remain retained identities." (ebox-commit-test--buffer-string buffer) before)) (should (eq (plist-get (ebox-commit - buffer (ebox-create :key 'root :content "next")) + buffer (ebox-test-box :key 'root :content "next")) :framework-participant-state) 'completed)))) (when (buffer-live-p buffer) (kill-buffer buffer)))))) @@ -1449,14 +1156,14 @@ remain retained identities." :condition (quit))))) (unwind-protect (progn - (ebox-render-to-buffer buffer (ebox-create :key 'root :content "0")) + (ebox-render-to-buffer buffer (ebox-test-box :key 'root :content "0")) (cl-letf (((symbol-function 'ebox-incremental--finalize-declarative-scroll-publication) (lambda (&rest _arguments) diagnostics))) (let ((report (ebox-commit - buffer (ebox-create :key 'root :content "1")))) + buffer (ebox-test-box :key 'root :content "1")))) (should (eq (plist-get report :framework-participant-state) 'completed)) (should (equal @@ -1464,7 +1171,7 @@ remain retained identities." diagnostics)))) (should (eq (plist-get (ebox-commit buffer - (ebox-create :key 'root :content "2")) + (ebox-test-box :key 'root :content "2")) :framework-participant-state) 'completed))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -1474,7 +1181,7 @@ remain retained identities." (let ((buffer (generate-new-buffer " *ebox-participant-owner-failure*"))) (unwind-protect (progn - (ebox-render-to-buffer buffer (ebox-create :key 'root :content "old")) + (ebox-render-to-buffer buffer (ebox-test-box :key 'root :content "old")) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (old-state (tp-surface-client-state surface)) @@ -1493,7 +1200,7 @@ remain retained identities." (setq failure (condition-case condition (ebox-commit - buffer (ebox-create :key 'root :content "new") + buffer (ebox-test-box :key 'root :content "new") #'ignore (lambda (_report) (setq rollback-called t))) (error condition)))) @@ -1510,22 +1217,22 @@ remain retained identities." (defun ebox-commit-test--mixed-owner-root (left right paint-a paint-b paint-c) "Return the raw sibling fixture for mixed geometry/paint publication." - (ebox-column - (ebox-create + (ebox-test-column + (ebox-test-box :key 'geometry-context :width '(80) :ebox-content-node - (ebox-column - (ebox-create :key 'left :host-ref 'left + (ebox-test-column + (ebox-test-box :key 'left :host-ref 'left :content left :font 'bold) - (ebox-create :key 'right :host-ref 'right + (ebox-test-box :key 'right :host-ref 'right :content right))) - (ebox-create :key 'paint-a :host-ref 'paint-a + (ebox-test-box :key 'paint-a :host-ref 'paint-a :content "paint-a" :color paint-a) - (ebox-create :key 'paint-b :host-ref 'paint-b + (ebox-test-box :key 'paint-b :host-ref 'paint-b :content "paint-b" :bgcolor paint-b) - (ebox-create :key 'paint-c :host-ref 'paint-c + (ebox-test-box :key 'paint-c :host-ref 'paint-c :content "paint-c" :color paint-c) - (ebox-create :key 'untouched :host-ref 'untouched + (ebox-test-box :key 'untouched :host-ref 'untouched :content "untouched"))) (ert-deftest ebox-candidate-host-paint-patch-preserves-subtree-identity () @@ -1533,27 +1240,27 @@ remain retained identities." (let* ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-host-paint-patch* ") - (ebox-create + (ebox-test-box :key 'target :host-ref 'target :bgcolor "#111111" :ebox-content-node - (ebox-column - (ebox-create :key 'child :host-ref 'child :content "child"))))) + (ebox-test-column + (ebox-test-box :key 'child :host-ref 'child :content "child"))))) (child-id (plist-get (ebox--host-ref-node buffer 'child) :node-id))) (unwind-protect (progn (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-patch-host-paint candidate 'target - (ebox-create + (ebox-test-box :key 'target :host-ref 'target :bgcolor "#111111" :ebox-content-node - (ebox-column - (ebox-create :key 'child :host-ref 'child :content "child"))) - (ebox-create + (ebox-test-column + (ebox-test-box :key 'child :host-ref 'child :content "child"))) + (ebox-test-box :key 'target :host-ref 'target :bgcolor "#EEEEEE" :ebox-content-node - (ebox-column - (ebox-create :key 'child :host-ref 'child :content "child")))) + (ebox-test-column + (ebox-test-box :key 'child :host-ref 'child :content "child")))) (let ((report (ebox-commit buffer candidate))) (should (eq (plist-get report :projection-kind) 'paint)) (should (= child-id @@ -1566,51 +1273,57 @@ remain retained identities." (should-not (ebox-candidate-patch-host-paint candidate 'target - (ebox-create + (ebox-test-box :key 'target :host-ref 'target :bgcolor "#EEEEEE" :content "child") - (ebox-create + (ebox-test-box :key 'target :host-ref 'target :bgcolor "#EEEEEE" :width '(40) :content "child")))) (let ((candidate (ebox-candidate-begin buffer))) (should (ebox-candidate-patch-host-paint candidate 'target - (ebox-create + (ebox-test-box :key 'target :host-ref 'target :bgcolor "#EEEEEE" :ebox-content-node - (ebox-column - (ebox-create :key 'child :host-ref 'child :content "child"))) - (ebox-create + (ebox-test-column + (ebox-test-box :key 'child :host-ref 'child :content "child"))) + (ebox-test-box :key 'target :host-ref 'target :bgcolor "#EEEEEE" :color "#FFFFFF" :ebox-content-node - (ebox-column - (ebox-create :key 'child :host-ref 'child :content "child"))))) + (ebox-test-column + (ebox-test-box :key 'child :host-ref 'child :content "child"))))) (ebox-commit buffer candidate) (should (equal "#FFFFFF" - (plist-get (ebox--host-ref-node buffer 'target) :color))) - (should - (equal "#FFFFFF" - (plist-get (ebox--host-ref-node buffer 'child) :color))))) + (ebox-style-node-specified-value + (ebox--host-ref-node buffer 'target) :color))) + (let* ((rendered (ebox-commit-test--buffer-string buffer)) + (position (string-match "child" rendered))) + (should position) + (should + (equal "#FFFFFF" + (ebox-commit-test--face-value + (get-text-property position 'face rendered) + :foreground)))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (defun ebox-commit-test--fixed-basis-selection-root (row-1 row-2) "Return a stretched fixed-basis panel containing two selectable rows." (let ((panel - (ebox-create + (ebox-test-box :key 'selection-panel :width 'stretch :min-width 0 :min-height 24 :flex-grow 2 :flex-shrink 1 :flex-basis '(340) :ebox-content-node - (ebox-column - (ebox-create :key 'row-1 :host-ref 'row-1 :content row-1) - (ebox-create :key 'row-2 :host-ref 'row-2 :content row-2))))) - (ebox-flex + (ebox-test-column + (ebox-test-box :key 'row-1 :host-ref 'row-1 :content row-1) + (ebox-test-box :key 'row-2 :host-ref 'row-2 :content row-2))))) + (ebox-test-flex :key 'fixed-basis-selection-root :width '(900) :height 24 :flex-flow '(row nowrap) :align-items 'stretch - (ebox-create :key 'peer :content "peer" :width 'stretch + (ebox-test-box :key 'peer :content "peer" :width 'stretch :min-width 0 :min-height 24 :flex-grow 4 :flex-shrink 1 :flex-basis '(620)) panel))) @@ -1621,10 +1334,10 @@ remain retained identities." (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'row-1 - (ebox-create :key 'row-1 :host-ref 'row-1 :content row-1)) + (ebox-test-box :key 'row-1 :host-ref 'row-1 :content row-1)) (ebox-candidate-replace-host-ref candidate 'row-2 - (ebox-create :key 'row-2 :host-ref 'row-2 :content row-2)) + (ebox-test-box :key 'row-2 :host-ref 'row-2 :content row-2)) candidate)) (ert-deftest ebox-commit-fixed-basis-selection-round-trip-stays-local () @@ -1667,8 +1380,8 @@ remain retained identities." (panel-snapshot (and snapshots (gethash panel-id snapshots)))) (push report reports) - (should (eq (plist-get report :projection-kind) - 'owner-scoped)) + (should (memq (plist-get report :projection-kind) + '(span-patch owner-scoped))) (should-not (member root-id (plist-get report :owner-ids))) (should-not (plist-get report :tp-full-root)) (should-not (plist-get report :tp-scope-fallback)) @@ -1695,192 +1408,28 @@ remain retained identities." (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'left - (ebox-create :key 'left :host-ref 'left + (ebox-test-box :key 'left :host-ref 'left :content left :font 'bold)) (ebox-candidate-replace-host-ref candidate 'right - (ebox-create :key 'right :host-ref 'right + (ebox-test-box :key 'right :host-ref 'right :content right)) (ebox-candidate-replace-host-ref candidate 'paint-a - (ebox-create :key 'paint-a :host-ref 'paint-a + (ebox-test-box :key 'paint-a :host-ref 'paint-a :content "paint-a" :color paint-a)) (ebox-candidate-replace-host-ref candidate 'paint-b - (ebox-create :key 'paint-b :host-ref 'paint-b + (ebox-test-box :key 'paint-b :host-ref 'paint-b :content "paint-b" :bgcolor paint-b)) (ebox-candidate-replace-host-ref candidate 'paint-c - (ebox-create :key 'paint-c :host-ref 'paint-c + (ebox-test-box :key 'paint-c :host-ref 'paint-c :content "paint-c" :color paint-c)) candidate)) -(ert-deftest ebox-commit-mixed-owner-reflow-is-atomic-and-round-trips () - "Geometry and color owners publish once without duplicating face layers." - (let ((buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-mixed-owner* ") - (ebox-commit-test--mixed-owner-root - "left-old" "right-old" "#111111" "#222222" "#333333")))) - (unwind-protect - (progn - (let ((candidate (ebox-candidate-begin buffer))) - (ebox-candidate-replace-host-ref - candidate 'left - (ebox-create :key 'left :host-ref 'left - :content "left-new" :font 'bold)) - (ebox-candidate-replace-host-ref - candidate 'right - (ebox-create :key 'right :host-ref 'right - :content "right-new")) - (ebox-candidate-replace-host-ref - candidate 'paint-a - (ebox-create :key 'paint-a :host-ref 'paint-a - :content "paint-a" :color "#AAAAAA")) - (ebox-candidate-replace-host-ref - candidate 'paint-b - (ebox-create :key 'paint-b :host-ref 'paint-b - :content "paint-b" :bgcolor "#BBBBBB")) - (ebox-candidate-replace-host-ref - candidate 'paint-c - (ebox-create :key 'paint-c :host-ref 'paint-c - :content "paint-c" :color "#CCCCCC")) - (let ((report (ebox-commit buffer candidate))) - (should (eq (plist-get report :projection-kind) - 'mixed-owner-reflow)) - (should-not (plist-get report :tp-full-root)) - (should-not (plist-get report :tp-scope-fallback)) - (should-not - (member (ebox--buffer-root-node-id buffer) - (plist-get report :owner-ids))) - (should (= (plist-get report :created-objects) 0)) - (should (= (plist-get report :removed-objects) 0)) - (with-current-buffer buffer - (should - (equal-including-properties - (buffer-substring (point-min) (point-max)) - (ebox-render (ebox--buffer-root-node buffer))))))) - (let ((candidate (ebox-candidate-begin buffer))) - (ebox-candidate-replace-host-ref - candidate 'left - (ebox-create :key 'left :host-ref 'left - :content "left-old" :font 'bold)) - (ebox-candidate-replace-host-ref - candidate 'right - (ebox-create :key 'right :host-ref 'right - :content "right-old")) - (ebox-candidate-replace-host-ref - candidate 'paint-a - (ebox-create :key 'paint-a :host-ref 'paint-a - :content "paint-a" :color "#111111")) - (ebox-candidate-replace-host-ref - candidate 'paint-b - (ebox-create :key 'paint-b :host-ref 'paint-b - :content "paint-b" :bgcolor "#222222")) - (ebox-candidate-replace-host-ref - candidate 'paint-c - (ebox-create :key 'paint-c :host-ref 'paint-c - :content "paint-c" :color "#333333")) - (let ((report (ebox-commit buffer candidate))) - (should (eq (plist-get report :projection-kind) - 'mixed-owner-reflow)) - (with-current-buffer buffer - (should - (equal-including-properties - (buffer-substring (point-min) (point-max)) - (ebox-render (ebox--buffer-root-node buffer)))))))) - (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-commit-mixed-owner-reflow-rolls-back-and-retries () - "Mixed publication restores the old pair after final-accept failure." - (let* ((buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-mixed-owner-rollback* ") - (ebox-commit-test--mixed-owner-root - "left-old" "right-old" "#111111" "#222222" "#333333"))) - (old-buffer (ebox-commit-test--buffer-string buffer)) - (candidate - (ebox-commit-test--mixed-owner-candidate - buffer "left-new" "right-new" "#AAAAAA" "#BBBBBB" "#CCCCCC"))) - (unwind-protect - (progn - (cl-letf (((symbol-function 'accept-change-group) - (lambda (_group) (error "mixed final accept")))) - (should-error (ebox-commit buffer candidate))) - (should (equal-including-properties - (ebox-commit-test--buffer-string buffer) old-buffer)) - (let ((retry - (ebox-commit-test--mixed-owner-candidate - buffer "left-new" "right-new" - "#AAAAAA" "#BBBBBB" "#CCCCCC"))) - (let ((report (ebox-commit buffer retry))) - (should (eq (plist-get report :projection-kind) - 'mixed-owner-reflow)) - (with-current-buffer buffer - (should - (equal-including-properties - (buffer-substring (point-min) (point-max)) - (ebox-render (ebox--buffer-root-node buffer)))))))) - (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-commit-mixed-owner-reflow-reuses-unchanged-fragments () - "Delegate affected face composition to TP and retain fragment topology." - (let* ((buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-mixed-owner-reuse* ") - (ebox-commit-test--mixed-owner-root - "left-old" "right-old" "#111111" "#222222" "#333333"))) - (surface (with-current-buffer buffer ebox-surface--buffer-surface)) - (old-state (tp-surface-client-state surface)) - (fragment-count (length (plist-get old-state :surface-fragments))) - (repaints 0) - (metadata-calls 0) - (original - (symbol-function 'ebox-surface--repaint-output-fragment!)) - (original-metadata - (symbol-function 'ebox-surface--fragment-metadata))) - (unwind-protect - (progn - (should - (cl-every - (lambda (fragment) - (or (null (plist-get fragment :paint-role-ids)) - (plist-get fragment :paint-node-chain))) - (plist-get old-state :surface-fragments))) - (should (plist-get old-state :surface-owned-ranges)) - (cl-letf (((symbol-function 'ebox-surface--repaint-output-fragment!) - (lambda (&rest args) - (cl-incf repaints) - (apply original args))) - ((symbol-function 'ebox-surface--fragment-metadata) - (lambda (&rest args) - (cl-incf metadata-calls) - (apply original-metadata args)))) - (should (eq (plist-get - (ebox-commit - buffer - (ebox-commit-test--mixed-owner-candidate - buffer "left-new" "right-new" - "#AAAAAA" "#BBBBBB" "#CCCCCC")) - :projection-kind) - 'mixed-owner-reflow))) - (should (> fragment-count 0)) - (should (zerop repaints)) - (should - (plist-get (tp-surface-client-state surface) - :paint-property-contributions)) - (let ((fragments - (plist-get (tp-surface-client-state surface) - :surface-fragments)) - (offset 0)) - (should fragments) - (dolist (fragment fragments) - (should (= offset (plist-get fragment :start))) - (setq offset (plist-get fragment :end))) - (should (= offset - (with-current-buffer buffer (buffer-size))))) - (should (= metadata-calls 0))) - (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-surface-owned-range-index-rebases-exact-boundaries () "Rebase retained ownership exactly and reject ambiguous inner boundaries." @@ -1908,11 +1457,11 @@ remain retained identities." (let* ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-structure-proof-domain* ") - (ebox-column - (ebox-create + (ebox-test-column + (ebox-test-box :key 'target :host-ref 'target :ebox-content-node - (ebox-column (ebox-create :key 'first :content "first")))))) + (ebox-test-column (ebox-test-box :key 'first :content "first")))))) (span-calls 0) (mixed-calls 0) (old-span @@ -1923,11 +1472,11 @@ remain retained identities." (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'target - (ebox-create + (ebox-test-box :key 'target :host-ref 'target :ebox-content-node - (ebox-column (ebox-create :key 'first :content "first") - (ebox-create :key 'second :content "second")))) + (ebox-test-column (ebox-test-box :key 'first :content "first") + (ebox-test-box :key 'second :content "second")))) (cl-letf (((symbol-function 'ebox-incremental--span-patch-projection-proof) (lambda (&rest arguments) @@ -1949,9 +1498,9 @@ remain retained identities." (let* ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-range-dirty-boundary* ") - (ebox-column + (ebox-test-column (ebox-child-range - 'rows (ebox-create :key 'old :content "old"))))) + 'rows (ebox-test-box :key 'old :content "old"))))) (state (ebox--buffer-render-state buffer)) (parent-id (plist-get (gethash 'rows (plist-get state :range-ref-table)) @@ -1962,7 +1511,7 @@ remain retained identities." (original (symbol-function 'ebox-incremental--surface-commit-input))) (ebox-candidate-replace-range-ref - candidate 'rows (list (ebox-create :key 'new :content "new"))) + candidate 'rows (list (ebox-test-box :key 'new :content "new"))) (cl-letf (((symbol-function 'ebox-incremental--surface-commit-input) (lambda (target old-state prepared) @@ -1981,78 +1530,35 @@ remain retained identities." (ebox-commit-test--buffer-string buffer)))) (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-commit-mixed-owner-reflow-skips-full-owner-planner () - "A proven mixed union should not also plan the complete dirty set." - (let* ((buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-mixed-owner-plan* ") - (ebox-commit-test--mixed-owner-root - "left-old" "right-old" "#111111" "#222222" "#333333"))) - (calls 0) - (axis-scans 0) - (original - (symbol-function 'ebox-incremental--layout-owner-plan)) - (original-context-axes - (symbol-function 'ebox-surface--context-axes))) - (unwind-protect - (cl-letf - (((symbol-function 'ebox-incremental--layout-owner-plan) - (lambda (&rest arguments) - (cl-incf calls) - (apply original arguments))) - ((symbol-function 'ebox-surface--context-axes) - (lambda (&rest arguments) - (cl-incf axis-scans) - (apply original-context-axes arguments)))) - (let* ((report - (ebox-commit - buffer - (ebox-commit-test--mixed-owner-candidate - buffer "left-new" "right-new" - "#AAAAAA" "#BBBBBB" "#CCCCCC"))) - (owner-ids (plist-get report :owner-ids))) - (should (eq (plist-get report :projection-kind) - 'mixed-owner-reflow)) - (should (= calls 0)) - (should (= axis-scans 0)) - (should (= (plist-get report :tp-scope-count) - (length owner-ids))) - (should-not (plist-get report :tp-scope-fallback)) - (with-current-buffer buffer - (should - (equal-including-properties - (buffer-substring (point-min) (point-max)) - (ebox-render (ebox--buffer-root-node buffer))))))) - (when (buffer-live-p buffer) (kill-buffer buffer))))) (defun ebox-commit-test--allocation-closure-root (toast paint-a paint-b &optional width footer-overflow root-overflow) "Return a generic whole-line Flex allocation-closure fixture." - (ebox-create + (ebox-test-box :key 'root :width (list (or width 800)) :height (and root-overflow 1) :overflow root-overflow :ebox-content-node - (ebox-column - (ebox-create :key 'status :host-ref 'status + (ebox-test-column + (ebox-test-box :key 'status :host-ref 'status :content "Theme: Light" :width '(200)) - (ebox-create :key 'paint-a :host-ref 'paint-a + (ebox-test-box :key 'paint-a :host-ref 'paint-a :content "paint-a" :color paint-a) - (ebox-create :key 'paint-b :host-ref 'paint-b + (ebox-test-box :key 'paint-b :host-ref 'paint-b :content "paint-b" :bgcolor paint-b) - (ebox-create + (ebox-test-box :key 'footer-owner :width (list (or width 800)) :ebox-content-node - (ebox-column - (ebox-flex + (ebox-test-column + (ebox-test-flex :key 'footer :width (list (or width 800)) :flex-wrap 'wrap :overflow footer-overflow :gap '(1 (4)) - (ebox-create + (ebox-test-box :key 'toast-slot :width 'stretch :min-width 0 :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :ebox-content-node - (ebox-column - (ebox-create :key 'toast :host-ref 'toast :content toast))) - (ebox-create :key 'peer :content "database.sqlite"))))))) + (ebox-test-column + (ebox-test-box :key 'toast :host-ref 'toast :content toast))) + (ebox-test-box :key 'peer :content "database.sqlite"))))))) (defun ebox-commit-test--allocation-closure-candidate (buffer toast paint-a paint-b) @@ -2060,14 +1566,14 @@ remain retained identities." (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'toast - (ebox-create :key 'toast :host-ref 'toast :content toast)) + (ebox-test-box :key 'toast :host-ref 'toast :content toast)) (ebox-candidate-replace-host-ref candidate 'paint-a - (ebox-create :key 'paint-a :host-ref 'paint-a + (ebox-test-box :key 'paint-a :host-ref 'paint-a :content "paint-a" :color paint-a)) (ebox-candidate-replace-host-ref candidate 'paint-b - (ebox-create :key 'paint-b :host-ref 'paint-b + (ebox-test-box :key 'paint-b :host-ref 'paint-b :content "paint-b" :bgcolor paint-b)) candidate)) @@ -2079,140 +1585,12 @@ remain retained identities." buffer toast paint-a paint-b))) (ebox-candidate-replace-host-ref candidate 'status - (ebox-create :key 'status :host-ref 'status + (ebox-test-box :key 'status :host-ref 'status :content status :width '(200))) candidate)) -(ert-deftest ebox-commit-allocation-closure-round-trips-exactly () - "Stable Flex allocation closes one content damage plus external paint." - (let* ((buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-allocation-closure* ") - (ebox-commit-test--allocation-closure-root - "Light" "#111111" "#222222"))) - (surface (with-current-buffer buffer ebox-surface--buffer-surface)) - (full-renders 0) - (original-full-render - (symbol-function 'ebox-surface--render-candidate))) - (unwind-protect - (cl-letf (((symbol-function 'ebox-surface--render-candidate) - (lambda (&rest arguments) - (cl-incf full-renders) - (apply original-full-render arguments)))) - (dolist (values '(("A longer dark notification" - "#AAAAAA" "#BBBBBB") - ("Light" "#111111" "#222222"))) - (let* ((before (tp-surface-revision surface)) - (candidate - (apply #'ebox-commit-test--allocation-closure-candidate - buffer values)) - (report (ebox-commit buffer candidate)) - (state (tp-surface-client-state surface)) - (owner-proof - (car (plist-get - (plist-get (plist-get state :mixed-owner-proof) - :geometry-proof) - :owner-proofs)))) - (should (eq (plist-get report :projection-kind) - 'mixed-owner-reflow)) - (should (plist-get owner-proof :allocation-closure-p)) - (should (= (tp-surface-revision surface) (1+ before))) - (should-not (plist-get report :tp-full-root)) - (should-not (plist-get report :tp-scope-fallback)) - (should (= (plist-get report :created-objects) 0)) - (should (= (plist-get report :removed-objects) 0)) - (should (= (or (plist-get report :moved-objects) 0) 0)) - (with-current-buffer buffer - (should - (equal-including-properties - (buffer-substring (point-min) (point-max)) - (ebox-render (ebox--buffer-root-node buffer))))))) - (should (= full-renders 0))) - (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-commit-allocation-closure-rolls-back-and-retries () - "Failed allocation-closure publication restores state before retry." - (let* ((buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-allocation-rollback* ") - (ebox-commit-test--allocation-closure-root - "Light" "#111111" "#222222"))) - (surface (with-current-buffer buffer ebox-surface--buffer-surface)) - (state (tp-surface-client-state surface)) - (revision (tp-surface-revision surface)) - (text (ebox-commit-test--buffer-string buffer))) - (unwind-protect - (progn - (cl-letf (((symbol-function 'accept-change-group) - (lambda (_group) (error "allocation final accept")))) - (should-error - (ebox-commit - buffer - (ebox-commit-test--allocation-closure-candidate - buffer "A longer dark notification" "#AAAAAA" "#BBBBBB")))) - (should (= (tp-surface-revision surface) revision)) - (should (eq (tp-surface-client-state surface) state)) - (should (equal-including-properties - (ebox-commit-test--buffer-string buffer) text)) - (should (eq (plist-get - (ebox-commit - buffer - (ebox-commit-test--allocation-closure-candidate - buffer "A longer dark notification" - "#AAAAAA" "#BBBBBB")) - :projection-kind) - 'mixed-owner-reflow))) - (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-commit-two-geometry-allocation-closure-round-trips () - "Combine one ordinary span with one allocation closure after global miss." - (let* ((buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-two-geometry-closure* ") - (ebox-commit-test--allocation-closure-root - "Light" "#111111" "#222222"))) - (surface (with-current-buffer buffer ebox-surface--buffer-surface)) - (full-renders 0) - (original-full-render - (symbol-function 'ebox-surface--render-candidate))) - (unwind-protect - (cl-letf (((symbol-function 'ebox-surface--render-candidate) - (lambda (&rest arguments) - (cl-incf full-renders) - (apply original-full-render arguments)))) - (dolist (values '(("Theme: Dark" "A longer dark notification" - "#AAAAAA" "#BBBBBB") - ("Theme: Light" "Light" - "#111111" "#222222"))) - (let* ((before (tp-surface-revision surface)) - (report - (ebox-commit - buffer - (apply - #'ebox-commit-test--two-geometry-allocation-candidate - buffer values))) - (state (tp-surface-client-state surface)) - (proofs - (plist-get - (plist-get (plist-get state :mixed-owner-proof) - :geometry-proof) - :owner-proofs))) - (should (eq (plist-get report :projection-kind) - 'mixed-owner-reflow)) - (should (= 2 (length proofs))) - (should (cl-some (lambda (proof) - (plist-get proof :allocation-closure-p)) - proofs)) - (should (= (tp-surface-revision surface) (1+ before))) - (should-not (plist-get report :tp-full-root)) - (should-not (plist-get report :tp-scope-fallback)) - (with-current-buffer buffer - (should - (equal-including-properties - (buffer-substring (point-min) (point-max)) - (ebox-render (ebox--buffer-root-node buffer))))))) - (should (= full-renders 0))) - (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-allocation-closure-allows-recomposable-ancestor-paint () "Allow ancestor paint but reject paint at/below an allocation owner." @@ -2232,25 +1610,6 @@ remain retained identities." (ebox-incremental--allocation-closure-paint-disjoint-p state '(3) '(4)))))) -(ert-deftest ebox-commit-allocation-closure-unsafe-shapes-fallback () - "Wrap/width, overflow, and scroll changes cannot use allocation closure." - (dolist (case '((120 nil nil) (800 visible nil) (800 nil scroll))) - (pcase-let ((`(,width ,footer-overflow ,root-overflow) case)) - (let ((buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-allocation-fallback* ") - (ebox-commit-test--allocation-closure-root - "Light" "#111111" "#222222" - width footer-overflow root-overflow)))) - (unwind-protect - (let ((report - (ebox-commit - buffer - (ebox-commit-test--allocation-closure-candidate - buffer (make-string 200 ?X) "#AAAAAA" "#BBBBBB")))) - (should-not (eq (plist-get report :projection-kind) - 'mixed-owner-reflow))) - (when (buffer-live-p buffer) (kill-buffer buffer))))))) (ert-deftest ebox-commit-allocation-closure-proof-misses-fallback () "Topology, selector, cascade, and role misses reject allocation closure." @@ -2265,18 +1624,18 @@ remain retained identities." (ebox-candidate-replace-host-ref candidate 'toast (if (eq kind 'topology) - (ebox-create + (ebox-test-box :key 'toast :host-ref 'toast :ebox-content-node - (ebox-column - (ebox-create :key 'nested-toast + (ebox-test-column + (ebox-test-box :key 'nested-toast :content "A longer notification"))) - (ebox-create :key 'toast :host-ref 'toast + (ebox-test-box :key 'toast :host-ref 'toast :class (and (eq kind 'selector) "changed") :content "A longer notification"))) (ebox-candidate-replace-host-ref candidate 'paint-a - (ebox-create :key 'paint-a :host-ref 'paint-a + (ebox-test-box :key 'paint-a :host-ref 'paint-a :content "paint-a" :color "#AAAAAA")) (let ((report (if (eq kind 'cascade) diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index 95dfb40..5f54c91 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -148,14 +148,14 @@ (setq plain-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-plain-lookahead*") - (ebox-create :content "plain" :width 120))) + (ebox-test-box :content "plain" :width 120))) (setq flex-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-flex-lookahead*") - (ebox-flex + (ebox-test-flex :width '(120) - (ebox-flex-item - (ebox-create :content "item" :width '(40)))))) + (ebox-test-flex-item + (ebox-test-box :content "item" :width '(40)))))) (should (= (ebox--root-reflow-scroll-lookahead-lines-for-buffer plain-buffer) @@ -240,7 +240,7 @@ (ert-deftest ebox-scroll-prefix-publishes-private-line-with-suffix-paint () "A cached hidden source line should not rebuild the rendered prefix." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "" :width 20 :height 2)) + (let* ((box (ebox-test-box :content "" :width 20 :height 2)) (content-lines '("zero" "one" "two" "three")) (state (list :content-lines (seq-take content-lines 3) :rendered-content-lines @@ -413,7 +413,7 @@ (let* ((ebox-viewport-width 240) (ebox-viewport-height 6) (layout - (ebox-build + (ebox-test-build '(box :width (viewport) :height (viewport-height) :bgcolor "#151A23" (column @@ -442,7 +442,7 @@ (ebox-style-add-rule ".card" '(:color "#F8FAFC" :bgcolor "#1E293B") :layer 'components) - (let* ((node (ebox-create :class 'card :content "Cascade" :width '(96))) + (let* ((node (ebox-test-box :class 'card :content "Cascade" :width '(96))) (face (ebox-test--first-content-face (ebox-render node)))) (should (ebox-test--face-has-key-value-p face :foreground "#F8FAFC")) @@ -543,9 +543,9 @@ "A child nil color should block inheritance without inheriting background." (ebox-test--reset-runtime-state) (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) - (child (ebox-create :id 'child :content "Child" :color nil + (child (ebox-test-box :id 'child :content "Child" :color nil :width '(96))) - (root (ebox-create :class 'panel :ebox-content-node child + (root (ebox-test-box :class 'panel :ebox-content-node child :wrap-mode nil :width '(96)))) (ebox-style-add-rule ".panel" '(:color "#E2E8F0" :bgcolor "#0F172A") @@ -589,9 +589,9 @@ (let ((rendered (substring-no-properties (ebox-render - (ebox-flex - (ebox-create :content "A") - (ebox-create :content "B")))))) + (ebox-test-flex + (ebox-test-box :content "A") + (ebox-test-box :content "B")))))) (should (equal (mapcar #'string-trim-right (split-string rendered "\n")) '("A" "B")))))) @@ -602,9 +602,9 @@ (let ((ebox-style-stylesheet (ecss-stylesheet-create))) (ebox-style-add-rule ".card" '(:color "#94A3B8") :layer 'base) (ebox-style-add-rule ".card" '(:color "#38BDF8") :layer 'components) - (let* ((rule-node (ebox-create :class 'card :content "Rule")) + (let* ((rule-node (ebox-test-box :class 'card :content "Rule")) (inline-node - (ebox-create :class 'card :content "Inline" :color "#F8FAFC"))) + (ebox-test-box :class 'card :content "Inline" :color "#F8FAFC"))) (should (equal (ebox-test--effective-face-value (ebox-test--first-content-face (ebox-render rule-node)) @@ -630,9 +630,9 @@ :layer 'layout) (ebox-style-add-rule ".placed" '(:grid-column 2) :layer 'layout) (ebox-test--with-rendered-buffer - (ebox-grid - (ebox-create :class 'placed :content "Placed") - (ebox-create :content "Auto")) + (ebox-test-grid + (ebox-test-box :class 'placed :content "Placed") + (ebox-test-box :content "Auto")) (let* ((root (ebox-test--runtime-root (current-buffer))) (placed (car (plist-get root :children)))) (should (equal (plist-get (plist-get root :props) @@ -646,10 +646,10 @@ "Font computed values should reach child text before pixel measurement." (ebox-test--reset-runtime-state) (let* ((child - (ebox-create :id 'child :content "MMMM" + (ebox-test-box :id 'child :content "MMMM" :font-family "Child Family")) (root - (ebox-create :ebox-content-node child :wrap-mode nil + (ebox-test-box :ebox-content-node child :wrap-mode nil :font-family "Parent Family" :font-height 1.25 :font-weight 'bold)) measured-face) @@ -685,7 +685,7 @@ (child "#1D4ED8") (rendered (ebox-render - (ebox-build + (ebox-test-build `(column :width (120) :height 5 :padding-block 1 :bgcolor ,parent (box :width (120) :height 1 :margin-bottom 1 @@ -720,12 +720,12 @@ "A pure paint change should not recompute spatial layout." (ebox-test--reset-runtime-state) (let* ((target - (ebox-create :id 'target :content "Paint" :width '(60) + (ebox-test-box :id 'target :content "Paint" :width '(60) :color "#E2E8F0" :bgcolor "#1E293B")) (root - (ebox-flex + (ebox-test-flex :width '(160) - (ebox-grid :grid-template-columns '((60)) target)))) + (ebox-test-grid :grid-template-columns '((60)) target)))) (ebox-test--with-rendered-buffer root (let ((handle (ebox-region-resolve (current-buffer) 'target))) (cl-letf (((symbol-function 'ebox--string-pixel-width) @@ -750,9 +750,9 @@ (ert-deftest ebox-region-ids-assigns-stable-ids-before-render () (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "L" :width 40)) - (right (ebox-create :content "R" :width 50)) - (layout (ebox-concat left right)) + (let* ((left (ebox-test-box :content "L" :width 40)) + (right (ebox-test-box :content "R" :width 50)) + (layout (ebox-test-concat left right)) (ids-before (ebox-region-ids layout))) (should (equal ids-before '(1 2))) (should (equal (ebox-region-ids layout) ids-before)) @@ -762,7 +762,7 @@ (ert-deftest ebox-live-mount-uses-runtime-copy-and-leaves-source-stable () "Live publication should update a surface copy without mutating SOURCE." (ebox-test--reset-runtime-state) - (let* ((source (ebox-build + (let* ((source (ebox-test-build '(box :id "root" :content "before\nhidden" :width (120) :color "#111111" :height 1 :overflow scroll))) (buffer (generate-new-buffer " *ebox-live-copy-source*"))) @@ -796,7 +796,7 @@ (ert-deftest ebox-live-mount-allows-one-source-on-independent-surfaces () "One immutable source should mount and update independently in two buffers." (ebox-test--reset-runtime-state) - (let* ((source (ebox-build + (let* ((source (ebox-test-build '(box :id "root" :content "owned\nhidden" :width (120) :height 1 :overflow scroll))) first-buffer second-buffer) @@ -830,7 +830,7 @@ (ert-deftest ebox-live-mount-kill-invalidates-handle () "Killing a surface buffer should invalidate its region handles." (ebox-test--reset-runtime-state) - (let* ((old-source (ebox-create :id "old" :content "old" :width 120)) + (let* ((old-source (ebox-test-box :id "old" :content "old" :width 120)) primary-buffer secondary-buffer) (unwind-protect (progn @@ -859,11 +859,11 @@ (ert-deftest ebox-runtime-node-ids-cover-layout-and-box-nodes () "Every runtime node should have a stable internal node id." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-stack - (ebox-create :content "Top" :width 60) - (ebox-concat - (ebox-create :content "Left" :width 40) - (ebox-create :content "Right" :width 40)))) + (let* ((layout (ebox-test-stack + (ebox-test-box :content "Top" :width 60) + (ebox-test-concat + (ebox-test-box :content "Left" :width 40) + (ebox-test-box :content "Right" :width 40)))) (ids-before (ebox--runtime-node-ids layout))) (should (= (length ids-before) 5)) (should (= (length (delete-dups (copy-sequence ids-before))) 5)) @@ -873,7 +873,7 @@ (ert-deftest ebox-wrapper-child-nodes-are-in-runtime-index () "A wrapper box's child layout should remain visible in runtime state." (ebox-test--reset-runtime-state) - (let* ((node (ebox-build + (let* ((node (ebox-test-build '(column :padding 1 (box :content "One") (box :content "Two")))) @@ -895,11 +895,11 @@ (ert-deftest ebox-region-render-owner-index-maps-boxes-and-flex-wrapper () "Region lookup should map each box to its smallest renderable owner." (ebox-test--reset-runtime-state) - (let* ((plain (ebox-create :id "plain" :content "Plain" :width '(80))) - (flex-child (ebox-create :id "child" :content "Flex child" + (let* ((plain (ebox-test-box :id "plain" :content "Plain" :width '(80))) + (flex-child (ebox-test-box :id "child" :content "Flex child" :width '(80))) - (flex (ebox-flex :width '(120) :padding 1 flex-child)) - (layout (ebox-column plain flex))) + (flex (ebox-test-flex :width '(120) :padding 1 flex-child)) + (layout (ebox-test-column plain flex))) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (runtime-root (ebox-test--runtime-root buffer)) @@ -935,11 +935,11 @@ "A later flex wrapper should not replace an earlier duplicate region owner." (ebox-test--reset-runtime-state) (let* ((region-id 9001) - (plain (ebox-create :content "First" :width '(80))) - (flex (ebox-flex :width '(120) - (ebox-create :content "Later" :width '(80)))) + (plain (ebox-test-box :content "First" :width '(80))) + (flex (ebox-test-flex :width '(120) + (ebox-test-box :content "Later" :width '(80)))) (wrapper (plist-get flex :box)) - (layout (ebox-column plain flex))) + (layout (ebox-test-column plain flex))) (ebox-put plain :region-id region-id) (ebox-put wrapper :region-id region-id) (let* ((index (ebox--runtime-index layout)) @@ -953,9 +953,9 @@ "Nested duplicate flex wrappers should retain recursive first-match lookup." (ebox-test--reset-runtime-state) (let* ((region-id 9002) - (inner (ebox-flex :width '(100) :padding 1 - (ebox-create :content "Inner" :width '(80)))) - (outer (ebox-flex :width '(140) :padding 1 inner)) + (inner (ebox-test-flex :width '(100) :padding 1 + (ebox-test-box :content "Inner" :width '(80)))) + (outer (ebox-test-flex :width '(140) :padding 1 inner)) (inner-wrapper (plist-get inner :box)) (outer-wrapper (plist-get outer :box))) (ebox-put inner-wrapper :region-id region-id) @@ -971,11 +971,11 @@ "Runtime indexing should collect the box table with render overwrite order." (ebox-test--reset-runtime-state) (let* ((shared-region-id 9003) - (inner (ebox-create :content "Inner" :width '(60))) - (outer (ebox-create :content "" :width '(90))) - (flex-child (ebox-create :content "Flex child" :width '(50))) - (flex (ebox-flex :width '(120) :padding 1 flex-child)) - (layout (ebox-column outer flex)) + (inner (ebox-test-box :content "Inner" :width '(60))) + (outer (ebox-test-box :content "" :width '(90))) + (flex-child (ebox-test-box :content "Flex child" :width '(50))) + (flex (ebox-test-flex :width '(120) :padding 1 flex-child)) + (layout (ebox-test-column outer flex)) (render-table (make-hash-table :test 'equal))) (plist-put outer :ebox-content-node inner) ;; Complete rendering records parents after their child content. A @@ -1003,13 +1003,13 @@ (ert-deftest ebox-declarative-candidate-reuses-indexed-region-box-table () "Candidate preparation should not rewalk the tree to collect source boxes." (ebox-test--reset-runtime-state) - (let ((root (ebox-column - (ebox-create :key 'first :content "First" :width '(80)) - (ebox-create :key 'second :content "Second" :width '(80)))) + (let ((root (ebox-test-column + (ebox-test-box :key 'first :content "First" :width '(80)) + (ebox-test-box :key 'second :content "Second" :width '(80)))) (next-root - (ebox-column - (ebox-create :key 'first :content "Changed" :width '(80)) - (ebox-create :key 'second :content "Second" :width '(80))))) + (ebox-test-column + (ebox-test-box :key 'first :content "Changed" :width '(80)) + (ebox-test-box :key 'second :content "Second" :width '(80))))) (ebox-test--with-rendered-buffer root (let* ((buffer (current-buffer)) (old-state (ebox--buffer-render-state buffer)) @@ -1039,8 +1039,8 @@ (ert-deftest ebox-region-render-owner-index-falls-back-for-legacy-state () "A runtime state without the new table should retain recursive lookup." (ebox-test--reset-runtime-state) - (let* ((child (ebox-create :content "Flex child" :width '(80))) - (flex (ebox-flex :width '(120) :padding 1 child)) + (let* ((child (ebox-test-box :content "Flex child" :width '(80))) + (flex (ebox-test-flex :width '(120) :padding 1 child)) (wrapper (plist-get flex :box))) (ebox-test--with-rendered-buffer flex (let* ((buffer (current-buffer)) @@ -1074,10 +1074,10 @@ (ert-deftest ebox-runtime-node-id-survives-viewport-rerender () "Viewport-only rerender should not assign new runtime node ids." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-flex + (let* ((layout (ebox-test-flex :flex-wrap 'wrap - (ebox-create :content "A" :width '(100)) - (ebox-create :content "B" :width '(100)))) + (ebox-test-box :content "A" :width '(100)) + (ebox-test-box :content "B" :width '(100)))) (ids-before nil) buffer) (unwind-protect @@ -1098,20 +1098,20 @@ "Keyed children should keep runtime ids across structural rebuilds." (ebox-test--reset-runtime-state) (let* ((old (ebox--runtime-tree - (ebox-column - (ebox-create :key 'a :content "A" :width 40) - (ebox-create :key 'b :content "B" :width 40)))) + (ebox-test-column + (ebox-test-box :key 'a :content "A" :width 40) + (ebox-test-box :key 'b :content "B" :width 40)))) (old-b-id (ebox--runtime-node-id-by-key old 'b)) (new (ebox--reconcile-runtime-tree old - (ebox-column - (ebox-create :key 'x :content "X" :width 40) - (ebox-create :key 'b :content "B2" :width 40))))) + (ebox-test-column + (ebox-test-box :key 'x :content "X" :width 40) + (ebox-test-box :key 'b :content "B2" :width 40))))) (should (equal (ebox--runtime-node-id-by-key new 'b) old-b-id)))) (ert-deftest ebox-tree-helpers-see-wrapper-child-nodes () "Tree helpers should see wrapper children without rendering static content." - (let* ((node (ebox-build + (let* ((node (ebox-test-build '(column :padding 1 (box :content "Child" :width (120))))) (children (ebox-tree-children node))) @@ -1442,9 +1442,9 @@ "Portable scroll templates rebind complete state to the candidate box." (ebox-test--reset-runtime-state) (let* ((buffer (generate-new-buffer " *ebox-portable-scroll-cache*")) - (old-box (ebox-create :content "old" :scroll-offset 0)) + (old-box (ebox-test-box :content "old" :scroll-offset 0)) (node-id (ebox--ensure-node-id old-box)) - (candidate-box (ebox-create :content "candidate" :scroll-offset 0)) + (candidate-box (ebox-test-box :content "candidate" :scroll-offset 0)) (node-table (make-hash-table :test 'eql)) (template (list :box old-box @@ -1488,7 +1488,7 @@ (actions (list (list 'set 'scroll - (list :box (ebox-create :content "old" :scroll-offset 0) + (list :box (ebox-test-box :content "old" :scroll-offset 0) :content-lines '("alpha") :render-content-prefix prefix-renderer :materialize-content-lines materializer)))) @@ -1549,7 +1549,7 @@ (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil) (layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) (flex :width (viewport) :gap 1 ,@(cl-loop for index below 8 @@ -1577,7 +1577,7 @@ "A cached subtree must restore lazy scroll state for its render context." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(column :id "viewport" :width (viewport) (box :id "scroll" :width (viewport) :height 4 :overflow scroll @@ -1634,7 +1634,7 @@ "An exact prepared root should bypass layout once and never affect children." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (240) (box :id "child" :content "prepared" :height 1)))) buffer) @@ -1685,7 +1685,7 @@ (ert-deftest ebox-prepared-root-render-rejects-stale-runtime () "A stale prepared root should fall back without entering later updates." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build '(box :id "root" :width (240) :content "old"))) + (let* ((layout (ebox-test-build '(box :id "root" :width (240) :content "old"))) buffer (fallback-count 0)) (unwind-protect @@ -1733,7 +1733,7 @@ "Viewport predictions should reuse one nonmutating isolated live tree." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (viewport) (box :content "viewport scratch" :width (viewport) :height 1)))) buffer) @@ -1770,7 +1770,7 @@ "Ordinary root prediction should run only after an idle interval." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (240) (box :content "scheduled ordinary root" :height 1)))) buffer @@ -1896,7 +1896,7 @@ "A TP paint slot should recolor retained output without property rewrites." (let* ((slot (tp-paint-slot-create '(:foreground "red"))) (face (tp-paint-slot-face slot)) - (rendered (ebox-render (ebox-create :content "paint" :color slot))) + (rendered (ebox-render (ebox-test-box :content "paint" :color slot))) (property (get-text-property 0 'face rendered))) (should (eq face property)) (tp-paint-slot-update slot '(:foreground "blue")) @@ -1999,7 +1999,7 @@ (ert-deftest ebox-scroll-rendered-content-lines-own-color-copy-in-place () "Colored scroll lines should add ownership to the color copy in place." - (let* ((box (ebox-create :width 120 + (let* ((box (ebox-test-box :width 120 :color "#112233" :bgcolor "#F4F5F6")) (region-id 901) @@ -2031,7 +2031,7 @@ (ert-deftest ebox-box-render-coalesces-foreground-background-pass () "Foreground and background should not each copy the rendered box lines." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "paint" + (let* ((box (ebox-test-box :content "paint" :width 12 :height 1 :color "#112233" :bgcolor "#ddeeff")) @@ -2056,7 +2056,7 @@ (ert-deftest ebox-incremental-update-scheduler-batches-multiple-style-changes () "Scheduler seed should coalesce multiple property changes into one flush." - (let* ((node (ebox-create :content "Batch" :border-color "#111111")) + (let* ((node (ebox-test-box :content "Batch" :border-color "#111111")) (id (car (ebox-region-ids node)))) (ebox-test--with-rendered-buffer node (ebox-incremental-begin-batch (current-buffer)) @@ -2073,7 +2073,7 @@ "Same-owner paint and geometry must preserve the stronger operation." (dolist (order '((paint geometry) (geometry paint))) (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Batch width" :width '(160) + (let* ((box (ebox-test-box :content "Batch width" :width '(160) :color "black")) (id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -2098,7 +2098,7 @@ (dolist (order '((parent child) (child parent))) (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id parent :width (120) :color "black" (box :id child :content "Child" :width (120) :color "black"))))) @@ -2123,7 +2123,7 @@ "Ancestor paint must run before a descendant text-changing operation." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id parent :width (160) :color "black" (box :id child :content "Child" :width (120) :color "blue"))))) @@ -2144,7 +2144,7 @@ (defun ebox-test--multi-row-batch-flex-layout () "Return a wrapped flex fixture with targets on separate rows." - (ebox-build + (ebox-test-build '(flex :id matrix :width (500) :flex-flow (row wrap) :column-gap (12) :row-gap 1 :padding (0 (12)) :border "#999999" @@ -2225,7 +2225,7 @@ (ert-deftest ebox-layout-module-renders-row-and-column-through-facade () "Facade rendering should still use extracted layout owners." (let ((rendered (ebox-render - (ebox-build + (ebox-test-build '(column (row (box :content "A" :width (40)) @@ -2236,9 +2236,9 @@ (ert-deftest ebox-layout-snapshot-details-are-computed-lazily () "Initial render should store cheap snapshots and complete spans on demand." (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "L" :width 40 :border t)) - (right (ebox-create :content "R" :width 40 :border t)) - (layout (ebox-concat left right)) + (let* ((left (ebox-test-box :content "L" :width 40 :border t)) + (right (ebox-test-box :content "R" :width 40 :border t)) + (layout (ebox-test-concat left right)) buffer) (unwind-protect (progn @@ -2266,7 +2266,7 @@ (ert-deftest ebox-layout-snapshot-details-include-footprint-signatures () "Detailed snapshots should expose explicit L2 footprint signatures." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Footprint" :width '(120) + (let* ((box (ebox-test-box :content "Footprint" :width '(120) :height 1 :border t)) (node-id (ebox--ensure-node-id box))) (ebox-test--with-rendered-buffer box @@ -2300,7 +2300,7 @@ (ert-deftest ebox-role-topology-signature-uses-published-span-index () "Role topology should reflect the published TP mount index." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Indexed" :width '(120) :border t)) + (let* ((box (ebox-test-box :content "Indexed" :width '(120) :border t)) (region-id (ebox--ensure-region-id box))) (ebox-test--with-rendered-buffer box (let ((roles (plist-get (ebox--role-topology-signature @@ -2318,19 +2318,19 @@ "Initial snapshot capture should not scan line ownership for every node." (ebox-test--reset-runtime-state) (let* ((layout (apply - #'ebox-column + #'ebox-test-column (cl-loop for index below 8 collect - (ebox-flex + (ebox-test-flex :border "#64748B" :bgcolor "#F8FAFC" :flex-flow '(row wrap) - (ebox-create + (ebox-test-box :content (format "A%d" index) :width 60 :bgcolor "#DBEAFE") - (ebox-create + (ebox-test-box :content (format "B%d" index) :width 60 :bgcolor "#DCFCE7") - (ebox-create + (ebox-test-box :content (format "C%d" index) :width 60 :bgcolor "#FEF3C7"))))) (calls 0) @@ -2357,10 +2357,10 @@ "Column rendering should not repeatedly measure already stacked strings." (ebox-test--reset-runtime-state) (let* ((layout (apply - #'ebox-column + #'ebox-test-column (cl-loop for index below 12 collect - (ebox-create + (ebox-test-box :content (format "Row %02d" index) :width (+ 40 (* index 2)))))) (multiline-width-calls 0) @@ -2414,7 +2414,7 @@ (ert-deftest ebox-layout-snapshot-records-display-style-and-wrapper-children () "Snapshots should describe display, style, and child ids for wrapper boxes." (ebox-test--reset-runtime-state) - (let* ((node (ebox-build + (let* ((node (ebox-test-build '(column :padding 1 :bgcolor "linen" (box :content "Child A" :width (120)) (box :content "Child B" :width (120))))) @@ -2443,7 +2443,7 @@ (ert-deftest ebox-dirty-set-classifies-paint-only-change () "A color-only change should produce paint dirty, not geometry dirty." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Paint" :width 80 + (let* ((box (ebox-test-box :content "Paint" :width 80 :border t :border-color "red")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -2462,9 +2462,9 @@ buffer layout) (setq layout - (ebox-flex :flex-wrap 'wrap - (ebox-create :content "A" :width '(120)) - (ebox-create :content "B" :width '(120)))) + (ebox-test-flex :flex-wrap 'wrap + (ebox-test-box :content "A" :width '(120)) + (ebox-test-box :content "B" :width '(120)))) (unwind-protect (progn (setq buffer @@ -2487,7 +2487,7 @@ (ert-deftest ebox-dirty-set-classifies-wrapper-child-viewport-geometry () "Viewport reflow under a wrapper should produce geometry dirty, not no-op." (ebox-test--reset-runtime-state) - (let* ((node (ebox-build + (let* ((node (ebox-test-build '(column :padding 1 (box :content "Text that wraps differently when narrow" :wrap-mode word)))) @@ -2510,7 +2510,7 @@ (ert-deftest ebox-patch-set-paints-without-rendered-text-replace () "Paint-only dirty entries should produce paint-patch ops." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Paint" :width 80 + (let* ((box (ebox-test-box :content "Paint" :width 80 :border t :border-color "red")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -2525,9 +2525,9 @@ (ert-deftest ebox-flex-wrapper-style-update-uses-paint-patch () "Flex wrapper style updates should patch faces without replacing spans." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-flex + (let* ((layout (ebox-test-flex :border "#5E7F6A" :bgcolor "#F8FCF8" - (ebox-create :content "A" :width 60 + (ebox-test-box :content "A" :width 60 :bgcolor "#DCEBDD"))) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout @@ -2541,9 +2541,9 @@ (ert-deftest ebox-patch-set-promotes-interleaved-row-child-to-row-owner () "Interleaved row children should promote to their common render owner." (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "Left" :width 60)) - (right (ebox-create :content "Right" :width 60)) - (layout (ebox-row left right)) + (let* ((left (ebox-test-box :content "Left" :width 60)) + (right (ebox-test-box :content "Right" :width 60)) + (layout (ebox-test-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-test--region-update left-id :width '(120)) @@ -2554,9 +2554,9 @@ (ert-deftest ebox-viewport-resize-patches-single-dependent-box () "Viewport resize should patch the smallest dependent owner when possible." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-column - (ebox-create :content "Fixed" :width '(120) :border t) - (ebox-create :content "Fluid" :width 'stretch :border t))) + (let* ((layout (ebox-test-column + (ebox-test-box :content "Fixed" :width '(120) :border t) + (ebox-test-box :content "Fluid" :width 'stretch :border t))) buffer ids-before) (unwind-protect @@ -2587,9 +2587,9 @@ buffer layout) (setq layout - (ebox-flex :flex-wrap 'wrap - (ebox-create :content "A" :width '(120)) - (ebox-create :content "B" :width '(120)))) + (ebox-test-flex :flex-wrap 'wrap + (ebox-test-box :content "A" :width '(120)) + (ebox-test-box :content "B" :width '(120)))) (unwind-protect (progn (setq buffer @@ -2614,13 +2614,13 @@ (ert-deftest ebox-viewport-rerender-patches-column-children-independently () "Viewport resize should publish the correct column scope through TP." (ebox-test--reset-runtime-state) - (let* ((top (ebox-create + (let* ((top (ebox-test-box :content "Top text should wrap into more lines when narrow." :wrap-mode 'word)) - (bottom (ebox-create + (bottom (ebox-test-box :content "Bottom text should also survive after top grows." :wrap-mode 'word)) - (layout (ebox-column top bottom)) + (layout (ebox-test-column top bottom)) buffer) (unwind-protect (progn @@ -2648,7 +2648,7 @@ (ert-deftest ebox-viewport-rerender-noops-without-viewport-deps () "Viewport resize should not rerender when no node depends on viewport width." (ebox-test--reset-runtime-state) - (let ((layout (ebox-create :content "Fixed" :width 80)) + (let ((layout (ebox-test-box :content "Fixed" :width 80)) buffer) (unwind-protect (progn @@ -2669,9 +2669,9 @@ (ert-deftest ebox-region-width-update-report-describes-constraint-change () "Ordinary box geometry updates should use the shared constraint-change model." (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "Left" :width 60)) - (right (ebox-create :content "Right" :width 60)) - (layout (ebox-row left right)) + (let* ((left (ebox-test-box :content "Left" :width 60)) + (right (ebox-test-box :content "Right" :width 60)) + (layout (ebox-test-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-test--region-update left-id :width '(120)) @@ -2692,7 +2692,7 @@ (let* ((ebox-viewport-width 240) (ebox-viewport-height 2) (layout - (ebox-build + (ebox-test-build '(box :id "root" :width (240) :height (viewport-height) :overflow scroll (column @@ -2711,9 +2711,9 @@ (ert-deftest ebox-region-width-update-promotes-parent-layout-owner () "Child geometry changes should patch the parent layout that reflows siblings." (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "Left" :width 60)) - (right (ebox-create :content "Right" :width 60)) - (layout (ebox-row left right)) + (let* ((left (ebox-test-box :content "Left" :width 60)) + (right (ebox-test-box :content "Right" :width 60)) + (layout (ebox-test-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-test--region-update left-id :width '(120)) @@ -2729,7 +2729,7 @@ (ert-deftest ebox-wrapper-child-width-update-promotes-to-wrapper-context () "Child geometry inside a wrapper should promote to the wrapper formatting context." (ebox-test--reset-runtime-state) - (let* ((node (ebox-build + (let* ((node (ebox-test-build '(column :padding 1 (row (box :content "Left" :width (60)) @@ -2755,7 +2755,7 @@ (ert-deftest ebox-patch-set-uses-wrapper-owner-for-contained-layout-change () "Contained layout changes should patch a real owner instead of vanishing." (ebox-test--reset-runtime-state) - (let* ((node (ebox-build + (let* ((node (ebox-test-build '(column (box :content "Stable header" :width (180)) (column :padding 1 @@ -2783,10 +2783,10 @@ "Viewport patch planning should not repeatedly inspect covered dirty nodes." (ebox-test--reset-runtime-state) (let* ((layout (apply - #'ebox-row + #'ebox-test-row (cl-loop for index below 24 collect - (ebox-create + (ebox-test-box :content (format "Fluid %02d" index) :border t)))) buffer) @@ -2823,10 +2823,10 @@ "Large local owner sets should coalesce when they cover most root lines." (ebox-test--reset-runtime-state) (let* ((layout (apply - #'ebox-column + #'ebox-test-column (cl-loop for index below 12 collect - (ebox-create + (ebox-test-box :content (format "Fluid section %02d" index) :border t)))) buffer) @@ -2861,10 +2861,10 @@ (let* ((content "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") (layout (apply - #'ebox-column + #'ebox-test-column (cl-loop for _index below 12 collect - (ebox-create + (ebox-test-box :content content :width 'viewport :wrap-mode 'word @@ -2895,10 +2895,10 @@ (let* ((content "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") (layout (apply - #'ebox-column + #'ebox-test-column (cl-loop for _index below 12 collect - (ebox-create + (ebox-test-box :content content :width 'viewport :wrap-mode 'word @@ -2931,7 +2931,7 @@ (ert-deftest ebox-buffer-render-context-selects-target-buffer () "Buffer render contexts should evaluate render-sensitive work in target buffers." (ebox-test--reset-runtime-state) - (let* ((node (ebox-create :content "context target")) + (let* ((node (ebox-test-box :content "context target")) (buffer nil) (other nil) seen-buffer) @@ -2954,7 +2954,7 @@ (ert-deftest ebox-buffer-render-context-preserves-caller-state-binding () "Render context internals should not capture a caller's STATE variable." (ebox-test--reset-runtime-state) - (let* ((node (ebox-create :content "context state")) + (let* ((node (ebox-test-box :content "context state")) (buffer nil) (state 'caller-scroll-state) seen-state) @@ -3169,7 +3169,7 @@ (ebox-test--reset-runtime-state) (let ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") - (ebox-create :content "before")))) + (ebox-test-box :content "before")))) (unwind-protect (let ((noninteractive nil) (gc-cons-threshold 1000) @@ -3190,7 +3190,7 @@ ((symbol-function 'cancel-timer) #'ignore)) (let ((token (ebox-render-burst-begin))) (ebox-commit - buffer (ebox-create :content "after") + buffer (ebox-test-box :content "after") (lambda (_report) (setq observed-depth ebox--deferred-render-gc-depth))) (should (= observed-depth 2)) @@ -3253,7 +3253,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") - (ebox-create :content "before"))) + (ebox-test-box :content "before"))) (let ((noninteractive nil) (gc-cons-threshold 1000) (gc-cons-percentage 0.1) @@ -3266,7 +3266,7 @@ (unwind-protect (progn (ebox-commit - buffer (ebox-create :content "after") + buffer (ebox-test-box :content "after") (lambda (_report) (setq observed-threshold gc-cons-threshold))) (should (= observed-threshold 4000)) @@ -3293,7 +3293,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") - (ebox-create :width '(viewport) :content "resize"))) + (ebox-test-box :width '(viewport) :content "resize"))) (let ((noninteractive nil) (gc-cons-threshold 1000) (gc-cons-percentage 0.1) @@ -3353,7 +3353,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") - (ebox-create :width '(viewport) :content "resize"))) + (ebox-test-box :width '(viewport) :content "resize"))) (let ((noninteractive t) (gc-cons-threshold 1000) (gc-cons-percentage 0.1) @@ -3414,17 +3414,17 @@ (ebox-test--reset-runtime-state) (let* ((layout (apply - #'ebox-column + #'ebox-test-column (cl-loop for section below 8 collect (apply - #'ebox-flex + #'ebox-test-flex :flex-flow '(row wrap) :row-gap 1 :column-gap '(8) (cl-loop for index below 4 collect - (ebox-create + (ebox-test-box :content (format "S%d-%d" section index) :width 80 :height 1)))))) @@ -3456,17 +3456,17 @@ (ebox-test--reset-runtime-state) (let* ((layout (apply - #'ebox-column + #'ebox-test-column (cl-loop for section below 8 collect (apply - #'ebox-flex + #'ebox-test-flex :flex-flow '(row wrap) :row-gap 1 :column-gap '(8) (cl-loop for index below 4 collect - (ebox-create + (ebox-test-box :content (format "S%d-%d" section index) :width 80 :height 1)))))) @@ -3507,7 +3507,7 @@ "Block padding should publish through its parent without rebuilding line indexes." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(column :id "root" :width (480) (box :id "target" :content "mixed padding" :width (220) :height 3 :box-sizing border-box @@ -3546,7 +3546,7 @@ "Published role ranges should be numeric snapshots of TP-owned mounts." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(column :id "root" :width (480) (box :id "target" :content "target" :width (220) :height 1 :border "#999999") @@ -3581,17 +3581,17 @@ (ebox-test--reset-runtime-state) (let* ((layout (apply - #'ebox-row + #'ebox-test-row (cl-loop for section below 8 collect (apply - #'ebox-flex + #'ebox-test-flex :flex-flow '(row wrap) :row-gap 1 :column-gap '(8) (cl-loop for index below 4 collect - (ebox-create + (ebox-test-box :content (format "S%d-%d" section index) :width 80 :height 1)))))) @@ -3623,11 +3623,11 @@ (ebox-test--reset-runtime-state) (let* ((layout (apply - #'ebox-flex + #'ebox-test-flex :flex-wrap 'wrap (cl-loop for index below 12 collect - (ebox-create + (ebox-test-box :content (format "Item %02d" index) :width 80 :height 1)))) @@ -3656,13 +3656,13 @@ (ebox-test--reset-runtime-state) (let* ((layout (apply - #'ebox-flex + #'ebox-test-flex :flex-wrap 'wrap :height 4 :align-items 'stretch (cl-loop for index below 10 collect - (ebox-create + (ebox-test-box :content (format "Stretch %02d" index) :width 80)))) buffer) @@ -3690,11 +3690,11 @@ (ebox-test--reset-runtime-state) (let* ((layout (apply - #'ebox-flex + #'ebox-test-flex :flex-wrap 'wrap (cl-loop for index below 12 collect - (ebox-create + (ebox-test-box :content (format "Measured %02d" index) :width 80 :height 1)))) @@ -3731,25 +3731,25 @@ (let* ((inner-flexes (cl-loop for index below 6 collect - (ebox-flex + (ebox-test-flex :width 180 :height 3 :flex-flow '(row wrap) :column-gap '(8) - (ebox-create + (ebox-test-box :content (format "A%d" index) :width 48 :height 1) - (ebox-create + (ebox-test-box :content (format "B%d" index) :width 48 :height 1)))) (layout (apply - #'ebox-flex + #'ebox-test-flex :flex-flow '(row wrap) :row-gap 1 :column-gap '(8) (mapcar (lambda (inner) - (ebox-column - (ebox-create :content "Static card" + (ebox-test-column + (ebox-test-box :content "Static card" :width 180 :height 1) inner)) @@ -3780,15 +3780,15 @@ (ebox-test--reset-runtime-state) (let* ((layout (apply - #'ebox-flex + #'ebox-test-flex :flex-flow '(row wrap) :column-gap '(8) (cl-loop for index below 6 collect - (ebox-column - (ebox-create :content (format "Card %d" index) + (ebox-test-column + (ebox-test-box :content (format "Card %d" index) :width 96 :height 1) - (ebox-create :content "Body" :width 96 :height 2))))) + (ebox-test-box :content "Body" :width 96 :height 2))))) buffer) (unwind-protect (progn @@ -3807,9 +3807,9 @@ "Render cacheability should not enumerate viewport-dependent node ids." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-column - (ebox-create :content "Static" :width 120) - (ebox-create :content "Also static" :width 120))) + (ebox-test-column + (ebox-test-box :content "Static" :width 120) + (ebox-test-box :content "Also static" :width 120))) (calls 0) (original (symbol-function 'ebox--viewport-dependent-node-ids))) (let ((ebox--render-cache-table (make-hash-table :test 'equal)) @@ -3826,7 +3826,7 @@ (ert-deftest ebox-render-cacheability-rejects-viewport-dependent-wrapper-child () "Wrapper boxes with viewport-dependent child layout should not reuse static render cache." (ebox-test--reset-runtime-state) - (let* ((node (ebox-build + (let* ((node (ebox-test-build '(column :padding 1 (box :content "Auto child follows viewport" :wrap-mode word))))) @@ -3838,7 +3838,7 @@ (ert-deftest ebox-render-cacheability-allows-definite-width-wrapper-child () "Definite-width boxes bound descendant viewport-dependent layout." (ebox-test--reset-runtime-state) - (let* ((node (ebox-build + (let* ((node (ebox-test-build '(column :width (210) :padding 1 (box :content "Child wraps inside the card" :wrap-mode word))))) @@ -3851,7 +3851,7 @@ (ert-deftest ebox-region-width-update-invalidates-viewport-dependencies () "Width updates can change which boxes participate in viewport resize." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Resizable" :width 80)) + (let* ((box (ebox-test-box :content "Resizable" :width 80)) (box-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-rerender-buffer-with-context (current-buffer) 260) @@ -3864,7 +3864,7 @@ (ert-deftest ebox-viewport-dependencies-include-wrapper-child-layout () "Viewport dependency discovery should see lazy child layout under wrapper boxes." (ebox-test--reset-runtime-state) - (let* ((node (ebox-build + (let* ((node (ebox-test-build '(column :padding 1 (box :content "Auto child follows the preview width" :wrap-mode word)))) @@ -3879,7 +3879,7 @@ (ert-deftest ebox-viewport-dependencies-stop-at-definite-width-wrapper () "Definite wrappers should contain descendant viewport dependencies." (ebox-test--reset-runtime-state) - (let* ((node (ebox-build + (let* ((node (ebox-test-build '(column :width (210) :padding 1 (box :content "Auto child wraps inside the fixed card" :wrap-mode word)))) @@ -3896,7 +3896,7 @@ (let* ((viewport-width 120) (node (let ((ebox-viewport-width viewport-width) (ebox-viewport-height 2)) - (ebox-build + (ebox-test-build '(column :width (120) (box :id child :content "One\nTwo\nThree\nFour" @@ -3932,14 +3932,14 @@ (ert-deftest ebox-viewport-dependency-axes-classify-both-dimensions () "A node may depend on width, height, or both viewport axes." (ebox-test--reset-runtime-state) - (let* ((width-only (ebox-create :content "Width" :width '(viewport))) - (height-only (ebox-create :content "Height" + (let* ((width-only (ebox-test-box :content "Width" :width '(viewport))) + (height-only (ebox-test-box :content "Height" :width '(120) :height '(viewport-height))) - (both (ebox-create :content "Both" + (both (ebox-test-box :content "Both" :width '(viewport) :height '(viewport-height))) - (layout (ebox-column width-only height-only both))) + (layout (ebox-test-column width-only height-only both))) (let ((ebox-viewport-width 360) (ebox-viewport-height 12) (ebox--viewport-dependent-node-ids-cache @@ -3960,7 +3960,7 @@ "Width-only viewport rerender should not dirty height-only roots." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-height 4)) - (ebox-build + (ebox-test-build '(column :width (120) :height (viewport-height) (box :content "Static" :width (120)))))) buffer) @@ -3982,14 +3982,14 @@ "Fixed width should retain vertical dependencies without exposing width-only ones." (ebox-test--reset-runtime-state) (let* ((width-only - (ebox-create :content "Width" :width '(viewport))) + (ebox-test-box :content "Width" :width '(viewport))) (ordinary-height-nodes (cl-loop for (property value) in '((:height (viewport-height)) (:min-height (- (viewport-height) 1)) (:max-height (+ (viewport-height) 1))) collect - (apply #'ebox-create + (apply #'ebox-test-box :content "Ordinary" (list property value)))) (flex-height-nodes @@ -3998,16 +3998,16 @@ (:min-height (- (viewport-height) 1)) (:max-height (+ (viewport-height) 1))) collect - (apply #'ebox-flex + (apply #'ebox-test-flex (append (list property value) - (list (ebox-create :content "Flex")))))) + (list (ebox-test-box :content "Flex")))))) (layout - (apply #'ebox-column + (apply #'ebox-test-column (append (list width-only) ordinary-height-nodes flex-height-nodes))) - (node (ebox-create :width '(210) + (node (ebox-test-box :width '(210) :wrap-mode nil :ebox-content-node layout)) (ids (let ((ebox-viewport-width 360) @@ -4021,11 +4021,11 @@ (ert-deftest ebox-viewport-dependencies-include-direct-flex-basis () "Viewport dependency discovery should include direct child flex-basis props." (ebox-test--reset-runtime-state) - (let* ((child (ebox-create :content "A" :width 80 + (let* ((child (ebox-test-box :content "A" :width 80 :flex-basis '(viewport))) - (node (ebox-flex :width 300 + (node (ebox-test-flex :width 300 child - (ebox-create :content "B" :width 80))) + (ebox-test-box :content "B" :width 80))) (ids (let ((ebox-viewport-width 360)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) @@ -4034,9 +4034,9 @@ (ert-deftest ebox-viewport-dependencies-ignore-flex-item-wrapper-auto-width () "Flex item wrappers should not be dirty only because their own width is auto." (ebox-test--reset-runtime-state) - (let* ((child (ebox-create :content "A" :width 80)) - (item (ebox-flex-item child)) - (node (ebox-flex :width 240 item)) + (let* ((child (ebox-test-box :content "A" :width 80)) + (item (ebox-test-flex-item child)) + (node (ebox-test-flex :width 240 item)) (ids (let ((ebox-viewport-width 360)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) @@ -4046,9 +4046,9 @@ (ert-deftest ebox-viewport-dependencies-include-flex-item-viewport-basis () "Flex item wrappers should stay dirty when their flex-basis uses viewport." (ebox-test--reset-runtime-state) - (let* ((child (ebox-create :content "A" :width 80)) - (item (ebox-flex-item child :flex-basis '(viewport))) - (node (ebox-flex :width 240 item)) + (let* ((child (ebox-test-box :content "A" :width 80)) + (item (ebox-test-flex-item child :flex-basis '(viewport))) + (node (ebox-test-flex :width 240 item)) (ids (let ((ebox-viewport-width 360)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) @@ -4057,7 +4057,7 @@ (ert-deftest ebox-buffer-viewport-dependency-cache-recomputes-after-style-change () "Changing responsive style should publish its exact viewport dependency." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Resizable" :width 80)) + (let* ((box (ebox-test-box :content "Resizable" :width 80)) (box-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-rerender-buffer-with-context (current-buffer) 320) @@ -4080,25 +4080,25 @@ (let* ((inner-flexes (cl-loop for index below 6 collect - (ebox-flex + (ebox-test-flex :width 180 :height 3 :flex-flow '(row wrap) :column-gap '(8) - (ebox-create + (ebox-test-box :content (format "A%d" index) :width 48 :height 1) - (ebox-create + (ebox-test-box :content (format "B%d" index) :width 48 :height 1)))) (layout (apply - #'ebox-flex + #'ebox-test-flex :flex-flow '(row wrap) :row-gap 1 :column-gap '(8) (mapcar (lambda (inner) - (ebox-column - (ebox-create :content "Static card" + (ebox-test-column + (ebox-test-box :content "Static card" :width 180 :height 1) inner)) @@ -4127,7 +4127,7 @@ "Viewport resize should not recapture every lightweight snapshot eagerly." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(column :width (viewport) ,@(cl-loop for idx below 24 collect @@ -4158,7 +4158,7 @@ "Viewport resize should republish region ranges through TP mounts." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(column :width (viewport) ,@(cl-loop for idx below 24 collect @@ -4192,11 +4192,11 @@ "Viewport dependency discovery should not recurse twice for the same node." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-flex + (ebox-test-flex :width 240 - (ebox-column - (ebox-create :content "A" :width 80) - (ebox-create :content "B" :width 80)))) + (ebox-test-column + (ebox-test-box :content "A" :width 80) + (ebox-test-box :content "B" :width 80)))) (node-count (length (ebox--runtime-node-ids layout))) (width-check-count 0) (height-check-count 0) @@ -4225,11 +4225,11 @@ "Render cache signatures should not recurse twice for the same node." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-flex + (ebox-test-flex :width 240 - (ebox-column - (ebox-create :content "A" :width 80) - (ebox-create :content "B" :width 80)))) + (ebox-test-column + (ebox-test-box :content "A" :width 80) + (ebox-test-box :content "B" :width 80)))) (node-count (length (ebox--runtime-node-ids layout))) (signature-count 0) (original (symbol-function 'ebox--render-cache-plist-signature)) @@ -4254,17 +4254,27 @@ (ert-deftest ebox-render-cache-signature-includes-inline-auto-width-context () "Row intrinsic sizing must not reuse a cross-axis stretch render." (ebox-test--reset-runtime-state) - (let ((node (ebox-create :content "context-sensitive"))) + (let ((node (ebox-test-box :content "context-sensitive"))) (let ((normal (let ((ebox--inline-auto-width-intrinsic-p nil)) (ebox--render-cache-node-signature node))) (intrinsic (let ((ebox--inline-auto-width-intrinsic-p t)) (ebox--render-cache-node-signature node)))) (should-not (equal normal intrinsic))))) +(ert-deftest ebox-render-cache-signature-includes-intrinsic-layout-context () + "Final layout must not reuse an intrinsic measurement render." + (ebox-test--reset-runtime-state) + (let ((node (ebox-test-box :content "context-sensitive" :width '(80)))) + (let ((final (let ((ebox--intrinsic-layout-measurement nil)) + (ebox--render-cache-node-signature node))) + (intrinsic (let ((ebox--intrinsic-layout-measurement t)) + (ebox--render-cache-node-signature node)))) + (should-not (equal final intrinsic))))) + (ert-deftest ebox-render-cache-signature-includes-rendered-region-identity () "Cached render strings should never cross distinct region identities." (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "Stable" :width 80)) + (let* ((left (ebox-test-box :content "Stable" :width 80)) (right (copy-tree left))) (plist-put left :node-id 1) (plist-put right :node-id 1) @@ -4277,7 +4287,7 @@ (ert-deftest ebox-render-cache-probe-reuses-first-render-output () "The first cache probe should retain the identity used by its output." (ebox-test--reset-runtime-state) - (let* ((node (ebox-create :content "Stable" :width 80)) + (let* ((node (ebox-test-box :content "Stable" :width 80)) (ebox--surface-materialization-active t) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) @@ -4296,7 +4306,7 @@ (ert-deftest ebox-render-transaction-snapshots-display-signature () "One render transaction should sample its display signature once." (ebox-test--reset-runtime-state) - (let ((node (ebox-create :content "A" :width 80)) + (let ((node (ebox-test-box :content "A" :width 80)) (calls 0)) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () @@ -4309,7 +4319,7 @@ (ert-deftest ebox-render-transaction-nesting-reuses-display-signature () "A nested render transaction should reuse the outer display snapshot." (ebox-test--reset-runtime-state) - (let ((node (ebox-create :content "A" :width 80)) + (let ((node (ebox-test-box :content "A" :width 80)) (calls 0)) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () @@ -4322,7 +4332,7 @@ (ert-deftest ebox-render-transactions-refresh-display-signature () "Separate render transactions should sample display state independently." (ebox-test--reset-runtime-state) - (let ((node (ebox-create :content "A" :width 80)) + (let ((node (ebox-test-box :content "A" :width 80)) (calls 0) signatures) (cl-letf (((symbol-function 'ebox--display-signature) @@ -4385,7 +4395,7 @@ (ert-deftest ebox-render-cache-signature-keeps-viewport-axes-with-snapshot () "Display snapshots should not collapse width or height cache dimensions." (ebox-test--reset-runtime-state) - (let ((node (ebox-create :content "A" + (let ((node (ebox-test-box :content "A" :width '(viewport) :height '(viewport-height))) signatures) @@ -4406,9 +4416,9 @@ (ert-deftest ebox-render-cache-signature-ignores-unrelated-viewport-height () "Width-only subtree signatures should survive unrelated height changes." (ebox-test--reset-runtime-state) - (let* ((node (ebox-column - (ebox-create :content "A" :width '(viewport)) - (ebox-create :content "B" :width 80))) + (let* ((node (ebox-test-column + (ebox-test-box :content "A" :width '(viewport)) + (ebox-test-box :content "B" :width 80))) (left (let ((ebox-viewport-width 320) (ebox-viewport-height 10)) (ebox--render-cache-node-signature node))) @@ -4421,11 +4431,11 @@ "Unchanged runtime body signatures should survive buffer render transactions." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-flex + (ebox-test-flex :width 240 - (ebox-column - (ebox-create :content "A" :width 80) - (ebox-create :content "B" :width 80)))) + (ebox-test-column + (ebox-test-box :content "A" :width 80) + (ebox-test-box :content "B" :width 80)))) (node-count (length (ebox--runtime-node-ids layout))) (signature-count 0) (original (symbol-function 'ebox--render-cache-plist-signature)) @@ -4455,9 +4465,9 @@ (ert-deftest ebox-buffer-render-signature-invalidates-mutated-path () "Runtime mutation should not reuse a stale ancestor body signature." (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "Before" :width 80)) - (right (ebox-create :content "Stable" :width 80)) - (layout (ebox-column left right)) + (let* ((left (ebox-test-box :content "Before" :width 80)) + (right (ebox-test-box :content "Stable" :width 80)) + (layout (ebox-test-column left right)) (left-region-id (car (ebox-region-ids left))) old-root-signature new-root-signature @@ -4491,9 +4501,9 @@ (ert-deftest ebox-render-cache-signature-ignores-viewport-for-static-column () "Static column signatures should not churn only because outer viewport changed." (ebox-test--reset-runtime-state) - (let ((node (ebox-column - (ebox-create :content "A" :width 80) - (ebox-create :content "B" :width 80)))) + (let ((node (ebox-test-column + (ebox-test-box :content "A" :width 80) + (ebox-test-box :content "B" :width 80)))) (ebox--runtime-node-ids node) (let ((left (let ((ebox-viewport-width 320)) (ebox--render-cache-node-signature node))) @@ -4504,7 +4514,7 @@ (ert-deftest ebox-render-cache-plist-signature-avoids-append-churn () "Render cache plist signatures should be built linearly." (ebox-test--reset-runtime-state) - (let ((node (ebox-create :content "Signature" :width 80 :border t)) + (let ((node (ebox-test-box :content "Signature" :width 80 :border t)) (original-append (symbol-function 'append)) (append-calls 0)) (cl-letf (((symbol-function 'append) @@ -4520,7 +4530,7 @@ (let* ((content (ebox-lines-join (list (ebox-pixel-space 80) (ebox-pixel-space 80)))) - (box (ebox-create :content content :width 80 :wrap-mode nil)) + (box (ebox-test-box :content content :width 80 :wrap-mode nil)) (measure-count 0) (original (symbol-function 'ebox--string-pixel-width))) (plist-put box :ebox-content-width-exact-p t) @@ -4534,10 +4544,10 @@ (ert-deftest ebox-uniform-stack-content-skips-parent-justify-measurement () "A target-width stack should not be measured again by its parent box." (ebox-test--reset-runtime-state) - (let* ((child (ebox-column - (ebox-create :content "A" :width '(viewport)) - (ebox-create :content "B" :width '(viewport)))) - (parent (ebox-create :width '(viewport) :wrap-mode nil + (let* ((child (ebox-test-column + (ebox-test-box :content "A" :width '(viewport)) + (ebox-test-box :content "B" :width '(viewport)))) + (parent (ebox-test-box :width '(viewport) :wrap-mode nil :ebox-content-node child)) (justify-count 0) (original (symbol-function 'ebox--lines-justify))) @@ -4555,7 +4565,7 @@ "Exact uniform lines should bypass proven no-op word/char formatting." (ebox-test--reset-runtime-state) (dolist (wrap-mode '(word char)) - (let* ((box (ebox-create :content "" :width '(120) + (let* ((box (ebox-test-box :content "" :width '(120) :wrap-mode wrap-mode :text-align 'left)) (pixel-width (ebox--content-pixel box)) (line (propertize (ebox-pixel-space pixel-width) @@ -4584,7 +4594,7 @@ (dolist (case '((kp left 120) (word center 120) (char left 119))) - (let* ((box (ebox-create :content "" :width '(120) + (let* ((box (ebox-test-box :content "" :width '(120) :wrap-mode (nth 0 case) :text-align (nth 1 case))) (content (ebox-pixel-space 120)) @@ -4601,14 +4611,12 @@ (ert-deftest ebox-transparent-preformatted-box-fast-path-is-exact () "Transparent exact-width wrappers should match the generic box pipeline." (ebox-test--reset-runtime-state) - (let* ((child-a (ebox-create :content "A" :width '(viewport) :border t + (let* ((child-a (ebox-test-box :content "A" :width '(viewport) :border t :region-id 'child-a)) - (child-b (ebox-create :content "B" :width '(viewport) :border t + (child-b (ebox-test-box :content "B" :width '(viewport) :border t :region-id 'child-b)) - (parent (ebox-create :width '(viewport) :wrap-mode nil - :region-id 'parent - :ebox-content-node - (ebox-column child-a child-b))) + (parent (ebox-test-column + :width '(viewport) :region-id 'parent child-a child-b)) generic fast) (let ((ebox-viewport-width 120) (ebox--transparent-preformatted-box-fast-path-disabled t)) @@ -4622,7 +4630,7 @@ "One-pass exact wrappers should preserve generic chrome and ownership." (ebox-test--reset-runtime-state) (let* ((box - (ebox-create + (ebox-test-box :content "" :width '(140) :wrap-mode nil :region-id 'wrapper :padding-left-pixel 7 :padding-right-pixel 5 :padding-top-height 1 :padding-bottom-height 2 @@ -4656,7 +4664,7 @@ "One-pass formatted strings should preserve generic chrome and ownership." (ebox-test--reset-runtime-state) (let* ((box - (ebox-create + (ebox-test-box :content "Alpha beta gamma delta" :width '(88) :wrap-mode 'word :text-align 'center :region-id 'formatted-wrapper :padding-left-pixel 4 :padding-right-pixel 6 @@ -4682,9 +4690,9 @@ (ert-deftest ebox-flat-preformatted-string-proves-width-before-reuse () "Formatted string reuse must not publish an unproven uniform width." (ebox-test--reset-runtime-state) - (let* ((child (ebox-create :content "This content must wrap" :width '(5) + (let* ((child (ebox-test-box :content "This content must wrap" :width '(5) :wrap-mode nil :region-id 'overflowing-child)) - (parent (ebox-create :width '(5) :wrap-mode 'word + (parent (ebox-test-box :width '(5) :wrap-mode 'word :region-id 'wrapping-parent :ebox-content-node child)) generic fast) @@ -4700,7 +4708,7 @@ (ert-deftest ebox-flat-preformatted-fast-path-keeps-intrinsic-scroll-state () "Intrinsic flat rendering must not clear the published scroll state." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "" :width '(40) :wrap-mode nil + (let* ((box (ebox-test-box :content "" :width '(40) :wrap-mode nil :region-id 'intrinsic-wrapper)) (content (ebox-pixel-space 40)) (state (list :scroll-offset 0 :sentinel t)) @@ -4726,7 +4734,7 @@ (ebox-pixel-space 60) 'ebox-content 'right 'ebox-content-idx 0 'ebox-content-owner 'right 'ebox-content-owners '(right))) - (box (ebox-create :content "" :width 100 :wrap-mode nil + (box (ebox-test-box :content "" :width 100 :wrap-mode nil :color "#112233" :bgcolor "#DDEEFF")) (rendered (car @@ -4753,9 +4761,9 @@ (ert-deftest ebox-transparent-preformatted-fast-path-keeps-intrinsic-scroll-state () "Intrinsic transparent rendering must not clear the published scroll state." (ebox-test--reset-runtime-state) - (let* ((child (ebox-create :content "" :width '(40) :wrap-mode nil + (let* ((child (ebox-test-box :content "" :width '(40) :wrap-mode nil :region-id 'transparent-child)) - (parent (ebox-create :content "" :width '(40) :wrap-mode nil + (parent (ebox-test-box :content "" :width '(40) :wrap-mode nil :region-id 'transparent-parent :ebox-content-node child)) (content (ebox-pixel-space 40)) @@ -4775,7 +4783,7 @@ "Exact fixed-height hidden wrappers should preserve generic output." (ebox-test--reset-runtime-state) (let* ((box - (ebox-create + (ebox-test-box :content "" :width '(140) :height 6 :box-sizing 'border-box :overflow 'hidden :wrap-mode nil :region-id 'fixed-wrapper :padding-left-pixel 7 :padding-right-pixel 5 @@ -4816,7 +4824,7 @@ ("one\ntwo\nthree" scroll top) ("one\ntwo\nthree" hidden center))) (let* ((box - (ebox-create + (ebox-test-box :content (nth 0 case) :width '(120) :height 5 :box-sizing 'border-box :overflow (nth 1 case) :vertical-align (nth 2 case) :wrap-mode nil)) @@ -4835,10 +4843,10 @@ (ert-deftest ebox-overflowing-stack-content-still-uses-parent-formatting () "An overflowing child stack must not publish uniform parent-width metadata." (ebox-test--reset-runtime-state) - (let* ((child (ebox-column - (ebox-create :content "A" :width 140) - (ebox-create :content "B" :width 140))) - (parent (ebox-create :width 120 :wrap-mode nil + (let* ((child (ebox-test-column + (ebox-test-box :content "A" :width 140) + (ebox-test-box :content "B" :width 140))) + (parent (ebox-test-box :width 120 :wrap-mode nil :ebox-content-node child)) (justify-count 0) (original (symbol-function 'ebox--lines-justify)) @@ -4856,18 +4864,18 @@ "A wrapper should mark child output exact only at its proven viewport width." (ebox-test--reset-runtime-state) (let* ((overflow-child - (ebox-column - (ebox-create :content "A" :width 140) - (ebox-create :content "B" :width 140))) + (ebox-test-column + (ebox-test-box :content "A" :width 140) + (ebox-test-box :content "B" :width 140))) (overflow-parent - (ebox-create :width 120 :wrap-mode nil + (ebox-test-box :width 120 :wrap-mode nil :ebox-content-node overflow-child)) (exact-child - (ebox-column - (ebox-create :content "A" :width 120) - (ebox-create :content "B" :width 120))) + (ebox-test-column + (ebox-test-box :content "A" :width 120) + (ebox-test-box :content "B" :width 120))) (exact-parent - (ebox-create :width 120 :wrap-mode nil + (ebox-test-box :width 120 :wrap-mode nil :ebox-content-node exact-child)) (ebox-viewport-width 120) (ebox--box-content-render-cache (make-hash-table :test 'eq)) @@ -4903,10 +4911,10 @@ (ert-deftest ebox-render-caches-lazy-box-content-within-render-pass () "A lazy child layout should be rendered once per box/context in one pass." (ebox-test--reset-runtime-state) - (let* ((child (ebox-column - (ebox-create :content "A" :width 80) - (ebox-create :content "B" :width 80))) - (parent (ebox-create :width 'max-content :padding 2)) + (let* ((child (ebox-test-column + (ebox-test-box :content "A" :width 80) + (ebox-test-box :content "B" :width 80))) + (parent (ebox-test-box :width 'max-content :padding 2)) (render-count 0) (original (symbol-function 'ebox--render-node-as-box-content))) (plist-put parent :ebox-content-node child) @@ -4920,7 +4928,7 @@ (ert-deftest ebox-buffer-update-report-includes-dirty-and-patch-summary () "Update reports should show dirty and patch-set summaries." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Report" :width 80 + (let* ((box (ebox-test-box :content "Report" :width 80 :border t :border-color "red")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -4936,8 +4944,8 @@ (ert-deftest ebox-buffer-update-report-is-buffer-local () "Updating one rendered buffer should not replace another buffer's report." (ebox-test--reset-runtime-state) - (let* ((first-box (ebox-create :content "First" :width '(80))) - (second-box (ebox-create :content "Second" :width '(80))) + (let* ((first-box (ebox-test-box :content "First" :width '(80))) + (second-box (ebox-test-box :content "Second" :width '(80))) (first-id (car (ebox-region-ids first-box))) first-buffer second-buffer) (unwind-protect @@ -4969,7 +4977,7 @@ (ert-deftest ebox-buffer-update-report-returns-defensive-copy () "The public accessor should not return mutable render-state storage." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Copy" :width '(80))) + (let* ((box (ebox-test-box :content "Copy" :width '(80))) buffer) (unwind-protect (progn @@ -5006,7 +5014,7 @@ (ert-deftest ebox-region-update-return-matches-buffer-update-report () "A direct region update should return the report stored on its buffer." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Return" :width '(80) + (let* ((box (ebox-test-box :content "Return" :width '(80) :color "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -5017,7 +5025,7 @@ (ert-deftest ebox-region-update-no-op-replaces-stale-buffer-report () "A direct unchanged update should publish its own no-op report." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "No-op" :width '(80) + (let* ((box (ebox-test-box :content "No-op" :width '(80) :color "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -5034,7 +5042,7 @@ (ert-deftest ebox-batched-region-update-keeps-prior-report-until-flush () "Batched region updates should return nil and publish only on flush." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Batch" :width '(80) + (let* ((box (ebox-test-box :content "Batch" :width '(80) :color "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -5054,9 +5062,9 @@ (ert-deftest ebox-batch-flush-stores-report-on-flushed-buffer-only () "Empty and non-empty flushes should update only the target buffer report." (ebox-test--reset-runtime-state) - (let* ((first-box (ebox-create :content "First" :width '(80) + (let* ((first-box (ebox-test-box :content "First" :width '(80) :color "#111111")) - (second-box (ebox-create :content "Second" :width '(80) + (second-box (ebox-test-box :content "Second" :width '(80) :color "#111111")) (second-id (car (ebox-region-ids second-box))) first-buffer second-buffer) @@ -5104,7 +5112,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") - (ebox-create :content "Hello" :width 40))) + (ebox-test-box :content "Hello" :width 40))) (should (eq (current-buffer) origin)) (should (buffer-live-p buffer)) (with-current-buffer buffer @@ -5123,7 +5131,7 @@ (with-current-buffer buffer (use-local-map parent-map) (ebox-render-to-buffer buffer-name - (ebox-create :content "Hello" :width 40)) + (ebox-test-box :content "Hello" :width 40)) (should (eq (current-local-map) parent-map)) (should (eq (lookup-key (current-local-map) (kbd "x")) #'ignore))) (when (buffer-live-p buffer) @@ -5134,7 +5142,7 @@ (ert-deftest ebox-buffer-kill-clears-region-runtime-caches () "Killing an ebox buffer should clear region-scoped runtime cache entries." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A\nB\nC" :width 80 :height 1 + (let* ((box (ebox-test-box :content "A\nB\nC" :width 80 :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box))) (buffer (ebox-render-to-buffer @@ -5149,7 +5157,7 @@ (ert-deftest ebox-buffer-kill-discards-buffer-owned-runtime-state () "Killing an Ebox buffer should discard every buffer-keyed runtime entry." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Lifecycle" :color "#111111")) + (let* ((box (ebox-test-box :content "Lifecycle" :color "#111111")) (region-id (car (ebox-region-ids box))) buffer surface handle) (unwind-protect @@ -5191,9 +5199,9 @@ (ert-deftest ebox-concat-content-update-preserves-neighbor-box () (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "A\nB" :width 60)) - (right (ebox-create :content "R1\nR2\nR3" :width 80)) - (layout (ebox-concat left right)) + (let* ((left (ebox-test-box :content "A\nB" :width 60)) + (right (ebox-test-box :content "R1\nR2\nR3" :width 80)) + (layout (ebox-test-concat left right)) (ids (ebox-region-ids layout)) (left-id (nth 0 ids)) (right-id (nth 1 ids))) @@ -5208,10 +5216,10 @@ (ert-deftest ebox-geometry-update-rerenders-root-once-safely () (ebox-test--reset-runtime-state) - (let* ((top (ebox-create :content "Top" :width 60)) - (left (ebox-create :content "L" :width 40)) - (right (ebox-create :content "R" :width 40)) - (layout (ebox-stack top (ebox-concat left right))) + (let* ((top (ebox-test-box :content "Top" :width 60)) + (left (ebox-test-box :content "L" :width 40)) + (right (ebox-test-box :content "R" :width 40)) + (layout (ebox-test-stack top (ebox-test-concat left right))) (ids (ebox-region-ids layout)) (left-id (nth 1 ids))) (ebox-test--with-rendered-buffer layout @@ -5225,11 +5233,11 @@ (ert-deftest ebox-border-color-update-patches-without-full-root-rerender () "A pure border color change should patch owned spans, not rebuild the root." (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "L" :width 40 + (let* ((left (ebox-test-box :content "L" :width 40 :border t :border-color "red")) - (right (ebox-create :content "R" :width 40 + (right (ebox-test-box :content "R" :width 40 :border t :border-color "green")) - (layout (ebox-concat left right)) + (layout (ebox-test-concat left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((full-rerenders @@ -5246,7 +5254,7 @@ (ert-deftest ebox-flex-row-border-color-update-patches-region-role () "Paint-only updates inside flex rows should not fall back to root rerender." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(flex :flex-flow (row wrap) :column-gap (12) :padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2" (column @@ -5285,7 +5293,7 @@ (ert-deftest ebox-paint-update-synthesizes-missing-horizontal-border-surface () "Adding a missing top/bottom paint surface should patch from region anchors." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(flex :flex-flow (row wrap) :column-gap (12) :padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2" (column @@ -5329,7 +5337,7 @@ (ert-deftest ebox-paint-projection-keeps-old-fragment-metadata-immutable () "Paint projection should not mutate the previous state's fragment metadata." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(flex :flex-flow (row wrap) :column-gap (12) :padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2" (column @@ -5373,7 +5381,7 @@ (let* ((property (nth 0 case)) (removed-role (nth 1 case)) (kept-roles (nth 2 case)) - (layout (ebox-create :content "Border" + (layout (ebox-test-box :content "Border" :width 80 :padding '(0 (8)) :border "#D97757")) @@ -5402,7 +5410,7 @@ "Side color patches should match default and explicit full-render paint." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-create + (ebox-test-box :content "Border" :width 80 :border-left-pixel 2 :border-left-color "#AA0000" :border-right-pixel 2 :border-right-color "#AA0000")) @@ -5435,8 +5443,8 @@ (ert-deftest ebox-region-update-reuses-buffer-render-viewport-context () "Incremental rerender should use the viewport from the original buffer render." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-flex :padding '(0 4) :border "#64748B" - (ebox-create :content "Flex Child" :width '(120) + (let* ((layout (ebox-test-flex :padding '(0 4) :border "#64748B" + (ebox-test-box :content "Flex Child" :width '(120) :border t))) (region-id (car (ebox--node-all-region-ids layout))) buffer) @@ -5467,7 +5475,7 @@ "Runtime rerender should resolve viewport-sized boxes with the new context." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-width 240)) - (ebox-build + (ebox-test-build '(box :content "Viewport" :width (viewport) :border t)))) buffer) (unwind-protect @@ -5494,7 +5502,7 @@ "Runtime rerender should resolve viewport-height boxes with the new context." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-height 2)) - (ebox-build + (ebox-test-build '(box :content "One\nTwo\nThree\nFour" :width (120) :height (viewport-height) @@ -5525,9 +5533,9 @@ (ert-deftest ebox-stable-content-update-patches-without-full-root-rerender () "A content update with unchanged rendered geometry should stay local." (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "Alpha" :width 80 :height 1)) - (right (ebox-create :content "Right" :width 80 :height 1)) - (layout (ebox-concat left right)) + (let* ((left (ebox-test-box :content "Alpha" :width 80 :height 1)) + (right (ebox-test-box :content "Right" :width 80 :height 1)) + (layout (ebox-test-concat left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((full-rerenders @@ -5543,7 +5551,7 @@ "Stable content updates inside nested layouts should not patch the root." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(flex :flex-flow (row wrap) :column-gap (12) :padding (0 (12)) :border "#999999" (column :width (210) :border "#999999" @@ -5578,7 +5586,7 @@ "Stable flex-contained geometry changes should stop before root." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(column :id root :width (520) :padding (1 (18)) (flex :id matrix :flex-flow (row wrap) :column-gap (12) :row-gap 1 :padding (0 (12)) @@ -5607,11 +5615,11 @@ (defun ebox-test--nested-width-containment-layout (&optional height) "Return a nested flex width-update fixture with optional fixed HEIGHT." (let* ((target - (ebox-create :content "width target\nready to grow" + (ebox-test-box :content "width target\nready to grow" :width '(190) :height 3 :padding '(0 (10)) :border "#999999")) (owner - (apply #'ebox-flex + (apply #'ebox-test-flex (append (list :width '(300) :padding '(0 (10)) :border "#999999" :justify-content 'center @@ -5619,19 +5627,19 @@ (when height (list :height height)) (list target)))) (card - (ebox-column - (ebox-create :content "Width" :width '(300) :height 2) + (ebox-test-column + (ebox-test-box :content "Width" :width '(300) :height 2) owner - (ebox-create :content "The target stays in this card" + (ebox-test-box :content "The target stays in this card" :width '(300) :height 3) - (ebox-create :content "Metric" :width '(300) :height 4))) + (ebox-test-box :content "Metric" :width '(300) :height 4))) (peer - (ebox-column - (ebox-create :content "Peer" :width '(300) :height 2) - (ebox-create :content "Stable peer" :width '(300) :height 5) - (ebox-create :content "Footer" :width '(300) :height 7))) + (ebox-test-column + (ebox-test-box :content "Peer" :width '(300) :height 2) + (ebox-test-box :content "Stable peer" :width '(300) :height 5) + (ebox-test-box :content "Footer" :width '(300) :height 7))) (layout - (ebox-flex :width '(720) :flex-flow '(row wrap) + (ebox-test-flex :width '(720) :flex-flow '(row wrap) :column-gap '(12) card peer))) (list :layout layout :target target :owner owner))) @@ -5658,32 +5666,32 @@ (ert-deftest ebox-contained-partial-flex-content-change-preserves-fixed-slot () "A fixed partial flex should update without disturbing adjacent siblings." (ebox-test--reset-runtime-state) - (let* ((mark (ebox-create :key 'mark :host-ref 'mark + (let* ((mark (ebox-test-box :key 'mark :host-ref 'mark :content "○ OFF" :width 'max-content :overflow 'hidden)) - (label (ebox-create :key 'label :content " Power off" + (label (ebox-test-box :key 'label :content " Power off" :host-ref 'label :width 'max-content :overflow 'hidden)) - (switch (ebox-create :key 'switch :host-ref 'switch + (switch (ebox-test-box :key 'switch :host-ref 'switch :width 'max-content :wrap-mode nil :overflow 'hidden - :ebox-content-node (ebox-row mark label))) - (owner (ebox-flex :width '(496) :min-width '(496) + :ebox-content-node (ebox-test-row mark label))) + (owner (ebox-test-flex :width '(496) :min-width '(496) :max-width '(496) :height 1 :min-height 1 :max-height 1 :overflow 'hidden :bgcolor "#F7F8FA" :wrap-mode nil :flex-flow '(column nowrap) switch)) - (caption (ebox-create :content "switch" :width '(170) + (caption (ebox-test-box :content "switch" :width '(170) :min-width '(170) :max-width '(170) :height 1 :min-height 1 :max-height 1)) - (outer (ebox-flex :width '(680) :min-width '(680) + (outer (ebox-test-flex :width '(680) :min-width '(680) :max-width '(680) :height 1 :min-height 1 :max-height 1 :overflow 'hidden :bgcolor "#F7F8FA" :wrap-mode nil :column-gap '(14) :flex-flow '(row nowrap) caption owner)) - (footer (ebox-create :content "FOOTER" :width '(680) :height 1)) - (layout (ebox-column outer footer)) + (footer (ebox-test-box :content "FOOTER" :width '(680) :height 1)) + (layout (ebox-test-column outer footer)) (mark-node-id (ebox--ensure-node-id mark)) (label-node-id (ebox--ensure-node-id label)) (owner-node-id (ebox--ensure-node-id owner))) @@ -5697,12 +5705,12 @@ (let ((candidate (ebox-candidate-begin (current-buffer)))) (ebox-candidate-replace candidate mark-node-id - (ebox-create :key 'mark :host-ref 'mark + (ebox-test-box :key 'mark :host-ref 'mark :content " ON ● " :width 'max-content :overflow 'hidden)) (ebox-candidate-replace candidate label-node-id - (ebox-create :key 'label :host-ref 'label + (ebox-test-box :key 'label :host-ref 'label :content " Much longer power on state" :width 'max-content :overflow 'hidden)) @@ -5722,18 +5730,18 @@ (ert-deftest ebox-contained-partial-flex-structure-change-preserves-fixed-slot () "A fixed partial flex should own bounded descendant structure changes." (ebox-test--reset-runtime-state) - (let* ((input (ebox-create :key 'input :content "query" + (let* ((input (ebox-test-box :key 'input :content "query" :overflow 'hidden)) (content - (ebox-create :key 'content :host-ref 'content + (ebox-test-box :key 'content :host-ref 'content :width 'max-content :overflow 'hidden - :ebox-content-node (ebox-column input))) - (owner (ebox-flex :width '(180) :height 2 :overflow 'hidden + :ebox-content-node (ebox-test-column input))) + (owner (ebox-test-flex :width '(180) :height 2 :overflow 'hidden :flex-flow '(column nowrap) content)) - (caption (ebox-create :content "search" :width '(80) :height 2)) - (outer (ebox-row caption owner)) - (footer (ebox-create :content "FOOTER" :width '(260) :height 1)) - (layout (ebox-column outer footer)) + (caption (ebox-test-box :content "search" :width '(80) :height 2)) + (outer (ebox-test-row caption owner)) + (footer (ebox-test-box :content "FOOTER" :width '(260) :height 1)) + (layout (ebox-test-column outer footer)) (content-node-id (ebox--ensure-node-id content)) (owner-node-id (ebox--ensure-node-id owner))) (plist-put owner :key 'owner) @@ -5742,13 +5750,13 @@ (let ((candidate (ebox-candidate-begin (current-buffer)))) (ebox-candidate-replace candidate content-node-id - (ebox-create + (ebox-test-box :key 'content :host-ref 'content :width 'max-content :overflow 'hidden :ebox-content-node - (ebox-column - (ebox-create :key 'input :content "query" :overflow 'hidden) - (ebox-create :key 'option :content "Apple" :overflow 'hidden)))) + (ebox-test-column + (ebox-test-box :key 'input :content "query" :overflow 'hidden) + (ebox-test-box :key 'option :content "Apple" :overflow 'hidden)))) (ebox-commit (current-buffer) candidate)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) @@ -5828,14 +5836,14 @@ "A visible-overflow flex wrapper must not contain child width changes." (ebox-test--reset-runtime-state) (let* ((target - (ebox-create :content "child" :width '(190) + (ebox-test-box :content "child" :width '(190) :height 5 :min-height 5)) (owner - (ebox-flex :width '(300) :height 2 :overflow 'visible + (ebox-test-flex :width '(300) :height 2 :overflow 'visible :align-items 'flex-start target)) (layout - (ebox-stack owner - (ebox-create :content "footer" :width '(300) + (ebox-test-stack owner + (ebox-test-box :content "footer" :width '(300) :height 2))) (owner-node-id (ebox--ensure-node-id owner))) (ebox-test--with-rendered-buffer layout @@ -5886,7 +5894,7 @@ "Flex main footprint changes should not render a large flex owner twice." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(column :id root :width (520) :padding (1 (18)) (flex :id matrix :flex-flow (row wrap) :column-gap (12) :row-gap 1 :padding (0 (12)) @@ -5919,15 +5927,15 @@ nil (or width '(2600)))) (target - (ebox-create :id "target" :content "width target\nmoves sibling layout" + (ebox-test-box :id "target" :content "width target\nmoves sibling layout" :width '(160) :height 3 :padding '(0 (8)) :border "#999999")) (first - (ebox-column - (ebox-create :content "Title" :width '(190)) + (ebox-test-column + (ebox-test-box :content "Title" :width '(190)) target - (ebox-create :content "Metric" :width '(190) :height 4)))) - (apply #'ebox-flex + (ebox-test-box :content "Metric" :width '(190) :height 4)))) + (apply #'ebox-test-flex (append (list :flex-flow '(row wrap) :column-gap '(12) :row-gap 1 @@ -5938,15 +5946,15 @@ (list first) (cl-loop for index from 1 below count collect - (ebox-column - (ebox-create + (ebox-test-column + (ebox-test-box :content (format "Title %02d" index) :width '(190)) - (ebox-create :content "Peer\nsame row" + (ebox-test-box :content "Peer\nsame row" :width '(190) :height 3 :padding '(0 (8)) :border "#999999") - (ebox-create :content "Metric" + (ebox-test-box :content "Metric" :width '(190) :height 4))))))) (ert-deftest ebox-flex-owner-update-reports-scoped-tp-publication () @@ -6012,7 +6020,7 @@ (defun ebox-test--dynamic-update-row-layout () "Return a small flex-row layout shaped like the dynamic update matrix." - (ebox-build + (ebox-test-build '(column :id root :width (1200) :padding (1 (18)) (flex :id matrix :flex-flow (row wrap) :column-gap (12) :row-gap 1 :padding (0 (12)) @@ -6049,7 +6057,7 @@ (defun ebox-test--dynamic-update-tight-row-layout () "Return a flex-row layout whose target growth exceeds its old item slot." - (ebox-build + (ebox-test-build '(column :id root :width (720) (box :content "Header" :width (720)) (column :id panel :width (720) :border "#999999" @@ -6138,7 +6146,7 @@ "TP scroll publication must preserve per-character display properties." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id scroll :content "第 1 行\n第 2 行\n第 3 行\n第 4 行" :width (230) :height 2 :padding (0 (10)) @@ -6166,7 +6174,7 @@ "Scroll marker patches should not shift later buffer positions." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id scroll :content "x\nsubstantially longer visible line" :width (180) :height 1 :overflow scroll @@ -6192,7 +6200,7 @@ "Nested scroll updates should atomically survive outer cache replay." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id root :width (180) :height 2 :overflow scroll (column (box :id inner :width (180) :height 2 :overflow scroll @@ -6228,7 +6236,7 @@ "Child-layout scroll boxes should publish one retained viewport update." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(box :id "root" :height 2 :overflow scroll (column (box :id "a" :content "A" :height 1) @@ -6259,7 +6267,7 @@ "Fast scroll window patches should keep scroll box padding and border." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(box :id "root" :height 4 :box-sizing border-box :overflow scroll :padding-top 1 :padding-bottom 1 @@ -6367,7 +6375,7 @@ "Fixed border-box padding changes should remain an internal fragment impact." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(column :id root :width (480) (flex :id row :flex-flow (row nowrap) :column-gap (12) (box :id padding :content "padding target" @@ -6391,7 +6399,7 @@ "Mixed block/inline padding shorthand should not promote to a root rerender." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(column :id root :width (480) (flex :id row :flex-flow (row nowrap) :column-gap (12) (box :id padding :content "padding target" @@ -6429,7 +6437,7 @@ "Border-box padding cannot claim a fixed footprint without a content line." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id padding :content "padding target" :width (190) :height 4 :box-sizing border-box :padding 2 :border "#999999")))) @@ -6494,7 +6502,7 @@ "Visible cached content patches should preserve nested wrapper paint." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id "root" :height 1 :overflow scroll (column (box :id "wrapper" :height 1 :overflow scroll @@ -6628,7 +6636,7 @@ "Clipping a visible target should replace the ancestor-owned old suffix." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(column :id root :width (760) (flex :id matrix :width (600) :height 5 :flex-flow (row nowrap) @@ -6673,11 +6681,11 @@ (ert-deftest ebox-overflow-reversal-skips-layout-only-ancestor () "Old visible suffix coverage should skip ancestors without wrapper ownership." (ebox-test--reset-runtime-state) - (let* ((target (ebox-create :content "A\nB\nC" :height 1 + (let* ((target (ebox-test-box :content "A\nB\nC" :height 1 :overflow 'visible)) (region-id (car (ebox-region-ids target))) - (inner (ebox-stack target (ebox-create :content "TAIL"))) - (layout (ebox-stack inner (ebox-create :content "ROOT TAIL"))) + (inner (ebox-test-stack target (ebox-test-box :content "TAIL"))) + (layout (ebox-test-stack inner (ebox-test-box :content "ROOT TAIL"))) (inner-id (ebox--ensure-node-id inner)) (root-id (ebox--ensure-node-id layout))) (ebox-test--with-rendered-buffer layout @@ -6695,12 +6703,12 @@ (ert-deftest ebox-overflow-reversal-skips-visible-wrapper-ancestor () "A visible wrapper cannot cover its own old unowned overflow suffix." (ebox-test--reset-runtime-state) - (let* ((target (ebox-create :content "A\nB\nC" :height 1 + (let* ((target (ebox-test-box :content "A\nB\nC" :height 1 :overflow 'visible)) (region-id (car (ebox-region-ids target))) - (wrapper (ebox-create :ebox-content-node target :width '(100) + (wrapper (ebox-test-box :ebox-content-node target :width '(100) :height 1 :overflow 'visible)) - (layout (ebox-stack wrapper (ebox-create :content "TAIL"))) + (layout (ebox-test-stack wrapper (ebox-test-box :content "TAIL"))) (wrapper-id (ebox--ensure-node-id wrapper)) (root-id (ebox--ensure-node-id layout))) (ebox-test--with-rendered-buffer layout @@ -6719,7 +6727,7 @@ "Coverage should climb above an enclosing visible wrapper's old footprint." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(column :id root :width (100) (box :id outer :width (100) :height 2 :overflow visible (box :id inner :width (100) @@ -6757,7 +6765,7 @@ (let* ((old-overflow (car transition)) (new-overflow (cadr transition)) (layout - (ebox-build + (ebox-test-build `(flex :id root :width (200) (box :id target :content "A\nB\nC" :width (80) :height 1 :overflow ,old-overflow) @@ -6786,7 +6794,7 @@ (let* ((old-overflow (car transition)) (new-overflow (cadr transition)) (layout - (ebox-build + (ebox-test-build `(flex :id root :width (220) (box :id card :width (100) (box :id target :content "A\nB\nC" :width (80) :height 1 @@ -6817,7 +6825,7 @@ "A batch should carry pre-mutation overflow into coverage planning." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(column :id root :width (100) (box :id wrapper :width (100) (column @@ -6854,7 +6862,7 @@ "Multiple owner replacements normalize shifted spans before scoped refresh." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(column :id root :width (760) (flex :id top-matrix :width (600) :height 5 :flex-flow (row nowrap) @@ -6932,7 +6940,7 @@ "Text-changing sibling patches should not reuse translated snapshot spans." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(column :id root :width (120) (box :id a :content "A" :width (120) :height 1) (box :id b :content "B" :width (120) :height 1))))) @@ -6969,7 +6977,7 @@ "Direct flex item margin changes should patch the flex line owner." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(flex :id matrix :width (260) :flex-flow (row nowrap) :column-gap (0) (box :id target :content "A" :width (80) :height 1 @@ -6991,7 +6999,7 @@ "Failed promoted span patches must fall back to a layout-safe ancestor." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(flex :id matrix :width (680) :flex-flow (row wrap) :column-gap (12) :row-gap 1 (column :width (210) :border "#999999" @@ -7022,7 +7030,7 @@ (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 500) (layout - (ebox-build + (ebox-test-build '(flex :id root :width (500) :height 4 :flex-flow (row nowrap) :align-items flex-start (box :id fixed :content "peer" :width (320) :height 4 @@ -7059,7 +7067,7 @@ "A zero-minimum fixed-basis item may update inside its grown slot." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(flex :id root :width (900) :height 24 :flex-flow (row nowrap) :align-items stretch (box :id list :content "peer" :width stretch :min-width 0 @@ -7115,7 +7123,7 @@ (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 300) (layout - (ebox-build + (ebox-test-build '(flex :id root :width (300) :height 1 :flex-flow (row nowrap) :align-items flex-start (box :id target :content "A" :width auto :height 1 @@ -7147,7 +7155,7 @@ (ert-deftest ebox-buffer-update-report-describes-span-patch () "The update report should expose the actual minimal patch strategy." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Trace" :width 80 :height 1)) + (let* ((box (ebox-test-box :content "Trace" :width 80 :height 1)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-test--region-update region-id :content "Patch") @@ -7163,9 +7171,9 @@ (ert-deftest ebox-buffer-update-report-describes-owner-rerender-fallback () "The update report should expose geometry owner rerender fallback." (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "L" :width 40)) - (right (ebox-create :content "R" :width 40)) - (layout (ebox-concat left right)) + (let* ((left (ebox-test-box :content "L" :width 40)) + (right (ebox-test-box :content "R" :width 40)) + (layout (ebox-test-concat left right)) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-test--region-update region-id :width '(120)) @@ -7179,7 +7187,7 @@ (ert-deftest ebox-region-update-stable-geometry-uses-span-patch () "Geometry updates with stable span footprint should use the L2 planner path." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Align" :width '(120) :height 1 + (let* ((box (ebox-test-box :content "Align" :width '(120) :height 1 :text-align 'left)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -7194,9 +7202,9 @@ (ert-deftest ebox-region-update-parent-layout-change-keeps-owner-rerender () "Geometry updates that move siblings should not be forced into span-patch." (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "L" :width '(40))) - (right (ebox-create :content "R" :width '(40))) - (layout (ebox-concat left right)) + (let* ((left (ebox-test-box :content "L" :width '(40))) + (right (ebox-test-box :content "R" :width '(40))) + (layout (ebox-test-concat left right)) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-test--region-update region-id :width '(120)) @@ -7209,10 +7217,10 @@ (ert-deftest ebox-region-update-inline-child-stable-geometry-uses-span-patch () "Stable internal changes inside an inline child should not rerender siblings." (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "Left" :width '(80) :height 1 + (let* ((left (ebox-test-box :content "Left" :width '(80) :height 1 :text-align 'left)) - (right (ebox-create :content "Right" :width '(80) :height 1)) - (layout (ebox-concat left right)) + (right (ebox-test-box :content "Right" :width '(80) :height 1)) + (layout (ebox-test-concat left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-test--region-update left-id :text-align 'center) @@ -7230,7 +7238,7 @@ (ert-deftest ebox-region-update-border-box-inline-change-uses-span-patch () "Border-box left/right spacing can be internal when outer width is fixed." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Pad" :width '(120) :height 1 + (let* ((box (ebox-test-box :content "Pad" :width '(120) :height 1 :box-sizing 'border-box)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -7243,7 +7251,7 @@ (ert-deftest ebox-region-update-content-box-inline-change-rerenders-owner () "Content-box left/right spacing changes the parent-facing outer width." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Pad" :width '(120) :height 1 + (let* ((box (ebox-test-box :content "Pad" :width '(120) :height 1 :box-sizing 'content-box)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -7257,7 +7265,7 @@ "Changing a flex item's box model should rerun its parent's placement." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(column :id root :width (760) (flex :id outer :width (600) :height 5 :flex-flow (row nowrap) (box :id card :width (300) @@ -7294,7 +7302,7 @@ "Block-size updates should skip flex-slot proof below fixed containment." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(column :id root :width (760) (flex :id outer :width (600) :height 5 :flex-flow (row nowrap) (box :id card :width (300) @@ -7338,7 +7346,7 @@ (ert-deftest ebox-region-update-span-candidate-falls-back-on-wrap-change () "A wrap-changing update should publish a correct scoped TP plan." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "abcdefghijklmnopqrstuvwxyz" + (let* ((box (ebox-test-box :content "abcdefghijklmnopqrstuvwxyz" :width '(120) :box-sizing 'border-box :wrap-mode 'char)) @@ -7376,7 +7384,7 @@ (pcase-let ((`(,property ,value ,expected) case)) (ert-info ((format "property %S" property)) (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Paint" :width '(120) + (let* ((box (ebox-test-box :content "Paint" :width '(120) :height 1)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -7404,7 +7412,7 @@ (pcase-let ((`(,property ,value ,expected ,_strategy) case)) (ert-info ((format "property %S" property)) (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Alpha\nBeta\nGamma\nDelta" + (let* ((box (ebox-test-box :content "Alpha\nBeta\nGamma\nDelta" :width '(160) :height 2 :overflow 'scroll @@ -7434,7 +7442,7 @@ (ert-deftest ebox-bgcolor-update-mutates-box-and-keeps-content () (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Color" :width 60 :bgcolor "#111111")) + (let* ((box (ebox-test-box :content "Color" :width 60 :bgcolor "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-test--region-update region-id :bgcolor "#222222") @@ -7452,10 +7460,10 @@ "Background color spellings should share one engine longhand and paint path." (ebox-test--reset-runtime-state) (let* ((background-box - (ebox-create :content "Color" :width 60 + (ebox-test-box :content "Color" :width 60 :background-color "#111111")) (bgcolor-box - (ebox-create :content "Color" :width 60 :bgcolor "#111111")) + (ebox-test-box :content "Color" :width 60 :bgcolor "#111111")) (background-face (ebox-test--first-content-face (ebox-render background-box))) (bgcolor-face @@ -7488,7 +7496,7 @@ (ert-deftest ebox-vertical-align-middle-alias-matches-center () "Creation and updates should normalize middle to center before rendering." (ebox-test--reset-runtime-state) - (let ((middle (ebox-create :content "X" :height 3 + (let ((middle (ebox-test-box :content "X" :height 3 :vertical-align 'middle))) (should (equal (ebox--expand-property :vertical-align 'middle) '(:vertical-align center))) @@ -7498,7 +7506,7 @@ (string-match-p "X" (substring-no-properties line))) (ebox-string-lines (ebox-render middle))) 1)) - (let* ((updated (ebox-create :content "X" :height 3 + (let* ((updated (ebox-test-box :content "X" :height 3 :vertical-align 'top)) (region-id (car (ebox-region-ids updated)))) (ebox-test--with-rendered-buffer updated @@ -7521,7 +7529,7 @@ (ert-deftest ebox-update-reuses-shorthand-property-expansion () (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Box" :width 60)) + (let* ((box (ebox-test-box :content "Box" :width 60)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-test--region-update region-id @@ -7544,7 +7552,7 @@ (ert-deftest ebox-region-update-adds-absent-shorthand-properties-incrementally () "Shorthand updates should locate the same box and patch without root rebuilds." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Absent properties" :width '(160))) + (let* ((box (ebox-test-box :content "Absent properties" :width '(160))) (region-id (car (ebox-region-ids box))) (ids-before (ebox-region-ids box))) (ebox-test--with-rendered-buffer box @@ -7599,7 +7607,7 @@ (ert-deftest ebox-color-and-visibility-are-real-and-display-is-ignored () (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Paint" + (let* ((box (ebox-test-box :content "Paint" :color "#eeeeee" :display 'none :visibility 'hidden)) @@ -7636,12 +7644,12 @@ (ert-deftest ebox-row-column-and-spacer-compose-layouts () (ebox-test--reset-runtime-state) - (let* ((a (ebox-create :content "A" :width 40)) - (b (ebox-create :content "B" :width 40)) - (c (ebox-create :content "C" :width 40)) - (row (ebox-row a nil b c)) - (column (ebox-column a b nil c)) - (spacer (ebox-spacer :width 20 :height 2))) + (let* ((a (ebox-test-box :content "A" :width 40)) + (b (ebox-test-box :content "B" :width 40)) + (c (ebox-test-box :content "C" :width 40)) + (row (ebox-test-row a nil b c)) + (column (ebox-test-column a b nil c)) + (spacer (ebox-test-spacer :width 20 :height 2))) (should (equal (ebox-region-ids row) '(1 2 3))) (should (equal (ebox-region-ids column) '(1 2 3))) (should (eq (plist-get spacer :ebox-type) 'box)) @@ -7654,9 +7662,9 @@ "Row and column should be one container node with direct children." (ebox-test--reset-runtime-state) (let* ((children (cl-loop for label in '("A" "B" "C" "D") - collect (ebox-create :content label :width 40))) - (row (apply #'ebox-row children)) - (column (apply #'ebox-column children))) + collect (ebox-test-box :content label :width 40))) + (row (apply #'ebox-test-row children)) + (column (apply #'ebox-test-column children))) (should (= (length (ebox--node-children row)) 4)) (should (= (length (ebox--node-children column)) 4)) (should (= (length (ebox--runtime-node-ids row)) 5)) @@ -7664,7 +7672,7 @@ (ert-deftest ebox-overflow-hidden-clips-extra-content-lines () (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A\nB\nC" + (let* ((box (ebox-test-box :content "A\nB\nC" :height 1 :overflow 'hidden)) (plain (substring-no-properties (ebox-render box)))) @@ -7675,7 +7683,7 @@ (ert-deftest ebox-overflow-visible-appends-extra-content-lines () (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A\nB\nC" + (let* ((box (ebox-test-box :content "A\nB\nC" :height 1 :overflow 'visible)) (plain (substring-no-properties (ebox-render box)))) @@ -7726,7 +7734,7 @@ (ert-deftest ebox-overflow-visible-paints-foreground-without-ownership () "Visible overflow should inherit only the outer box foreground." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A\nB\nC" + (let* ((box (ebox-test-box :content "A\nB\nC" :width '(40) :height 1 :overflow 'visible @@ -7740,7 +7748,7 @@ (ert-deftest ebox-region-update-refreshes-visible-overflow-foreground () "Color updates should add, replace, and remove visible overflow paint." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A\nB\nC" + (let* ((box (ebox-test-box :content "A\nB\nC" :width '(40) :height 1 :overflow 'visible @@ -7768,7 +7776,7 @@ (ert-deftest ebox-owner-rerender-keeps-visible-overflow-unowned () "Owner rerenders should not restore box ownership to visible overflow." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A\nB\nC" + (let* ((box (ebox-test-box :content "A\nB\nC" :width '(40) :height 1 :overflow 'scroll @@ -7792,7 +7800,7 @@ (ert-deftest ebox-overflow-scroll-keeps-window-height-and-state () (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A\nB\nC" + (let* ((box (ebox-test-box :content "A\nB\nC" :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box)))) @@ -7815,7 +7823,7 @@ "Scroll commands should let the buffer move when the box has no scroll work." (ebox-test--reset-runtime-state) (with-temp-buffer - (insert (ebox-render (ebox-create :content "short" :height 1))) + (insert (ebox-render (ebox-test-box :content "short" :height 1))) (insert "\n") (dotimes (index 40) (insert (format "tail-%02d\n" index))) @@ -7831,7 +7839,7 @@ (ert-deftest ebox-scroll-bubbles-from-child-to-scrollable-ancestor () "Key scrolling over a child box should scroll the nearest scrollable ancestor." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(box :id "root" :height 2 :overflow scroll (column (box :id "child" :content "A" :height 1) @@ -7875,7 +7883,7 @@ (ert-deftest ebox-wheel-scroll-falls-back-for-non-scrollable-region () "Mouse wheel over a normal box should keep default buffer scrolling." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "short" :height 1)) + (let* ((box (ebox-test-box :content "short" :height 1)) (region-id (car (ebox-region-ids box))) mwheel-args) (ebox-test--with-rendered-buffer box @@ -7890,7 +7898,7 @@ (ert-deftest ebox-wheel-scroll-consumes-scrollable-region () "Mouse wheel should still scroll an overflow='scroll box when it can." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A\nB\nC" + (let* ((box (ebox-test-box :content "A\nB\nC" :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box))) @@ -7910,7 +7918,7 @@ (ert-deftest ebox-wheel-scroll-bubbles-from-child-to-scrollable-ancestor () "Mouse wheel scrolling over a child box should scroll its scrollable ancestor." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(box :id "root" :height 2 :overflow scroll (column (box :id "child" :content "A" :height 1) @@ -7938,7 +7946,7 @@ (ert-deftest ebox-scroll-state-is-created-only-for-scrollable-boxes () "Non-overflowing boxes should not inflate the global scroll hit-test table." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(column (box :id "short-a" :content "A" :height 1) (box :id "short-b" :content "B" :height 1) @@ -7960,7 +7968,7 @@ (ert-deftest ebox-scroll-hit-test-uses-owner-stack-before-mount-lookup () "Wheel hit testing should avoid TP mount lookup when owners suffice." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(box :id "root" :height 1 :overflow scroll (column (box :id "child" :content "A" :height 1) @@ -7977,14 +7985,14 @@ (ert-deftest ebox-chrome-free-scroll-render-reuses-propertized-prefix-lines () "A chrome-free scroll box should not propertize its visible prefix twice." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "row-1\nrow-2\nrow-3" + (let* ((box (ebox-test-box :content "row-1\nrow-2\nrow-3" :width 120 :height 2 :overflow 'scroll :color "#112233" :bgcolor "#ddeeff")) (region-id (ebox--ensure-region-id box)) - (reference (ebox-create :content "row-1\nrow-2\nrow-3" + (reference (ebox-test-box :content "row-1\nrow-2\nrow-3" :width 120 :height 2 :overflow 'hidden @@ -8020,7 +8028,7 @@ (concat (propertize "alpha" 'face '(:weight bold) 'help-echo "rich") "\n \nomega")) - (box (ebox-create :content content + (box (ebox-test-box :content content :width 140 :height 5 :overflow 'scroll @@ -8028,7 +8036,7 @@ :color "#112233" :bgcolor "#ddeeff")) (region-id (ebox--ensure-region-id box)) - (reference (ebox-create :content content + (reference (ebox-test-box :content content :width 140 :height 5 :overflow 'hidden @@ -8065,7 +8073,7 @@ "Lazy scroll windows should not rebuild visible content lines per tick." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -8102,7 +8110,7 @@ "Viewport resize must preserve lazy scroll's fixed viewport fast path." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(column :id "viewport" :width (viewport) :height (viewport-height) (box :content "" :width (viewport) :height 1) @@ -8177,7 +8185,7 @@ "Viewport resize after scrolling should not materialize all hidden content." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(column :id "viewport" :width (viewport) :height (viewport-height) (box :content "" :width (viewport) :height 1) @@ -8224,7 +8232,7 @@ "Flex intrinsic measurement should not render lazy scroll window content." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (- (viewport-height) 2) :overflow scroll @@ -8256,7 +8264,7 @@ "Interactive scrolling should extend a lazy prefix even with idle prefetch off." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (- (viewport-height) 10) :overflow scroll @@ -8305,7 +8313,7 @@ "GUI scroll cache misses should not synchronously render missing content." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (- (viewport-height) 10) :overflow scroll @@ -8359,7 +8367,7 @@ "A caller-provided prefix budget should synchronously extend GUI lazy scrolls." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (- (viewport-height) 10) :overflow scroll @@ -8421,7 +8429,7 @@ :width (110) :height 1))))) (layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -8459,7 +8467,7 @@ "Lazy scroll prefixes should not render hidden row-wrap flex rows." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -8500,7 +8508,7 @@ "Partial flex window rendering should match the full render prefix." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 :padding (0 (8)) :border "#D97757" @@ -8526,7 +8534,7 @@ "Window prefix rendering must not freeze shrinkable definite widths." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :width (160) :flex-flow (row wrap) (box :id "shrink-a" :content "alpha beta gamma delta" :width (200) :height 2) @@ -8549,7 +8557,7 @@ "Char-wrapped flex prefixes should use the full-render fallback." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :width (100) :wrap-mode char :flex-flow (row wrap) :row-gap 0 :column-gap 0 ,@(cl-loop for char across "ABCD" @@ -8577,7 +8585,7 @@ "Non-left-aligned flex prefixes should use the full-render fallback." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :width (100) :text-align center :flex-flow (row wrap) :row-gap 0 :column-gap 0 ,@(cl-loop for char across "ABCDEF" @@ -8601,11 +8609,40 @@ (should (equal expected-signatures actual-signatures)) (should-not producer))) +(ert-deftest ebox-canonical-column-scroll-window-renders-a-bounded-prefix () + "A canonical Column should expose only the requested scroll prefix." + (ebox-test--reset-runtime-state) + (let* ((node + (apply #'ebox-test-column + (cl-loop for index below 20 + collect + (ebox-test-box + :content (format "row %02d" index) + :height 1)))) + (full (ebox-string-lines (ebox-render node))) + (render-count 0) + (original (symbol-function 'ebox--render-with-cache))) + (cl-letf (((symbol-function 'ebox--render-with-cache) + (lambda (&rest arguments) + (cl-incf render-count) + (apply original arguments)))) + (let ((window (ebox--render-stack-window-lines node 3))) + (should-not (plist-get window :complete)) + (should + (equal (mapcar #'substring-no-properties (seq-take full 3)) + (mapcar #'substring-no-properties + (plist-get window :lines)))) + (should + (equal (mapcar #'ebox--string-pixel-width (seq-take full 3)) + (mapcar #'ebox--string-pixel-width + (plist-get window :lines)))))) + (should (< render-count 20)))) + (ert-deftest ebox-flex-window-lines-use-chunk-wrapper-for-first-prefix () "A seedless flex prefix should avoid the generic full wrapper renderer." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :id "flex-window-wrapper" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 @@ -8654,7 +8691,7 @@ "A compatible seedless completion should avoid the generic full wrapper." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :id "flex-window-complete-wrapper" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 @@ -8696,7 +8733,7 @@ (ert-deftest ebox-flex-flat-wrapper-owns-color-copy-in-place () "Flat flex wrappers should decorate one private line copy per source line." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :width 120 + (let* ((box (ebox-test-box :width 120 :padding-left 8 :padding-right 8 :border-left 1 @@ -8732,7 +8769,7 @@ "Repeated flex window renders should reuse the render cache." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 ,@(cl-loop for idx below 8 @@ -8758,7 +8795,7 @@ "One flex window render should not repeatedly compute the node signature." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 ,@(cl-loop for idx below 8 @@ -8783,7 +8820,7 @@ "Extending a flex prefix should not rerender prior wrapper lines." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :id "flex-prefix-wrapper" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 @@ -8817,7 +8854,7 @@ "Extending a flex prefix should not rejoin the whole cached prefix." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 :padding (0 (8)) :border "#D97757" @@ -8845,7 +8882,7 @@ "A complete flex source should not full-render bottom chrome before LIMIT." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :id "flex-complete-prefix" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 @@ -8891,7 +8928,7 @@ "Completing a cached flex prefix should append bottom border without full render." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :id "flex-complete-border" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 @@ -8928,7 +8965,7 @@ "A failed flex completion should not publish half-appended cache lists." (ebox-test--reset-runtime-state) (let* ((node - (ebox-build + (ebox-test-build `(flex :id "flex-atomic-prefix" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 @@ -9048,23 +9085,23 @@ (should (= 1 (ebox--flex-window-completion-prefix-tail-rewrite-count - (list :box (ebox-create :border-bottom-p t + (list :box (ebox-test-box :border-bottom-p t :padding-bottom 0))))) (should (= 1 (ebox--flex-window-completion-prefix-tail-rewrite-count - (list :box (ebox-create :border-bottom-p t + (list :box (ebox-test-box :border-bottom-p t :padding-bottom 0 :margin-bottom 2))))) (should (= 0 (ebox--flex-window-completion-prefix-tail-rewrite-count - (list :box (ebox-create :border-bottom-p t + (list :box (ebox-test-box :border-bottom-p t :padding-bottom 2))))) (should (= 0 (ebox--flex-window-completion-prefix-tail-rewrite-count - (list :box (ebox-create :border-bottom-p nil + (list :box (ebox-test-box :border-bottom-p nil :padding-bottom 0)))))) (ert-deftest ebox-flex-window-cache-rejects-short-incomplete-exact-hit () @@ -9090,7 +9127,7 @@ (ert-deftest ebox-byte-compiled-scroll-window-binds-wrapper-viewport () "Compiled lazy rendering must dynamically bind the wrapper content width." (ebox-test--reset-runtime-state) - (let* ((wrapper (ebox-create :content "" :width '(278) :wrap-mode nil)) + (let* ((wrapper (ebox-test-box :content "" :width '(278) :wrap-mode nil)) (source (list :stack :test-stack :wrapper wrapper)) observed-viewport) (cl-letf (((symbol-function 'ebox--render-stack-window-lines) @@ -9105,7 +9142,7 @@ (ert-deftest ebox-scroll-window-wrapper-applies-declared-prefix-rewrite () "Wrapper prefix reuse should rerender only a declared changed range." (ebox-test--reset-runtime-state) - (let* ((wrapper (ebox-create :content "" :width 20 :wrap-mode nil)) + (let* ((wrapper (ebox-test-box :content "" :width 20 :wrap-mode nil)) (source (list :stack :test-stack :wrapper wrapper)) (plain-tail "tail") (complete-tail @@ -9170,7 +9207,7 @@ (ert-deftest ebox-scroll-window-wrapper-maps-top-chrome-rewrite-ranges () "Wrapper reuse should include top chrome only when source line zero changes." (ebox-test--reset-runtime-state) - (let ((wrapper (ebox-create :content "" :width 20 :wrap-mode nil + (let ((wrapper (ebox-test-box :content "" :width 20 :wrap-mode nil :margin-top 1 :padding-top 2))) (cl-labels ((mapped-range (source-range) @@ -9213,7 +9250,7 @@ (ert-deftest ebox-scroll-prefix-renderer-applies-equal-length-prefix-rewrite () "Equal-length completion should rerender only its declared changed line." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "" :width 20 :height 1 + (let* ((box (ebox-test-box :content "" :width 20 :height 1 :wrap-mode nil)) (plain-lines '("head" "body" "tail")) (source-lines (copy-sequence plain-lines)) @@ -9272,9 +9309,9 @@ (ert-deftest ebox-scroll-prefix-projects-wrapper-top-chrome-delta () "A published prefix should reuse a source cache with hidden top chrome." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "" :width 20 :height 1 + (let* ((box (ebox-test-box :content "" :width 20 :height 1 :wrap-mode nil)) - (wrapper (ebox-create :content "" :width 20 :wrap-mode nil + (wrapper (ebox-test-box :content "" :width 20 :wrap-mode nil :padding-top 2 :margin-top 1)) (source (list :stack :test-stack :wrapper wrapper)) stack-lines @@ -9340,7 +9377,7 @@ (ert-deftest ebox-scroll-prefix-exact-extension-scans-only-appended-lines () "A long exact prefix should retain its cons cells and scan only its delta." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "" :width 20 :height 1 + (let* ((box (ebox-test-box :content "" :width 20 :height 1 :wrap-mode nil)) (source-lines (cl-loop for idx below 1000 collect (format "line-%d" idx))) @@ -9473,7 +9510,7 @@ (ert-deftest ebox-scroll-prefix-renderer-failure-keeps-published-pair-atomic () "A failed rendered delta should preserve both published prefix lists." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "" :width 20 :height 1 + (let* ((box (ebox-test-box :content "" :width 20 :height 1 :wrap-mode nil)) (source-lines (list "line-0" "line-1" "line-2")) (source-tail (last source-lines)) @@ -9589,7 +9626,7 @@ "A prewarm-only prefix slice should yield once, then publish exact content." (ebox-test--reset-runtime-state) (let* ((node (list :ebox-type 'test-yielding-prewarm)) - (box (ebox-create :content "" :width 20 :height 1 + (box (ebox-test-box :content "" :width 20 :height 1 :wrap-mode nil :overflow 'scroll)) (renderer (ebox--scroll-window-prefix-renderer @@ -9637,7 +9674,7 @@ "Large stack-backed scroll boxes should render hidden rows only on demand." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) :padding (1 0) @@ -9695,7 +9732,7 @@ "Incremental lazy scroll renders should cover one smooth wheel drain." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -9725,7 +9762,7 @@ "Small lazy scroll steps should not materialize the whole source." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -9772,7 +9809,7 @@ "Large lazy scroll misses should schedule prefetch without inline render." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -9833,7 +9870,7 @@ "Smooth scroll cache misses should keep moving with bounded prefix render." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -9894,7 +9931,7 @@ "Smooth scroll cache misses should extend a bounded prefix without stalling." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -9955,7 +9992,7 @@ "Large smooth pending input should not render a large lazy prefix at once." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10011,7 +10048,7 @@ "Idle prefetch should not compete with an active smooth scroll timer." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10064,7 +10101,7 @@ "Smooth scroll should not pause at a lazy prefix boundary." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10119,7 +10156,7 @@ "Repeated lazy scroll steps should keep TP mounts valid." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10177,7 +10214,7 @@ "Repeated lazy scroll steps should reuse a prefetched lazy prefix." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10223,7 +10260,7 @@ "Idle prefetch should fill lazy scroll lines before wheel ticks need them." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10272,7 +10309,7 @@ "Lazy prefix slices should retain the initial render's display cache." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10372,7 +10409,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-item-prefetch*") - (ebox-build + (ebox-test-build '(flex :width (240) (item (box :content "atomic" :width (120))))))) (puthash @@ -10525,7 +10562,7 @@ "Successful lazy scroll steps should schedule the next generic prefetch." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10557,7 +10594,7 @@ "Smooth scroll should prefetch only after the active animation stops." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10625,7 +10662,7 @@ "Exact-width lazy scroll source lines should not be reformatted." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10663,7 +10700,7 @@ (defun ebox-test--complete-lazy-scroll-batch-layout () "Return a complete five-row lazy scroll cache fixture." - (ebox-build + (ebox-test-build '(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10702,7 +10739,7 @@ "Extending a lazy prefix should not replay producer caches from before an update." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10735,7 +10772,7 @@ "Extending a lazy prefix should preserve committed content until batch flush." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -10929,7 +10966,7 @@ (ert-deftest ebox-explicit-batch-flush-retries-after-quit () "A quit during a batch flush must keep the recorded changes retryable." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Batch retry" :color "black")) + (let* ((box (ebox-test-box :content "Batch retry" :color "black")) (id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((buffer (current-buffer))) @@ -10963,7 +11000,7 @@ "Lazy prefix extension should render only newly appended visible lines." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11015,7 +11052,7 @@ "Lazy prefix extension should not join the full cached prefix string." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11056,11 +11093,11 @@ "Lazy prefix extension should not recompute node signatures each slice." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll))) (source-node - (ebox-build + (ebox-test-build `(column :width (viewport) (flex :id "signature-flex" :width (viewport) :flex-flow (row wrap) @@ -11101,7 +11138,7 @@ "Lazy prefix extension should append wrapper chrome without rerendering it." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) :box-sizing border-box @@ -11155,7 +11192,7 @@ "The first smooth wheel input should only replace visible lines." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11208,7 +11245,7 @@ "Post-resize lazy scroll should not extend prefixes inside smooth ticks." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11263,7 +11300,7 @@ "Viewport rerender should warm lazy scroll prefixes before fast scrolling." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11301,7 +11338,7 @@ "Smooth scrolling should keep render GC settings until the animation stops." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11359,7 +11396,7 @@ "A parent paint-patch must not clobber a child's own declared paint." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id parent :width (120) :color "black" (column :width (120) (box :id child :content "Child" :width (120) @@ -11388,7 +11425,7 @@ "Intrinsic measurement must not reset live scroll offsets or state." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11429,7 +11466,7 @@ "A quit during viewport reflow must not wedge later same-size reflows." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (viewport) :content "viewport reflow retry"))) (buffer nil)) @@ -11463,7 +11500,7 @@ "Coalesced wheel events must not leak the deferred GC lease depth." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11513,7 +11550,7 @@ "Smooth wheel clamp should extend lazy scroll prefixes without full render." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11556,7 +11593,7 @@ "Hidden updates inside lazy scroll windows should stay deferred." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11647,7 +11684,7 @@ "A clean offscreen prefix extension should not stall the live window." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11680,7 +11717,7 @@ "A visibly changed prefix should publish atomically without offset motion." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11727,7 +11764,7 @@ "A visible slot miss should publish rebuilt lazy lines before returning." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11786,7 +11823,7 @@ "Hidden updates should patch cached lazy prefix lines before deferring." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11833,7 +11870,7 @@ "Deferred hidden updates should be visible when lazy prefix reaches them." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11881,7 +11918,7 @@ (ert-deftest ebox-lazy-scroll-dirty-prefix-rebinds-signature-cache () "Dirty lazy prefixes should rebind the render signature cache before rendering." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "" :width 10)) + (let* ((box (ebox-test-box :content "" :width 10)) (renderer (ebox--scroll-window-prefix-renderer box :test-source 120 4)) first-cache @@ -11916,7 +11953,7 @@ "Visible content plus paint updates should patch scroll lines directly." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -11962,7 +11999,7 @@ "Post-patch scroll line sync should retain TP-owned viewport ranges." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -12002,7 +12039,7 @@ "Paint updates and later scrolling should retain TP-owned ranges." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -12049,7 +12086,7 @@ "A scroll-free root reflow should not inspect scroll containment." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(column :width (viewport) (box :content "resize" :width (viewport))))) buffer @@ -12083,7 +12120,7 @@ "Hidden geometry changes in lazy scroll windows should not rerender roots." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -12116,7 +12153,7 @@ "Paint-only updates should not pay lazy scroll geometry deferral costs." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -12200,7 +12237,7 @@ "Predictive warming should start from the first valid resize velocity." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (viewport) :height 2 (box :content "steady" :width (viewport) :height 1)))) buffer @@ -12245,7 +12282,7 @@ "Predicted renders should publish only persistent render-cache entries." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (flex :width (viewport) :gap 1 @@ -12303,7 +12340,7 @@ "A stale predicted render should exit before touching render caches." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (viewport) :content "stale"))) buffer (render-count 0) @@ -12335,7 +12372,7 @@ "Single-property updates should cancel stale predictions and schedule none." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (viewport) (box :id "target" :content "old" :width 80)))) buffer) @@ -12362,7 +12399,7 @@ "Idle prewarm should prepare shared update state without materializing scrolls." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height 2 :overflow scroll (column ,@(cl-loop for idx below 40 @@ -12427,7 +12464,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-visible-prewarm*") - (ebox-build + (ebox-test-build '(box :id "root" :height 2 :overflow scroll (column (box :content "one" :height 1) @@ -12482,7 +12519,7 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-native-scroll-opt-in*") - (ebox-build + (ebox-test-build '(box :id "root" :height 2 :overflow scroll (column (box :content "one" :height 1) @@ -12517,7 +12554,7 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-native-scroll-viewport*") - (ebox-build + (ebox-test-build '(box :id "root" :width (viewport) :height 2 :overflow scroll (column @@ -12540,7 +12577,7 @@ explicit visible-window handoff may admit its full-content publication." "Prewarm should index current scroll lines while lazy prefix work is active." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build '(box :id "root" :height 2 :overflow scroll (column (box :id "target" :content "target" :height 1) @@ -12579,7 +12616,7 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-build + (ebox-test-build '(box :id "root" :height 2 :overflow scroll (column (box :content "row 0" :height 1) @@ -12621,7 +12658,7 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-build + (ebox-test-build '(box :id "root" :height 2 :overflow scroll (column (box :content "row 0" :height 1) @@ -12708,7 +12745,7 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-build + (ebox-test-build '(box :height 2 :overflow scroll (column (box :content "row 0" :height 1) @@ -12734,7 +12771,7 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-build + (ebox-test-build '(column (box :id "a" :content "A") (box :id "b" :content "B"))))) @@ -12755,7 +12792,7 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-build + (ebox-test-build '(column (box :id "target" :content "old") (box :id "sibling" :content "same"))))) @@ -12784,7 +12821,7 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-build '(box :id "target" :content "old")))) + (ebox-test-build '(box :id "target" :content "old")))) (should (gethash buffer ebox--runtime-prewarm-jobs)) (let ((target-id (ebox-test--selector-region-id buffer "#target"))) @@ -12809,7 +12846,7 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-build + (ebox-test-build '(column (box :content "row 0") (box :content "row 1") @@ -12849,7 +12886,7 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-build + (ebox-test-build '(box :width (210) (column (box :id "width-only" :width (viewport)) @@ -12894,11 +12931,66 @@ explicit visible-window handoff may admit its full-content publication." (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-viewport-reflow-consumes-prepared-context-axes () + "A topology-stable viewport commit must not rederive its axis partition." + (ebox-test--reset-runtime-state) + (let ((buffer nil) + (axis-scans 0) + (validation-scans 0) + (inheritance-scans 0) + (reconciliations 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 6)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-axis-proof*") + (ebox-test-build + '(column :width (viewport) + (box :content "A" :height 1) + (box :content "B" :height 1))))) + (let ((original-axes + (symbol-function 'ebox-surface--context-axes)) + (original-validation + (symbol-function 'ebox-tree-validate-declarative-root)) + (original-inheritance + (symbol-function + 'ebox-surface--inline-inheritance-required-p)) + (original-reconcile + (symbol-function 'ebox-surface--reconcile-candidate))) + (cl-letf (((symbol-function 'ebox-surface--context-axes) + (lambda (&rest arguments) + (cl-incf axis-scans) + (apply original-axes arguments))) + ((symbol-function 'ebox-tree-validate-declarative-root) + (lambda (&rest arguments) + (cl-incf validation-scans) + (apply original-validation arguments))) + ((symbol-function + 'ebox-surface--inline-inheritance-required-p) + (lambda (&rest arguments) + (cl-incf inheritance-scans) + (apply original-inheritance arguments))) + ((symbol-function 'ebox-surface--reconcile-candidate) + (lambda (&rest arguments) + (cl-incf reconciliations) + (apply original-reconcile arguments)))) + (let ((report + (ebox-rerender-buffer-with-context buffer 220 6))) + (should (eq (plist-get report :projection-kind) + 'viewport-reflow))))) + (should (zerop axis-scans)) + (should (zerop validation-scans)) + (should (zerop inheritance-scans)) + (should (zerop reconciliations))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-runtime-idle-prewarm-scroll-slices-use-list-cursors () "Late cached-line slices should not restart traversal from the list head." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :height 2 :overflow scroll (column ,@(cl-loop for idx below 40 @@ -12928,7 +13020,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-hidden-scroll-child-content-update-patches-scroll-state () "Hidden scroll children should update cached scroll lines without rerendering." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(box :id "root" :height 1 :overflow scroll (column (box :content "visible" :height 1) @@ -12962,7 +13054,7 @@ explicit visible-window handoff may admit its full-content publication." collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1))) - (layout (ebox-build + (layout (ebox-test-build `(box :id "root" :height 2 :overflow scroll (column ,@children)))) root-id target-id) @@ -12989,7 +13081,7 @@ explicit visible-window handoff may admit its full-content publication." "An empty fixed box in a visible scene must not be mistaken for hidden." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-build + (ebox-test-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -13043,7 +13135,7 @@ explicit visible-window handoff may admit its full-content publication." :content ,(format "row %02d" index) :height 1))) (layout - (ebox-build + (ebox-test-build `(column :width (viewport) (box :id "rail" :content "ready" :width (viewport) :height 1) @@ -13084,7 +13176,7 @@ explicit visible-window handoff may admit its full-content publication." "Geometry updates on a revealed child should use a scoped TP plan." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (180) :height 2 :overflow scroll (column (box :content "row 0" :height 1) @@ -13110,7 +13202,7 @@ explicit visible-window handoff may admit its full-content publication." "Updating a scrolled-away node should not reuse its stale buffer spans." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (180) :height 2 :overflow scroll (column (box :id "target" :content "target" :width (120) :height 1 @@ -13162,7 +13254,7 @@ explicit visible-window handoff may admit its full-content publication." "A hidden inner-scroll update should survive inner and outer replay." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (180) :height 1 :overflow scroll (column (box :id "wrapper" :width (180) :height 1 :overflow scroll @@ -13211,7 +13303,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-wheel-scroll-uses-buffer-scroll-region-without-point () "Wheel events from fringe-like areas should still scroll the ebox page." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(box :id "root" :height 2 :overflow scroll (column (box :content "A" :height 1) @@ -13293,7 +13385,7 @@ explicit visible-window handoff may admit its full-content publication." "Nested boxes should consume their capacity before the native buffer." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id "outer" :height 4 :overflow scroll (column (box :id "middle" :height 2 :overflow scroll @@ -13460,7 +13552,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-scroll-fast-report-uses-boolean-proof-flag () "Retained-window proof payload should not leak into the public report." (ebox-test--reset-runtime-state) - (let ((layout (ebox-build + (let ((layout (ebox-test-build '(box :id "root" :content "A\nB" :height 1 :overflow scroll)))) (ebox-test--with-rendered-buffer layout @@ -13488,7 +13580,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-smooth-wheel-scroll-moves-immediately-then-drains () "Smooth wheel scrolling should respond immediately and drain pending lines." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(box :id "root" :height 1 :overflow scroll (column (box :content "A" :height 1) @@ -13511,7 +13603,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-smooth-wheel-scroll-coalesces-while-timer-active () "Further wheel input should queue behind the active timer, not edit at once." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(box :id "root" :height 1 :overflow scroll (column (box :content "A" :height 1) @@ -13540,7 +13632,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-smooth-wheel-scroll-clamps-pending-to-range () "Large smooth wheel input should not leave an oversized timer backlog." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(box :id "root" :height 1 :overflow scroll (column (box :content "A" :height 1) @@ -13568,7 +13660,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-page-scroll-bubbles-from-child-to-scrollable-ancestor () "Page scrolling over a child box should scroll the nearest scrollable ancestor." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(box :id "root" :height 3 :overflow scroll (column (box :id "child" :content "A" :height 1) @@ -13601,7 +13693,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-scroll-rerender-preserves-viewport-height-context () "Scroll rerender fallback should keep viewport-height boxes clipped." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(box :id "root" :height (viewport-height) :overflow scroll (column (box :id "child" :content "A" :height 1) @@ -13633,7 +13725,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-wheel-scroll-falls-back-at-scroll-boundary () "Mouse wheel should bubble to the buffer when inner scroll is at its edge." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A\nB\nC" + (let* ((box (ebox-test-box :content "A\nB\nC" :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box))) @@ -13715,7 +13807,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-border-box-max-height-includes-vertical-padding () (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A\nB\nC\nD" + (let* ((box (ebox-test-box :content "A\nB\nC\nD" :box-sizing 'border-box :max-height 3 :padding '(1 0) @@ -13728,7 +13820,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-content-box-min-height-wins-when-min-exceeds-max () "Content-box height conflicts should use the minimum as the used height." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A" + (let* ((box (ebox-test-box :content "A" :box-sizing 'content-box :min-height 5 :max-height 3 @@ -13741,7 +13833,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-border-box-min-height-wins-when-min-exceeds-max () "Border-box height conflicts should use the minimum as the outer height." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "A" + (let* ((box (ebox-test-box :content "A" :box-sizing 'border-box :min-height 5 :max-height 3 @@ -13753,7 +13845,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-border-box-max-width-includes-horizontal-padding-and-border () (ebox-test--reset-runtime-state) - (let ((box (ebox-create :content "abc abc abc abc abc abc abc abc abc abc" + (let ((box (ebox-test-box :content "abc abc abc abc abc abc abc abc abc abc" :box-sizing 'border-box :max-width '(100) :padding-inline '(20) @@ -13769,7 +13861,7 @@ explicit visible-window handoff may admit its full-content publication." (expected (apply #'max (mapcar #'ebox--string-pixel-width (ebox-string-lines content)))) - (box (ebox-create :content content)) + (box (ebox-test-box :content content)) (rendered (ebox-render box)) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines rendered)))) @@ -13781,7 +13873,7 @@ explicit visible-window handoff may admit its full-content publication." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 240)) (dolist (width-value '(nil auto)) - (let ((box (apply #'ebox-create + (let ((box (apply #'ebox-test-box (append (list :content "short line wraps into viewport" :padding-inline '(20) :border-left '(10) @@ -13796,7 +13888,7 @@ explicit visible-window handoff may admit its full-content publication." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 120) (content "alpha beta gamma delta") - (box (ebox-create :content content :width '(220) :wrap-mode 'word)) + (box (ebox-test-box :content content :width '(220) :wrap-mode 'word)) (rendered (ebox-render box)) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines rendered)))) @@ -13808,7 +13900,7 @@ explicit visible-window handoff may admit its full-content publication." "A column wrapper width should contain auto-sized children." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 1000) - (node (ebox-build + (node (ebox-test-build '(column :width (120) (box :content "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega" :wrap-mode word)))) @@ -13827,7 +13919,7 @@ explicit visible-window handoff may admit its full-content publication." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 1000)) (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7))) - (let* ((node (ebox-build + (let* ((node (ebox-test-build '(column :width (400) (box :content "X")))) (widths (mapcar #'ebox--string-pixel-width @@ -13838,7 +13930,7 @@ explicit visible-window handoff may admit its full-content publication." "A column wrapper width should become the viewport for nested auto flex." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 1000) - (node (ebox-build + (node (ebox-test-build '(column :width (240) (flex :flex-wrap wrap :column-gap (10) (box :content "A" :width (100)) @@ -13858,9 +13950,9 @@ explicit visible-window handoff may admit its full-content publication." "An overflowing definite child should not widen unrelated column rows." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 200) - (node (ebox-column - (ebox-create :content "Auto row") - (ebox-create :content "Fixed row" :width '(300)))) + (node (ebox-test-column + (ebox-test-box :content "Auto row") + (ebox-test-box :content "Fixed row" :width '(300)))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node))))) (should (equal widths '(200 300))))) @@ -13872,20 +13964,20 @@ explicit visible-window handoff may admit its full-content publication." (max-content (ebox--string-pixel-width content)) (min-content (ebox--string-pixel-width "bbbb"))) (should (= (ebox--content-pixel - (ebox-create :content content :width 'auto)) + (ebox-test-box :content content :width 'auto)) max-content)) (should (= (ebox--content-pixel - (ebox-create :content content :width 'max-content)) + (ebox-test-box :content content :width 'max-content)) max-content)) (should (= (ebox--content-pixel - (ebox-create :content content :width 'min-content)) + (ebox-test-box :content content :width 'min-content)) min-content)) (should (= (ebox--content-pixel - (ebox-create :content content + (ebox-test-box :content content :width '(80) :max-width 'none)) 80)) - (should-error (ebox-create :content content :width 'none) + (should-error (ebox-test-box :content content :width 'none) :type 'error))) (ert-deftest ebox-max-content-wrapper-measures-composite-child-intrinsically () @@ -13896,12 +13988,12 @@ explicit visible-window handoff may admit its full-content publication." (expected (apply #'max (mapcar #'ebox--string-pixel-width lines))) (node - (ebox-create + (ebox-test-box :width 'max-content :ebox-content-node - (apply #'ebox-column + (apply #'ebox-test-column (mapcar (lambda (line) - (ebox-create :content line)) + (ebox-test-box :content line)) lines)))) (widths (mapcar #'ebox--string-pixel-width @@ -13911,7 +14003,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-definite-width-skips-unused-max-content-measurement () "A definite preferred width should not evaluate its unused auto fallback." (ebox-test--reset-runtime-state) - (let ((box (ebox-create :content "measurement must stay cold" + (let ((box (ebox-test-box :content "measurement must stay cold" :width '(80))) (measurements 0) (original (symbol-function 'ebox--content-max-pixel))) @@ -13927,7 +14019,7 @@ explicit visible-window handoff may admit its full-content publication." (ebox-test--reset-runtime-state) (let* ((content "intrinsic constraint") (expected (ebox--string-pixel-width content)) - (box (ebox-create :content content + (box (ebox-test-box :content content :width '(20) :min-width 'max-content)) (measurements 0) @@ -13942,7 +14034,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-auto-width-keeps-max-content-fallback-without-viewport () "An unresolved auto width should retain its intrinsic max-content fallback." (ebox-test--reset-runtime-state) - (let ((box (ebox-create :content "auto fallback")) + (let ((box (ebox-test-box :content "auto fallback")) (measurements 0) (original (symbol-function 'ebox--content-max-pixel))) (cl-letf (((symbol-function 'ebox--content-max-pixel) @@ -13962,15 +14054,15 @@ explicit visible-window handoff may admit its full-content publication." (should (< min-content between)) (should (< between max-content)) (should (= (ebox--content-pixel - (ebox-create :content content + (ebox-test-box :content content :width (list 'fit-content (list between)))) between)) (should (= (ebox--content-pixel - (ebox-create :content content + (ebox-test-box :content content :width (list 'fit-content (list 1)))) min-content)) (should (= (ebox--content-pixel - (ebox-create :content content + (ebox-test-box :content content :width (list 'fit-content (list (+ max-content 50))))) max-content)))) @@ -13984,7 +14076,7 @@ explicit visible-window handoff may admit its full-content publication." (stretch (+ min-content (/ (- max-content min-content) 2))) (ebox-viewport-width stretch)) (should (= (ebox--content-pixel - (ebox-create :content content :width 'fit-content)) + (ebox-test-box :content content :width 'fit-content)) stretch)))) (ert-deftest ebox-min-and-max-width-keywords-constrain-preferred-width () @@ -13995,23 +14087,23 @@ explicit visible-window handoff may admit its full-content publication." (max-content (ebox--string-pixel-width content)) (between (+ min-content (/ (- max-content min-content) 2)))) (should (= (ebox--content-pixel - (ebox-create :content content + (ebox-test-box :content content :width '(1) :min-width 'max-content)) max-content)) (should (= (ebox--content-pixel - (ebox-create :content content + (ebox-test-box :content content :width (list (+ max-content 50)) :max-width 'min-content)) min-content)) (should (= (ebox--content-pixel - (ebox-create :content content + (ebox-test-box :content content :width '(1) :min-width (list 'fit-content (list between)))) between)) (should (= (ebox--content-pixel - (ebox-create :content content + (ebox-test-box :content content :width (list (+ max-content 50)) :max-width (list 'fit-content (list between)))) @@ -14022,7 +14114,7 @@ explicit visible-window handoff may admit its full-content publication." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 240)) (dolist (keyword '(stretch contain)) - (let ((box (ebox-create :content "x" + (let ((box (ebox-test-box :content "x" :width keyword :padding-inline '(20) :border-left '(10) @@ -14037,7 +14129,7 @@ explicit visible-window handoff may admit its full-content publication." (lambda (_string) 60)) ((symbol-function 'ebox--max-char-pixel) (lambda (_string) 60))) - (let ((box (ebox-create :content "W" + (let ((box (ebox-test-box :content "W" :max-width '(20) :wrap-mode 'char))) (should (= (ebox--content-pixel box) 20)) @@ -14046,7 +14138,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-min-width-wins-when-min-width-exceeds-max-width () "CSS width constraints make min-width override max-width conflicts." (ebox-test--reset-runtime-state) - (let ((box (ebox-create :content "short" + (let ((box (ebox-test-box :content "short" :width '(40) :min-width '(120) :max-width '(80)))) @@ -14056,9 +14148,9 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-width-properties-reject-negative-values () "Width, min-width, and max-width should reject negative lengths." (ebox-test--reset-runtime-state) - (should-error (ebox-create :content "bad" :width '(-1)) :type 'error) - (should-error (ebox-create :content "bad" :min-width '(-1)) :type 'error) - (should-error (ebox-create :content "bad" :max-width '(-1)) :type 'error)) + (should-error (ebox-test-box :content "bad" :width '(-1)) :type 'error) + (should-error (ebox-test-box :content "bad" :min-width '(-1)) :type 'error) + (should-error (ebox-test-box :content "bad" :max-width '(-1)) :type 'error)) (ert-deftest ebox-edge-properties-reject-negative-values () "Public edge properties and engine longhands should reject negatives." @@ -14071,7 +14163,7 @@ explicit visible-window handoff may admit its full-content publication." (:border-top-width -1) (:border-left-pixel -1))) (should-error - (apply #'ebox-create (append '(:content "bad") props)) + (apply #'ebox-test-box (append '(:content "bad") props)) :type 'error))) (ert-deftest ebox-line-min-content-skips-discarded-space-measurements () @@ -14099,7 +14191,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-region-update-valid-paint-and-invalid-edge-is-atomic () "Invalid geometry should fail before a valid sibling property mutates." (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Atomic" + (let* ((box (ebox-test-box :content "Atomic" :width '(120) :bgcolor "#111111" :padding-left-pixel 2)) @@ -14117,7 +14209,7 @@ explicit visible-window handoff may admit its full-content publication." "A legal no-op should return before discovering rollback scope." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (240) :height 1 :overflow scroll (box :id "target" :content "same" :width (120)))))) (ebox-test--with-rendered-buffer layout @@ -14140,7 +14232,7 @@ explicit visible-window handoff may admit its full-content publication." "A child rollback snapshot should retain its scroll owner's box identity." (ebox-test--reset-runtime-state) (let ((layout - (ebox-build + (ebox-test-build '(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) @@ -14173,7 +14265,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-region-update-rolls-back-when-rerender-fails () "A failed local rerender must leave model, buffer, and report unchanged." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build + (let* ((layout (ebox-test-build '(box :id "target" :content "before" :width (120)))) (report-before 'keep-report)) (ebox-test--with-rendered-buffer layout @@ -14203,7 +14295,7 @@ explicit visible-window handoff may admit its full-content publication." (lines (ebox-string-lines (substring-no-properties (ebox-render - (ebox-create :content content + (ebox-test-box :content content :width (list width) :wrap-mode 'word)))))) (should (= (length lines) 2)) @@ -14260,7 +14352,7 @@ explicit visible-window handoff may admit its full-content publication." (lambda (text width) (setq justify-args (list text width)) text))) - (ebox-render (ebox-create :content "abc" + (ebox-render (ebox-test-box :content "abc" :width '(100) :wrap-mode 'kp))) (should require-called) @@ -14276,7 +14368,7 @@ explicit visible-window handoff may admit its full-content publication." (lambda (&rest _) "abc\nx"))) (dolist (align '(center right)) - (let* ((box (ebox-create :content "ignored" + (let* ((box (ebox-test-box :content "ignored" :width '(100) :wrap-mode 'kp :text-align align)) @@ -14296,7 +14388,7 @@ explicit visible-window handoff may admit its full-content publication." (lambda (_text width) (concat (ebox-pixel-space width) "\nshort")))) (let* ((rendered (ebox-render - (ebox-create :content "ignored" + (ebox-test-box :content "ignored" :width '(100) :wrap-mode 'kp))) (widths (mapcar #'ebox-string-pixel-width @@ -14306,7 +14398,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-content-face-keeps-foreground-and-background () (ebox-test--reset-runtime-state) (let* ((rendered (ebox-render - (ebox-create :content "Face" + (ebox-test-box :content "Face" :width 40 :color "#abcdef" :bgcolor "#123456"))) @@ -14317,7 +14409,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-border-faces-keep-side-specific-properties () (ebox-test--reset-runtime-state) (let* ((rendered (ebox-render - (ebox-create :content "Border" + (ebox-test-box :content "Border" :width 60 :padding '(1 1) :border '(1 "#ff0000")))) @@ -14346,7 +14438,7 @@ explicit visible-window handoff may admit its full-content publication." sentinel (funcall original-frame-parameter frame parameter))))) (let* ((rendered (ebox-render - (ebox-create :content "Border" + (ebox-test-box :content "Border" :width 60 :padding '(1 1) :border t))) @@ -14367,7 +14459,7 @@ explicit visible-window handoff may admit its full-content publication." (require 'face-remap) (let* ((text-scale-mode-amount 2) (rendered (ebox-render - (ebox-create :content "Border" + (ebox-test-box :content "Border" :width 120 :padding '(0 (8)) :border '(1 "#ff0000")))) @@ -14393,7 +14485,7 @@ explicit visible-window handoff may admit its full-content publication." (require 'face-remap) (let* ((text-scale-mode-amount -2) (rendered (ebox-render - (ebox-create :content "Border" + (ebox-test-box :content "Border" :width 120 :padding '(0 (8)) :border '(1 "#ff0000")))) @@ -14417,7 +14509,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-horizontal-borders-do-not-cover-horizontal-margins () (ebox-test--reset-runtime-state) (let* ((rendered (ebox-render - (ebox-create :content "Border" + (ebox-test-box :content "Border" :width 80 :margin '(0 (8) 0 (8)) :border '(1 "#ff0000")))) @@ -14472,6 +14564,15 @@ explicit visible-window handoff may admit its full-content publication." (face-remapping-alist '((default (:height 1.4))))) (should-not (equal plain (ebox--display-signature))))) +(ert-deftest ebox-display-signature-ignores-tp-paint-slot-remapping () + "Installing a TP paint layer must not invalidate geometry measurement." + (ebox-test--reset-runtime-state) + (let ((plain (ebox--display-signature)) + (face-remapping-alist + '((tp-paint-slot-99 (:foreground "#123456") + (:background "#F8FAFC"))))) + (should (equal plain (ebox--display-signature))))) + (ert-deftest ebox-pixel-width-respects-text-scale-with-fixed-spaces () (ebox-test--reset-runtime-state) (require 'face-remap) @@ -14596,9 +14697,9 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-flex-recache-skips-visited-source-subtrees () "Repeated source recache calls should not walk the same subtree twice." (ebox-test--reset-runtime-state) - (let* ((source (ebox-column - (ebox-create :content "A") - (ebox-create :content "B"))) + (let* ((source (ebox-test-column + (ebox-test-box :content "A") + (ebox-test-box :content "B"))) (ebox--render-recached-source-node-cache (make-hash-table :test 'eq)) (original-puthash (symbol-function 'puthash)) @@ -14637,7 +14738,7 @@ explicit visible-window handoff may admit its full-content publication." "A hidden box renders blank with identical text, lengths, and metadata." (let* ((make-box (lambda (visibility) - (ebox-create :content "Hello" :width (list 90) :border t + (ebox-test-box :content "Hello" :width (list 90) :border t :border-color "#AA3311" :bgcolor "#223344" :color "#FFFFFF" :padding '(0 1) :visibility visibility))) @@ -14671,16 +14772,16 @@ explicit visible-window handoff may admit its full-content publication." The buffer text never changes; showing again restores the visible publication with properties intact." (let* ((buffer (generate-new-buffer " *ebox-visibility*")) - (root (ebox-create + (root (ebox-test-box :width (list 300) :ebox-content-node - (ebox-column - (ebox-create :content "Header line" :id 'header + (ebox-test-column + (ebox-test-box :content "Header line" :id 'header :width (list 300)) - (ebox-create :content "Secret content" :id 'secret + (ebox-test-box :content "Secret content" :id 'secret :width (list 300) :border t :bgcolor "#224466" :color "#FFFFFF") - (ebox-create :content "Footer line" :id 'footer + (ebox-test-box :content "Footer line" :id 'footer :width (list 300)))))) (unwind-protect (progn @@ -14714,13 +14815,13 @@ face patch must match that exactly (issue014)." (ebox-test--reset-runtime-state) (let* ((make-tree (lambda (parent-color) - (ebox-create + (ebox-test-box :id 'parent :width (list 400) :border (list '(4) 'solid parent-color) :padding '(1 (12)) :ebox-content-node - (ebox-column - (ebox-create :id 'child :content "CHILD" :width (list 200) + (ebox-test-column + (ebox-test-box :id 'child :content "CHILD" :width (list 200) :border '((4) solid "#00AA00")))))) (visual-runs (lambda (buffer role) diff --git a/tests/ebox-dsl-tests.el b/tests/ebox-dsl-tests.el index 850385c..fa54e79 100644 --- a/tests/ebox-dsl-tests.el +++ b/tests/ebox-dsl-tests.el @@ -149,7 +149,8 @@ (should-not (ebox-box-node-p node)) (should (eq (ebox-node-source-handle node) 'source-text)) (should (string= (ebox-text-node-value node) "Hello")) - (should (string= (ebox-dsl-test--plain node) "Hello")))) + (should (string= (ebox-dsl-test--plain node) "Hello")) + (should-not (ebox-tree-node-visible-overflow-p node)))) (ert-deftest ebox-canonical-text-constructor-rejects-non-text-shapes () "A canonical TextNode should have exactly one string payload." @@ -157,6 +158,7 @@ (should-error (ebox-text-create :value "A" :content "B") :type 'error) (should-error (ebox-text-create :value "A" :children nil) :type 'error) (should-error (ebox-text-create :value "A" :color "red") :type 'error) + (should-error (ebox-build '(text :wrap-mode none "A")) :type 'error) (should-error (ebox-text-create :value "A" :value "B") :type 'error) @@ -164,6 +166,89 @@ (ebox-text-create :value "A" :source-handle 'a :source-handle 'b) :type 'error)) +(ert-deftest ebox-canonical-box-uses-registered-runtime-defaults () + "A Box should materialize the definite defaults consumed by layout." + (let ((box (ebox-box-create + :layout (ebox-normal-layout-create) :children nil))) + (should (eq (ebox-get box :overflow) 'scroll)) + (dolist (property '(:padding-left-pixel :padding-right-pixel + :padding-top-height :padding-bottom-height + :margin-left-pixel :margin-right-pixel + :margin-top-height :margin-bottom-height + :border-left-pixel :border-right-pixel)) + (should (zerop (ebox-get box property)))))) + +(ert-deftest ebox-render-cache-signature-ignores-derived-layout-proofs () + "A render must not invalidate its own canonical node cache key." + (let* ((node (ebox-build '(box :width '(80) "Stable"))) + (before + (let ((ebox--render-cache-signature-cache + (make-hash-table :test 'eq))) + (ebox--render-cache-node-signature node)))) + (plist-put node :ebox-content-width-exact-p t) + (plist-put node :ebox-content-layout-complete-p t) + (let ((after + (let ((ebox--render-cache-signature-cache + (make-hash-table :test 'eq))) + (ebox--render-cache-node-signature node)))) + (should (equal-including-properties before after))))) + +(ert-deftest ebox-canonical-box-materializes-inherited-foreground-once () + "A canonical Text should be the sole foreground paint owner under Box." + (let* ((rendered (ebox-render (ebox-build '(box :color "#123456" "A")))) + (face (get-text-property 0 'face rendered))) + (should (equal face '(:foreground "#123456"))))) + +(ert-deftest ebox-box-owns-wrap-policy-consumed-by-text () + "Box declarations should become inherited Text measurement input." + (let ((buffer (generate-new-buffer " *ebox-wrap-owner*"))) + (unwind-protect + (progn + (ebox-render-to-buffer + buffer (ebox-build '(box :wrap-mode none "Page 1 / 3"))) + (let* ((root (plist-get (ebox--buffer-render-state buffer) + :root-node)) + (text (car (ebox-box-node-children root)))) + (should (eq (ebox-style-node-specified-value root :wrap-mode) + 'none)) + (should (eq (plist-get root :wrap-mode) 'none)) + (should (eq (plist-get text :wrap-mode) 'none)))) + (kill-buffer buffer)))) + +(ert-deftest ebox-box-wrap-modes-preserve-word-char-kp-and-none () + "Keep every established Box wrapping algorithm behind one property." + (dolist (mode '(word char kp none)) + (let ((node (ebox-build `(box :wrap-mode ,mode "A B")))) + (should (eq (ebox-style-node-specified-value node :wrap-mode) mode)))) + (should-error (ebox-build '(box :wrap-mode arbitrary "A")) :type 'error) + (let* ((width (ebox--string-pixel-width "A")) + (char-box + (ebox-box-create + :layout (ebox-normal-layout-create) + :children (list (ebox-text-create :value "AB")) + :width (list width) :wrap-mode 'char)) + (none-box + (ebox-box-create + :layout (ebox-normal-layout-create) + :children (list (ebox-text-create :value "AB")) + :width (list width) :wrap-mode 'none))) + (should (= (length (ebox-string-lines (ebox-render char-box))) 2)) + (should (= (length (ebox-string-lines (ebox-render none-box))) 1))) + (let (justify-args) + (cl-letf (((symbol-function 'require) + (lambda (feature &optional _filename _noerror) + (eq feature 'ekp))) + ((symbol-function 'ekp-pixel-justify) + (lambda (text width) + (setq justify-args (list text width)) + text))) + (ebox-render + (ebox-box-create + :layout (ebox-normal-layout-create) + :children (list (ebox-text-create :value "paragraph")) + :width '(100) :wrap-mode 'kp))) + (should (equal justify-args '("paragraph" 100))))) + (ert-deftest ebox-canonical-normal-box-renders-inline-runs-and-blocks () "Normal should retain typed children and honor inline/block boundaries." (let* ((text (ebox-text-create :value "A" :source-handle 'source-text)) @@ -442,29 +527,6 @@ :column-gap) 8))))) -(ert-deftest ebox-canonical-flex-projects-one-box-frame-constraint () - "Typed and legacy Flex should derive the same content size from BoxFrame." - (dolist (sizing '(border-box content-box)) - (let* ((typed - (ebox-box-create - :layout (ebox-flex-layout-create) - :children (list (ebox-text-create :value "A") - (ebox-text-create :value "B")) - :width '(140) :padding '(0 (10)) :box-sizing sizing)) - (legacy - (ebox-flex - :width '(140) :padding '(0 (10)) :box-sizing sizing - (ebox-text-create :value "A") - (ebox-text-create :value "B"))) - (typed-rendered (ebox-render typed)) - (legacy-rendered (ebox-render legacy))) - (should - (string= - (string-trim-right (substring-no-properties typed-rendered)) - (string-trim-right (substring-no-properties legacy-rendered)))) - (should (= (ebox--string-pixel-width typed-rendered) - (ebox--string-pixel-width legacy-rendered)))))) - (ert-deftest ebox-canonical-grid-box-uses-one-box-identity-and-child-list () "A GridConfig should select the Box algorithm without a Grid runtime node." (let* ((layout (ebox-grid-layout-create)) @@ -573,39 +635,6 @@ (ebox-style-add-rule ".detached-flex-item" (list :grid-row 1)) (let ((ebox--render-root-parent-kind 'flex)) (should-error (ebox-render detached) :type 'error))) - ;; A real legacy adapter exempts only its two exact duplicate nodes. - (let* ((illegal-grandchild (child :flex-grow 1)) - (nested (parent (ebox-normal-layout-create) illegal-grandchild)) - (adapter (ebox-flex-item nested :flex-grow 1 :padding 1))) - ;; The obsolete mutable depth field must have no authority. - (plist-put adapter :ebox-flex-item-adapter-depth 99) - (should-error - (ebox-render (ebox-flex :width '(100) adapter)) - :type 'error)) - ;; Provenance binds the exact constructor-time source identities. - (let* ((adapter (ebox-flex-item (ebox-create :content "original"))) - (replacement (ebox-create :content "replacement"))) - (plist-put replacement :flex-grow 1) - (plist-put adapter :node replacement) - (should-error - (ebox-render (ebox-column adapter)) :type 'error)) - (let* ((adapter (ebox-flex-item (ebox-create :content "original"))) - (source (plist-get adapter :node)) - (replacement (ebox-create :content "replacement"))) - (plist-put replacement :flex-grow 1) - (setq adapter - (ebox-tree-copy-with-direct-child-replacements - adapter (list (cons source replacement)))) - (should-error - (ebox-render (ebox-column adapter)) :type 'error)) - (let* ((adapter - (ebox-flex-item (ebox-create :content "original") :padding 1)) - (wrapper (plist-get adapter :node)) - (replacement (ebox-create :content "replacement"))) - (plist-put replacement :flex-grow 1) - (plist-put wrapper :ebox-content-node replacement) - (should-error - (ebox-render (ebox-column adapter)) :type 'error)) ;; Internal layout and parent participation are orthogonal facts. (should (stringp @@ -632,30 +661,6 @@ (ebox-box-create :layout cross-kind-props :children nil) :type 'error))) -(ert-deftest ebox-canonical-grid-projects-one-box-frame-constraint () - "Typed and legacy Grid should derive the same content size from BoxFrame." - (dolist (sizing '(border-box content-box)) - (let* ((typed - (ebox-box-create - :layout (ebox-grid-layout-create) - :children (list (ebox-text-create :value "A") - (ebox-text-create :value "B")) - :width '(140) :padding '(0 (10)) :box-sizing sizing)) - (legacy - (ebox-grid - :width '(140) :padding '(0 (10)) :box-sizing sizing - (ebox-text-create :value "A") - (ebox-text-create :value "B"))) - (typed-rendered (ebox-render typed)) - (legacy-rendered (ebox-render legacy))) - (should (string= (substring-no-properties typed-rendered) - (substring-no-properties legacy-rendered))) - (should - (equal (mapcar #'ebox--string-pixel-width - (ebox-string-lines typed-rendered)) - (mapcar #'ebox--string-pixel-width - (ebox-string-lines legacy-rendered))))))) - (ert-deftest ebox-grid-layout-create-normalizes-the-complete-config-schema () "The typed Grid constructor should emit one canonical closed property set." (let* ((layout @@ -837,454 +842,126 @@ (ebox-box-create :layout normal :outer nil :children nil) :type 'error))) -(ert-deftest ebox-build-normalizes-text-author-forms () - "The Ebox DSL should normalize explicit and bare Text forms." - (let ((text (ebox-build '(text "A"))) - (bare (ebox-build "B"))) - (should (ebox-text-node-p text)) - (should (ebox-text-node-p bare)) - (should (string= (ebox-dsl-test--plain text) "A")) - (should (string= (ebox-dsl-test--plain bare) "B")))) +(ert-deftest ebox-build-normalizes-the-complete-author-grammar () + "Every public author form should lower to one canonical Text/Box model." + (dolist (case + '(("plain" text nil) + ((text "styled") text nil) + ((box) box normal) + ((row "A" "B") box row) + ((column "A" "B") box column) + ((flex "A" "B") box flex) + ((grid "A" "B") box grid))) + (let* ((form (nth 0 case)) + (kind (nth 1 case)) + (layout-kind (nth 2 case)) + (node (ebox-build form))) + (should (eq (ebox-node-kind node) kind)) + (should (symbolp (ebox-node-source-handle node))) + (should-not (intern-soft + (symbol-name (ebox-node-source-handle node)))) + (when layout-kind + (should + (eq (ebox-layout-config-kind (ebox-box-node-layout node)) + layout-kind))))) + (let ((inline-row (ebox-build '(row :outer inline "A" "B")))) + (should (equal (ebox--computed-display inline-row) '(inline row))))) -(ert-deftest ebox-build-text-requires-one-string-payload () - "The Ebox Text form should reject empty, multiple, and nested payloads." - (should-error (ebox-build '(text)) :type 'error) - (should-error (ebox-build '(text "A" "B")) :type 'error) - (should-error (ebox-build '(text (text "A"))) :type 'error) - (should-error (ebox-build '(text :color "red" "A")) :type 'error) - (should-error (ebox-build '(text :wrap-mode garbage "A")) :type 'error)) +(ert-deftest ebox-build-projects-source-style-without-polluting-canonical-geometry () + "Source metadata and author paint should use their orthogonal projections." + (let* ((form + '(box :key root :id "root" :class "card" + :background-color "red" :width '(80) + (text :color "blue" "A"))) + (original (symbol-function 'ebox-style-compile-declarations)) + (compile-calls 0) + (node + (cl-letf (((symbol-function 'ebox-style-compile-declarations) + (lambda (&rest arguments) + (cl-incf compile-calls) + (apply original arguments)))) + (ebox-build form))) + (child (car (ebox-box-node-children node))) + (rendered (ebox-render node)) + (face (get-text-property 0 'face rendered))) + (should (eq (plist-get node :key) 'root)) + (should (equal (plist-get node :id) "root")) + (should (equal (plist-get node :class) "card")) + (should (eq (plist-get node :host-ref) + (ebox-node-source-handle node))) + (should (eq (plist-get child :host-ref) + (ebox-node-source-handle child))) + (should (= compile-calls 2)) + (should (equal (ebox-get node :bgcolor) nil)) + (should + (cl-some (lambda (entry) + (and (listp entry) + (equal (plist-get entry :background) "red"))) + face)) + (should + (cl-some (lambda (entry) + (and (listp entry) + (equal (plist-get entry :foreground) "blue"))) + face)))) +(ert-deftest ebox-build-gives-equal-forms-distinct-source-identities () + "Structurally equal author forms must not share opaque source identity." + (let* ((node (ebox-build '(row (box "same") (box "same")))) + (children (ebox-box-node-children node))) + (should-not (equal (plist-get (car children) :host-ref) + (plist-get (cadr children) :host-ref))))) -(ert-deftest ebox-dsl-module-preserves-public-build-syntax () - "Extracted DSL compiler should preserve existing ebox-build syntax." - (let ((node (ebox-build - '(column :padding 1 :bgcolor "linen" - (row - (box :content "A" :width (40)) - (box :content "B" :width (40))))))) - (should (eq (plist-get node :ebox-type) 'box)) - (should (string-match-p "A" (ebox-render node))) - (should (string-match-p "B" (ebox-render node))))) +(ert-deftest ebox-build-validates-form-owned-config-and-direct-participation () + "Layout config belongs to its form; item roles belong to direct child Boxes." + (let* ((flex + (ebox-build + '(flex :width '(300) :gap '(1 (5)) + (box :flex 1 "A") + "B"))) + (flex-child (car (ebox-box-node-children flex))) + (grid + (ebox-build + '(grid :grid-template-columns '((20) (20)) + (box :grid-column '(1 :span 2) "Header")))) + (grid-child (car (ebox-box-node-children grid)))) + (should (= (plist-get flex-child :flex-grow) 1)) + (should (equal (plist-get grid-child :grid-column) '(1 :span 2))) + (should (string-match-p "A.*B" + (substring-no-properties (ebox-render flex)))) + (should (string-match-p "Header" + (substring-no-properties (ebox-render grid))))) + (dolist (form + '((box :flex-direction row) + (row :gap 1) + (column :grid-template-columns ((10))) + (flex :grid-template-columns ((10))) + (grid :flex-direction row))) + (should-error (ebox-build form) :type 'error)) + (should-error + (ebox-render (ebox-build '(box :flex-grow 1 "wrong parent"))) + :type 'error)) -(ert-deftest ebox-build-creates-box-from-keyword-plist () - "A `(box ...)' form should compile to a normal Ebox box node." - (let ((node (ebox-build '(box :content "Hello" :width (120) :border t)))) - (should (eq (plist-get node :ebox-type) 'box)) - (should (string-match-p "Hello" (ebox-dsl-test--plain node))))) - -(ert-deftest ebox-build-uses-string-child-as-box-content () - "A simple string child should be shorthand for box content." - (let ((node (ebox-build '(box :width (120) "Hello")))) - (should (eq (plist-get node :ebox-type) 'box)) - (should (string-match-p "Hello" (ebox-dsl-test--plain node))))) - -(ert-deftest ebox-build-resolves-viewport-width-from-dynamic-binding () - "The DSL should allow box widths to follow the current render viewport." - (let* ((ebox-viewport-width 320) - (node (ebox-build - '(box :content "Viewport" - :width (viewport) - :box-sizing border-box - :border t)))) - (should (= (car (ebox-dsl-test--line-widths node)) 320)))) - -(ert-deftest ebox-build-resolves-viewport-height-from-dynamic-binding () - "The DSL should allow box heights to follow the current render viewport." - (let* ((ebox-viewport-height 3) - (node (ebox-build - '(box :content "One\nTwo\nThree\nFour" - :height (viewport-height) - :overflow scroll)))) - (should (= (length (ebox-string-lines (ebox-render node))) 3)))) - -(ert-deftest ebox-build-resolves-viewport-height-arithmetic-expression () - "The DSL should allow viewport-relative height expressions." - (let* ((ebox-viewport-height 6) - (node (ebox-build - '(box :content "One\nTwo\nThree\nFour" - :height (- (viewport-height) 2) - :overflow scroll)))) - (should (= (length (ebox-string-lines (ebox-render node))) 4)))) - -(ert-deftest ebox-build-unquotes-static-list-property-values () - "The DSL should accept quoted list values used by `etaf-view' forms." - (let* ((node (ebox-build - '(grid :width '(120) - :grid-template-columns '((40) 1fr) - :gap '(0 (4)) - (box :content "A") - (box :content "B")))) - (props (plist-get node :raw-props)) - (line (car (ebox-string-lines (ebox-render node))))) - (should (equal (plist-get props :width) '(120))) - (should (equal (plist-get props :grid-template-columns) - '((40) 1fr))) - (should (= (ebox--string-pixel-width line) 120)))) - -(ert-deftest ebox-build-composes-fixed-viewport-shell-around-scrollbox () - "A fixed viewport shell can reserve chrome while an inner box scrolls." - (let* ((ebox-viewport-height 6) - (node (ebox-build - '(column :height (viewport-height) - (box :content "top" :height 1) - (box :id "root" :height (- (viewport-height) 2) - :overflow scroll - (column - (box :content "A" :height 1) - (box :content "B" :height 1) - (box :content "C" :height 1) - (box :content "D" :height 1) - (box :content "E" :height 1))) - (box :content "bottom" :height 1))))) - (should (= (length (ebox-string-lines (ebox-render node))) 6)))) - -(ert-deftest ebox-build-composes-row-and-column-layouts () - "Row and column forms should compose existing lazy layout nodes." - (let* ((node (ebox-build - '(column - (box :content "Header" :width (180)) - (row - (box :content "Left" :width (80)) - (spacer :width (20)) - (box :content "Right" :width (80)))))) - (plain (ebox-dsl-test--plain node))) - (should (eq (plist-get node :ebox-type) 'stack)) - (should (string-match-p "Header" plain)) - (should (string-match-p "Left.*Right" plain)) - (should (= (length (ebox-region-ids node)) 4)))) - -(ert-deftest ebox-build-wraps-child-layout-with-box-properties () - "A node with child layout may still apply box properties to the wrapper." - (let* ((node (ebox-build - '(box :width (240) :box-sizing border-box - :padding (1 20) :border t :bgcolor "#222222" - (row - (box :content "A" :width (80)) - (box :content "B" :width (80)))))) - (plain (ebox-dsl-test--plain node))) - (should (eq (plist-get node :ebox-type) 'box)) - (should (equal (ebox-get node :bgcolor) "#222222")) - (should (string-match-p "A.*B" plain)) - (dolist (width (ebox-dsl-test--line-widths node)) - (should (= width 240))))) - -(ert-deftest ebox-build-box-wrapper-preserves-preformatted-child-layout () - "A box wrapping child layout should not rewrap rendered children." - (cl-letf (((symbol-function 'require) - (lambda (feature &optional _filename _noerror) - (when (eq feature 'ekp) t))) - ((symbol-function 'ekp-pixel-justify) - (lambda (text _width) - (if (string-match-p "\n" text) - "REWRAPPED" - text)))) - (let* ((node (ebox-build - '(box :width (220) :box-sizing border-box - (row - (box :content "Left" :width (80) :height 2) - (box :content "Right" :width (80) :height 2))))) - (plain (ebox-dsl-test--plain node))) - (should-not (string-match-p "REWRAPPED" plain)) - (should (string-match-p "Left.*Right" plain)) - (dolist (width (ebox-dsl-test--line-widths node)) - (should (= width 220)))))) - -(ert-deftest ebox-build-row-wrapper-preserves-preformatted-layout () - "Row box properties should not rewrap the rendered child layout." - (cl-letf (((symbol-function 'require) - (lambda (feature &optional _filename _noerror) - (when (eq feature 'ekp) t))) - ((symbol-function 'ekp-pixel-justify) - (lambda (text _width) - (if (string-match-p "\n" text) - "REWRAPPED" - text)))) - (let* ((node (ebox-build - '(row :width (220) :box-sizing border-box - (box :content "Left" :width (80) :height 2) - (box :content "Right" :width (80) :height 2)))) - (plain (ebox-dsl-test--plain node))) - (should-not (string-match-p "REWRAPPED" plain)) - (should (string-match-p "Left.*Right" plain)) - (dolist (width (ebox-dsl-test--line-widths node)) - (should (= width 220)))))) - -(ert-deftest ebox-build-column-wrapper-preserves-preformatted-layout () - "Column box properties should not rewrap the rendered child layout." - (cl-letf (((symbol-function 'require) - (lambda (feature &optional _filename _noerror) - (when (eq feature 'ekp) t))) - ((symbol-function 'ekp-pixel-justify) - (lambda (text _width) - (if (string-match-p "\n" text) - "REWRAPPED" - text)))) - (let* ((node (ebox-build - '(column :width (180) :box-sizing border-box - (box :content "Top" :width (120)) - (box :content "Bottom" :width (120))))) - (plain (ebox-dsl-test--plain node))) - (should-not (string-match-p "REWRAPPED" plain)) - (should (string-match-p "Top" plain)) - (should (string-match-p "Bottom" plain)) - (dolist (width (ebox-dsl-test--line-widths node)) - (should (= width 180)))))) - -(ert-deftest ebox-build-padded-column-wrapper-stays-inside-viewport () - "A padded layout wrapper should not let preformatted children overflow." - (let* ((ebox-viewport-width 320) - (node (ebox-build - '(column :padding 1 - (box :content "Child width follows the viewport" - :wrap-mode word)))) - (widths (ebox-dsl-test--display-line-widths node))) - (should (cl-every (lambda (width) - (<= width ebox-viewport-width)) - widths)))) - -(ert-deftest ebox-build-wrapper-explicit-width-bounds-child-layout-viewport () - "A wrapper's explicit width should be the containing block for child layouts." - (let* ((ebox-viewport-width 360) - (node (ebox-build - '(column :width (180) - (box :content "alpha beta gamma delta epsilon zeta eta theta" - :wrap-mode word)))) - (widths (ebox-dsl-test--line-widths node))) - (dolist (width widths) - (should (<= width 180))))) - -(ert-deftest ebox-build-column-contains-auto-row-children () - "A column wrapper must not let an auto-width row child duplicate its viewport." - (let* ((node (ebox-build - '(column :width (120) - (row - (box :content "Left" :border t) - (box :content "Right" :border t)) - (box :content "Body" :border t)))) - (widths (ebox-dsl-test--line-widths node))) - (should (cl-every (lambda (width) (<= width 120)) widths)))) - -(ert-deftest ebox-row-keeps-explicit-viewport-widths-intrinsic-context () - "Row intrinsic sizing must not change explicit viewport-sized children." - (let* ((ebox-viewport-width 120) - (node (ebox-row - (ebox-create :content "Left" :width 'viewport) - (ebox-create :content "Right" :width 'stretch))) - (widths (ebox-dsl-test--line-widths node))) - (should (equal widths '(240))))) - -(ert-deftest ebox-row-keeps-composite-column-intrinsic-width () - "A composite column child must not consume the row viewport as auto width." - (let* ((ebox-viewport-width 120) - (node (ebox-row - (ebox-column - (ebox-create :content "Top") - (ebox-create :content "Bottom")) - (ebox-create :content "Tail"))) - (widths (ebox-dsl-test--line-widths node))) - (should (cl-every (lambda (width) (<= width 120)) widths)))) - -(ert-deftest ebox-build-lazy-column-contains-auto-row-children () - "Lazy column windows must apply the same intrinsic row sizing rule." - (let* ((ebox-viewport-width 120) - (ebox-viewport-height 4) - (node (ebox-build - '(box :width (viewport) - :height (viewport-height) - :overflow scroll - (column - (row - (box :content "Left" :border t) - (box :content "Right" :border t)) - (box :content "Body" :border t))))) - (widths (ebox-dsl-test--line-widths node))) - (should (cl-every (lambda (width) (<= width 120)) widths)))) - -(ert-deftest ebox-build-wrapper-viewport-width-bounds-child-layout-viewport () - "A wrapper's `(viewport)' width should pass its content box to child layouts." - (let ((cases - '((box :width (viewport) - :padding-inline (20) - :border-left (10) - :border-right (10) - (box :content "alpha beta gamma delta epsilon zeta eta theta" - :wrap-mode word)) - (column :width (viewport) - :padding-inline (20) - :border-left (10) - :border-right (10) - (box :content "alpha beta gamma delta epsilon zeta eta theta" - :wrap-mode word)) - (row :width (viewport) - :padding-inline (20) - :border-left (10) - :border-right (10) - (box :content "alpha beta gamma delta epsilon zeta eta theta" - :wrap-mode word) - (spacer :width (0) :height 1))))) - (dolist (dsl cases) - (let* ((ebox-viewport-width 240) - (node (ebox-build dsl)) - (widths (ebox-dsl-test--line-widths node))) - (dolist (width widths) - (should (<= width ebox-viewport-width))))))) - -(ert-deftest ebox-build-lazy-scroll-prefix-bounds-wrapper-child-viewport () - "Lazy scroll prefix rendering should preserve wrapper content viewport." - (let* ((ebox-viewport-width 320) - (ebox-viewport-height 5) - (node (ebox-build - '(box :id "root" - :width (viewport) - :height (- (viewport-height) 1) - :box-sizing border-box - :overflow scroll - (column :width (viewport) - :box-sizing border-box - :padding (1 (18)) - (box :content "One" - :width (viewport) - :box-sizing border-box - :border t) - (box :content "Two" - :width (viewport) - :box-sizing border-box - :border t) - (box :content "Three" - :width (viewport) - :box-sizing border-box - :border t) - (box :content "Four" - :width (viewport) - :box-sizing border-box - :border t))))) - (widths (ebox-dsl-test--line-widths node))) - (dolist (width widths) - (should (<= width ebox-viewport-width))))) - -(ert-deftest ebox-build-tags-map-to-css-like-display () - "DSL container tags should expose CSS-like outer/inner display values." - (let ((box-node (ebox-build '(box :content "A"))) - (row-node (ebox-build '(row (box :content "A") (box :content "B")))) - (column-node (ebox-build - '(column (box :content "A") (box :content "B")))) - (flex-node (ebox-build '(flex (box :content "A") (box :content "B"))))) - (should (equal (ebox--computed-display box-node) '(block flow))) - (should (equal (ebox--computed-display row-node) '(block row))) - (should (equal (ebox--computed-display column-node) '(block column))) - (should (equal (ebox--computed-display flex-node) '(block flex))))) - -(ert-deftest ebox-build-box-uses-orthogonal-outer-and-layout-axes () - "Canonical Box DSL should select outer participation and child layout." - (let ((normal (ebox-build '(box :outer inline :layout normal "A"))) - (row (ebox-build - '(box :outer inline :layout row - (box "A") - (box "B")))) - (column (ebox-build - '(box :outer block :layout column - (box "A") - (box "B"))))) - (should (equal (ebox--computed-display normal) '(inline flow))) - (should (equal (ebox--computed-display row) '(inline row))) - (should (equal (ebox--computed-display column) '(block column))) - (should (string= (ebox-dsl-test--plain row) "AB")) - (should (string= (ebox-dsl-test--plain column) "A\nB")))) - -(ert-deftest ebox-build-box-rejects-invalid-axis-values () - "Canonical Box DSL should reject unknown outer and layout values." - (should-error (ebox-build '(box :outer floating "A")) :type 'error) - (should-error (ebox-build '(box :layout masonry "A")) :type 'error)) - -(ert-deftest ebox-build-box-layout-consumes-direct-flex-item-properties () - "Canonical Flex Box should consume participation props from child Boxes." - (let* ((node (ebox-build - '(box :layout flex :width (300) - (box :flex-grow 1 :flex-basis (80) "A") - (box :flex-grow 2 :flex-basis (80) "B")))) - (children (plist-get node :children))) - (should (equal (ebox--computed-display node) '(block flex))) - (should (= (plist-get (car children) :flex-grow) 1)) - (should (= (plist-get (cadr children) :flex-grow) 2)) - (should (string-match-p "A" (ebox-dsl-test--plain node))) - (should (string-match-p "B" (ebox-dsl-test--plain node))))) - -(ert-deftest ebox-build-box-layout-consumes-direct-grid-item-properties () - "Canonical Grid Box should consume placement props from child Boxes." - (let* ((node (ebox-build - '(box :layout grid :grid-template-columns ((20) (20)) - (box :grid-column (1 :span 2) "Header")))) - (child (car (plist-get node :children)))) - (should (equal (ebox--computed-display node) '(block grid))) - (should (equal (plist-get child :grid-column) '(1 :span 2))) - (should (string-match-p "Header" (ebox-dsl-test--plain node))))) - -(ert-deftest ebox-build-wrapper-child-layout-rerenders-on-runtime-viewport-change () - "A DSL wrapper box should rerender child layout content on viewport resize." - (let ((node (let ((ebox-viewport-width 96)) - (ebox-build - '(column :padding 1 :bgcolor "linen" - (box :content "This child text should wrap again when the preview becomes narrow." - :wrap-mode word))))) - buffer) - (unwind-protect - (progn - (setq buffer - (let ((ebox-viewport-width 96)) - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-dsl-test*") - node))) - (let ((wide-text (with-current-buffer buffer - (buffer-substring-no-properties - (point-min) (point-max)))) - (wide-height (with-current-buffer buffer - (count-lines (point-min) (point-max))))) - (ebox-rerender-buffer-with-context buffer 36) - (let ((narrow-text (with-current-buffer buffer - (buffer-substring-no-properties - (point-min) (point-max)))) - (narrow-height (with-current-buffer buffer - (count-lines (point-min) (point-max))))) - (should-not (string= wide-text narrow-text)) - (should (> narrow-height wide-height))))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - -(ert-deftest ebox-build-item-is-flex-participation-sugar () - "DSL item should attach flex participation to its child semantics." - (let* ((node (ebox-build - '(flex :width (300) - (item :flex 1 (box :content "A")) - (box :content "B" :flex 1)))) - (rendered (let ((ebox-viewport-width 300)) - (ebox-render node)))) - (should (string-match-p "A" (substring-no-properties rendered))) - (should (string-match-p "B" (substring-no-properties rendered))))) - -(ert-deftest ebox-build-keeps-pre-render-region-ids-available () - "The DSL should not break the pre-render region-id workflow." - (let* ((node (ebox-build - '(row - (box :content "One" :width (80)) - (box :content "Two" :width (80))))) - (ids (ebox-region-ids node))) - (should (= (length ids) 2)) - (should (cl-every #'identity ids)))) - -(ert-deftest ebox-build-wrapper-region-ids-include-child-layout () - "A wrapper box should keep both its own region id and child layout ids." - (let* ((node (ebox-build - '(column :padding 1 - (box :content "One") - (box :content "Two")))) - (ids (ebox-region-ids node))) - (should (= (length ids) 3)) - (should (cl-every #'identity ids)))) - -(ert-deftest ebox-build-rejects-invalid-dsl () - "Bad forms should fail loudly instead of producing surprising nodes." - (should-error (ebox-build '(unknown :content "Nope")) :type 'error) - (should-error (ebox-build '(box :content)) :type 'error) - (should-error (ebox-build '(spacer :width (20) (box :content "bad"))) :type 'error)) +(ert-deftest ebox-build-rejects-removed-and-private-author-syntax () + "The author boundary should have one grammar and no runtime passthrough." + (dolist (form + '((ebox "A") + (spacer :width (20)) + (item (box "A")) + (grid-item (box "A")) + (box :content "A") + (box :layout flex "A") + (box :display (block flex) "A") + (box :ebox-type box "A") + (box :ebox-content-node nil) + (text) + (text "A" "B") + (text (box "A")) + (text :width (10) "A") + (row :item-gap -1) + (unknown "A"))) + (should-error (ebox-build form) :type 'error)) + (should-error + (ebox-build (ebox-test-box :content "raw runtime node")) + :type 'error)) (provide 'ebox-dsl-tests) ;;; ebox-dsl-tests.el ends here diff --git a/tests/ebox-fixtures.el b/tests/ebox-fixtures.el new file mode 100644 index 0000000..b48779d --- /dev/null +++ b/tests/ebox-fixtures.el @@ -0,0 +1,253 @@ +;;; ebox-fixtures.el --- Typed Ebox test fixtures -*- lexical-binding: t; -*- + +;;; Commentary: +;; Test-only construction helpers. They keep retained layout, publication, +;; and rollback tests on the canonical Text/Box/LayoutConfig model without +;; reintroducing removed public constructors. + +;;; Code: + +(require 'cl-lib) +(require 'ebox) + +(defconst ebox-test--source-fields + '(:source-handle :key :class :id) + "Canonical source fields accepted by typed fixture nodes.") + +(defconst ebox-test--direct-fields + '(:region-id :selector-state :selector-attributes :surface-properties + :scroll-offset :ebox-scroll-offset-controlled-p) + "Private runtime fields used only by focused backend tests.") + +(defconst ebox-test--non-style-fields + (append '(:source-handle :host-ref :key :class :id + :outer :content :ebox-content-node + :item-gap :cross-align) + ebox-test--direct-fields) + "Fixture fields that do not enter the canonical Ebox style schema.") + +(defun ebox-test--keep (plist keys) + "Return PLIST entries whose keys occur in KEYS." + (cl-loop for (key value) on plist by #'cddr + when (memq key keys) append (list key value))) + +(defun ebox-test--split-items (items) + "Return `(PROPERTIES . CHILDREN)' from evaluated fixture ITEMS." + (let (properties children) + (while items + (let ((item (pop items))) + (if (keywordp item) + (progn + (unless items (error "Missing fixture value for %S" item)) + (setq properties + (append properties (list item (pop items))))) + (push item children)))) + (cons properties (nreverse children)))) + +(defun ebox-test--without (plist keys) + "Return PLIST without entries whose keys occur in KEYS." + (cl-loop for (key value) on plist by #'cddr + unless (memq key keys) append (list key value))) + +(defun ebox-test--unquote (value) + "Return VALUE without one inert source-level quote wrapper." + (if (and (consp value) + (eq (car value) 'quote) + (null (cddr value))) + (cadr value) + value)) + +(defun ebox-test--normalize-properties (props) + "Return fixture PROPS with source literals normalized. +The typed author boundary spells explicit no-wrap as `none'; old backend +fixtures used nil for the same engine condition." + (cl-loop for (key value) on props by #'cddr + append (list key + (if (and (eq key :wrap-mode) (null value)) + 'none + (ebox-test--unquote value))))) + +(defun ebox-test--source-properties (props) + "Return typed source metadata projected from fixture PROPS." + (let ((source (ebox-test--keep props '(:key :class :id)))) + (when-let* ((handle (or (plist-get props :source-handle) + (plist-get props :host-ref)))) + (setq source (append source (list :source-handle handle)))) + source)) + +(defun ebox-test--declarations (tag props) + "Return canonical TAG declarations from evaluated fixture PROPS." + (ebox-style-compile-form + tag (ebox-test--without props ebox-test--non-style-fields))) + +(defun ebox-test--layout (tag props declarations) + "Return TAG LayoutConfig from PROPS and DECLARATIONS." + (ebox-layout-config-for-form + tag + (if (memq tag '(row column)) + (ebox-test--keep props '(:item-gap :cross-align)) + (ebox-style-declaration-properties + declarations + (lambda (property) + (memq (plist-get property :name) + (ebox-layout-config-property-names tag))))))) + +(defun ebox-test--text (value &optional props) + "Return one canonical Text fixture for VALUE and author PROPS." + (let ((declarations (ebox-test--declarations 'text props))) + (apply #'ebox-text-create + (append (list :value value :declarations declarations) + (ebox-test--source-properties props))))) + +(defun ebox-test--box-node (tag props children) + "Return canonical Box form TAG from PROPS and CHILDREN." + (let* ((props (ebox-test--normalize-properties props)) + (declarations (ebox-test--declarations tag props)) + (node + (apply #'ebox-box-create + (append + (list :layout (ebox-test--layout tag props declarations) + :outer (or (plist-get props :outer) 'block) + :children children + :declarations declarations) + (ebox-test--source-properties props))))) + (dolist (key ebox-test--direct-fields) + (when (plist-member props key) + (plist-put node key (copy-tree (plist-get props key))))) + node)) + +(defun ebox-test-box (&rest props) + "Return one typed Normal Box fixture from evaluated PROPS." + (let* ((content-present-p (plist-member props :content)) + (content (plist-get props :content)) + (child (plist-get props :ebox-content-node)) + (children + (cond + (child (list child)) + (content-present-p + (if (stringp content) + (list (ebox-test--text content)) + (error "Fixture :content must be a string: %S" content))) + (t nil)))) + (ebox-test--box-node 'box props children))) + +(defun ebox-test-row (&rest items) + "Return one typed Row fixture from evaluated ITEMS." + (pcase-let ((`(,props . ,children) (ebox-test--split-items items))) + (ebox-test--box-node 'row props (delq nil children)))) + +(defun ebox-test-column (&rest items) + "Return one typed Column fixture from evaluated ITEMS." + (pcase-let ((`(,props . ,children) (ebox-test--split-items items))) + (ebox-test--box-node 'column props (delq nil children)))) + +(defun ebox-test-concat (left right) + "Return a two-child typed Row fixture." + (ebox-test-row left right)) + +(defun ebox-test-stack (top bottom) + "Return a two-child typed Column fixture." + (ebox-test-column top bottom)) + +(defun ebox-test-spacer (&rest props) + "Return an empty typed Normal Box fixture." + (apply #'ebox-test-box props)) + +(defun ebox-test-flex (&rest items) + "Return one typed Flex fixture from evaluated ITEMS." + (pcase-let ((`(,props . ,children) (ebox-test--split-items items))) + (ebox-test--box-node 'flex props (delq nil children)))) + +(defun ebox-test-grid (&rest items) + "Return one typed Grid fixture from evaluated ITEMS." + (pcase-let ((`(,props . ,children) (ebox-test--split-items items))) + (ebox-test--box-node 'grid props (delq nil children)))) + +(defun ebox-test-grid-fr (factor) + "Return one fractional Grid track fixture." + (list 'fr factor)) + +(defun ebox-test-flex-item (node &rest props) + "Return NODE with direct Flex participation and Box properties from PROPS." + (setq props (ebox-test--normalize-properties props)) + (unless (ebox-box-node-p node) + (setq node (ebox-test--box-node 'box nil (list node)))) + (let* ((layout (ebox-box-node-layout node)) + (children (ebox-box-node-children node)) + (base (ebox-style-node-declarations node)) + (extra (ebox-test--declarations 'box props)) + (copy + (apply #'ebox-box-create + (append + (list :layout layout :children children + :outer (ebox-tree-display-outer node) + :declarations + (ebox-style-merge-declarations base extra)) + (ebox-test--source-properties node))))) + copy)) + +(defun ebox-test--build-item (items) + "Return one typed direct-child Box from old backend fixture ITEMS. +`item' never reaches production input; this test helper only projects its +participation properties onto the material child being exercised." + (pcase-let* ((`(,props . ,children) (ebox-test--split-items items)) + (props (ebox-test--normalize-properties props))) + (unless (= (length children) 1) + (error "Ebox test item requires exactly one child: %S" items)) + (apply #'ebox-test-flex-item + (ebox-test-build (car children)) props))) + +(defun ebox-test--build-box-form (tag items) + "Return one typed TAG Box fixture from old backend fixture ITEMS." + (pcase-let* ((`(,props . ,child-forms) (ebox-test--split-items items)) + (props (ebox-test--normalize-properties props)) + (content-present-p (plist-member props :content)) + (content (plist-get props :content)) + (content-node (plist-get props :ebox-content-node)) + (props (ebox-test--without + props '(:content :ebox-content-node))) + (children (mapcar #'ebox-test-build child-forms))) + (when content-node + (setq children (append children (list content-node)))) + (when content-present-p + (unless (stringp content) + (error "Ebox test :content must be a string: %S" content)) + (setq children (append children (list (ebox-test--text content))))) + (ebox-test--box-node tag props children))) + +(defun ebox-test-build (form) + "Build canonical typed nodes from one backend test FORM. + +This is deliberately not an author DSL. It isolates retained-layout and +publication tests from source parsing while their compact historical fixture +data is migrated. Production `ebox-build' remains strict and never accepts +`:content', `item', `concat', `stack', or `spacer'." + (cond + ((or (ebox-text-node-p form) (ebox-box-node-p form)) form) + ((stringp form) (ebox-test--text form)) + ((not (and (consp form) (symbolp (car form)))) + (error "Invalid Ebox typed test form: %S" form)) + ((eq (car form) 'text) + (pcase-let* ((`(,props . ,payloads) + (ebox-test--split-items (cdr form))) + (props (ebox-test--normalize-properties props))) + (unless (and (= (length payloads) 1) + (stringp (car payloads))) + (error "Ebox test text requires exactly one string payload: %S" form)) + (ebox-test--text (car payloads) props))) + ((memq (car form) '(box row column flex grid)) + (ebox-test--build-box-form (car form) (cdr form))) + ((eq (car form) 'item) + (ebox-test--build-item (cdr form))) + ((eq (car form) 'concat) + (ebox-test--build-box-form 'row (cdr form))) + ((eq (car form) 'stack) + (ebox-test--build-box-form 'column (cdr form))) + ((eq (car form) 'spacer) + (ebox-test--build-box-form 'box (cdr form))) + (t + (error "Unknown Ebox typed test form: %S" (car form))))) + +(provide 'ebox-fixtures) + +;;; ebox-fixtures.el ends here diff --git a/tests/ebox-flex-tests.el b/tests/ebox-flex-tests.el index 1c48e04..c11200c 100644 --- a/tests/ebox-flex-tests.el +++ b/tests/ebox-flex-tests.el @@ -41,12 +41,34 @@ "Return max content span width for REGION-ID in STRING." (ebox-flex-test--region-property-width string region-id 'ebox-content)) +(defun ebox-flex-test--region-owner-width (string region-id) + "Return max rendered owner span width for REGION-ID in STRING." + (let ((pos 0) + (len (length string)) + (current-width 0) + (max-width 0)) + (while (< pos len) + (let* ((owners (get-text-property pos 'ebox-content-owners string)) + (end (or (next-single-property-change + pos 'ebox-content-owners string len) + len))) + (if (memq region-id owners) + (setq current-width + (+ current-width + (string-pixel-width (substring string pos end)))) + (setq max-width (max max-width current-width) + current-width 0)) + (setq pos end))) + (max max-width current-width))) + (defun ebox-flex-test--content-widths (node) - "Return max content widths for NODE region ids." - (let* ((ids (ebox-region-ids node)) + "Return max content widths for NODE's direct material children." + (let* ((ids (mapcar (lambda (child) + (car (ebox-region-ids child))) + (ebox-box-node-children node))) (rendered (ebox-flex-test--render node))) (mapcar (lambda (id) - (ebox-flex-test--region-content-width rendered id)) + (ebox-flex-test--region-owner-width rendered id)) ids))) (defun ebox-flex-test--face-has-key-value-p (face key value) @@ -105,23 +127,29 @@ "Extracted flex code should preserve item participation semantics." (let* ((node (ebox-build '(flex :width (240) - (item :flex-grow 1 - (box :content "Grow" :width (40))) - (item (box :content "Fixed" :width (40)))))) + (box :flex-grow 1 :width (40) "Grow") + (box :width (40) "Fixed")))) (rendered (ebox-render node))) (should (string-match-p "Grow" rendered)) (should (string-match-p "Fixed" rendered)))) +(ert-deftest ebox-flex-measures-direct-text-without-box-geometry () + "A canonical Text child should participate as text, not a geometry Box." + (let* ((node (ebox-build '(flex :width (120) "Direct text"))) + (rendered (ebox-render node))) + (should (string-match-p "Direct text" + (substring-no-properties rendered))) + (should (= (ebox--string-max-pixel-width rendered) 120)))) + (ert-deftest ebox-flex-item-clears-disappeared-ecss-props-on-region-update () "A region update should clear removed rule props but retain inline item props." (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) (layout - (ebox-flex - (ebox-flex-item - (ebox-create :id "rule" :class 'grow :content "Rule" :width '(40))) - (ebox-flex-item - (ebox-create :id "inline" :content "Inline" :width '(40)) - :flex-grow 2))) + (ebox-test-flex + (ebox-test-box :id "rule" :class 'grow + :content "Rule" :width '(40)) + (ebox-test-box :id "inline" :content "Inline" :width '(40) + :flex-grow 2))) (buffer (generate-new-buffer " *ebox-flex-style-reset*"))) (unwind-protect (progn @@ -130,25 +158,25 @@ (let ((items (plist-get (plist-get (ebox--buffer-render-state buffer) :root-node) :children))) - (should (= (plist-get (plist-get (car items) :props) :flex-grow) 1)) - (should (= (plist-get (plist-get (cadr items) :props) :flex-grow) 2))) + (should (= (ebox-get (car items) :flex-grow) 1)) + (should (= (ebox-get (cadr items) :flex-grow) 2))) (ebox-style-reset-rules) (ebox-region-update (ebox-region-resolve buffer "rule") - :content "Next") + :width '(41)) (let ((items (plist-get (plist-get (ebox--buffer-render-state buffer) :root-node) :children))) - (should-not (plist-member (plist-get (car items) :props) :flex-grow)) - (should (= (plist-get (plist-get (cadr items) :props) :flex-grow) 2)))) + (should-not (ebox-get (car items) :flex-grow)) + (should (= (ebox-get (cadr items) :flex-grow) 2)))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-flex-accepts-flex-props-on-direct-child-box () "Flex item participation props may live directly on child boxes." - (let* ((layout (ebox-flex + (let* ((layout (ebox-test-flex :width 300 - (ebox-create :content "A" :flex-grow 1 :flex-basis '(80)) - (ebox-create :content "B" :flex-grow 1 :flex-basis '(80)))) + (ebox-test-box :content "A" :flex-grow 1 :flex-basis '(80)) + (ebox-test-box :content "B" :flex-grow 1 :flex-basis '(80)))) (rendered (let ((ebox-viewport-width 300)) (ebox-render layout))) (widths (mapcar #'ebox--string-pixel-width @@ -160,12 +188,12 @@ (ert-deftest ebox-flex-grows-items-to-fill-row-container () "Flex grow should distribute extra row width by grow factors." (let* ((layout - (ebox-flex :width '(300) - (ebox-flex-item - (ebox-create :content "A" :width '(80)) + (ebox-test-flex :width '(300) + (ebox-test-flex-item + (ebox-test-box :content "A" :width '(80)) :flex-grow 1 :flex-basis '(80)) - (ebox-flex-item - (ebox-create :content "B" :width '(80)) + (ebox-test-flex-item + (ebox-test-box :content "B" :width '(80)) :flex-grow 2 :flex-basis '(80)))) (widths (ebox-flex-test--content-widths layout))) (should (= (length widths) 2)) @@ -176,8 +204,8 @@ (ert-deftest ebox-flex-margin-box-target-subtracts-inline-margins-for-border-box () "A row flex target should include a border-box item's inline margins." (let ((layout - (ebox-flex :width '(200) :align-items 'flex-start - (ebox-create :content "A" + (ebox-test-flex :width '(200) :align-items 'flex-start + (ebox-test-box :content "A" :width '(40) :box-sizing 'border-box :margin-left '(20) @@ -188,10 +216,10 @@ (ert-deftest ebox-flex-margin-box-target-subtracts-block-margins-for-border-box () "A column flex target should include a border-box item's block margins." (let ((layout - (ebox-flex :width '(80) :height 6 + (ebox-test-flex :width '(80) :height 6 :flex-direction 'column :align-items 'flex-start - (ebox-create :content "A" + (ebox-test-box :content "A" :width '(40) :height 1 :box-sizing 'border-box @@ -203,8 +231,8 @@ (ert-deftest ebox-flex-margin-box-target-preserves-content-box-outer-sides () "Content-box flex targets should subtract padding, borders, and margins." (let ((layout - (ebox-flex :width '(200) :align-items 'flex-start - (ebox-create :content "A" + (ebox-test-flex :width '(200) :align-items 'flex-start + (ebox-test-box :content "A" :width '(40) :box-sizing 'content-box :padding-left '(10) @@ -216,31 +244,15 @@ :flex-grow 1)))) (should (equal (ebox-flex-test--line-widths layout) '(200))))) -(ert-deftest ebox-flex-row-render-uses-sized-line-concat () - "Row flex rendering should not remeasure known-width line parts." - (let* ((layout - (ebox-flex :width '(360) - (ebox-create :content "Alpha" :width '(80) :height 2) - (ebox-create :content "Beta" :width '(80) :height 2) - (ebox-create :content "Gamma" :width '(80) :height 2))) - (concat-calls 0) - (original (symbol-function 'ebox--lines-concat-horizontal))) - (cl-letf (((symbol-function 'ebox--lines-concat-horizontal) - (lambda (&rest args) - (cl-incf concat-calls) - (apply original args)))) - (should (equal (ebox-flex-test--line-widths layout) '(360 360)))) - (should (= concat-calls 0)))) - (ert-deftest ebox-flex-shrinks-items-by-shrink-factors () "Flex shrink should remove more width from larger shrink factors." (let* ((layout - (ebox-flex :width '(150) - (ebox-flex-item - (ebox-create :content "A" :width '(100)) + (ebox-test-flex :width '(150) + (ebox-test-flex-item + (ebox-test-box :content "A" :width '(100)) :flex-shrink 1 :flex-basis '(100)) - (ebox-flex-item - (ebox-create :content "B" :width '(100)) + (ebox-test-flex-item + (ebox-test-box :content "B" :width '(100)) :flex-shrink 3 :flex-basis '(100)))) (widths (ebox-flex-test--content-widths layout))) (should (= (length widths) 2)) @@ -253,12 +265,12 @@ (max-content (ebox--string-pixel-width content)) (min-content (ebox--string-pixel-width "bbbb")) (layout - (ebox-flex :width '(300) - (ebox-flex-item - (ebox-create :content content :width '(160) :wrap-mode 'word) + (ebox-test-flex :width '(300) + (ebox-test-flex-item + (ebox-test-box :content content :width '(160) :wrap-mode 'word) :flex-basis 'content) - (ebox-flex-item - (ebox-create :content content :width '(160) :wrap-mode 'word) + (ebox-test-flex-item + (ebox-test-box :content content :width '(160) :wrap-mode 'word) :flex-basis 'min-content))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths (list max-content min-content))))) @@ -266,12 +278,12 @@ (ert-deftest ebox-flex-grow-redistributes-space-after-max-width-clamp () "A max-width-clamped item should freeze and leave space for flexible siblings." (let* ((layout - (ebox-flex :width '(200) - (ebox-flex-item - (ebox-create :content "A" :width '(60) :max-width '(80)) + (ebox-test-flex :width '(200) + (ebox-test-flex-item + (ebox-test-box :content "A" :width '(60) :max-width '(80)) :flex-grow 1 :flex-basis '(60)) - (ebox-flex-item - (ebox-create :content "B" :width '(60)) + (ebox-test-flex-item + (ebox-test-box :content "B" :width '(60)) :flex-grow 1 :flex-basis '(60)))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(80 120))) @@ -280,12 +292,12 @@ (ert-deftest ebox-flex-grow-starts-from-flex-base-before-min-width-clamp () "Grow distribution should start from flex base sizes, not hypothetical sizes." (let* ((layout - (ebox-flex :width '(200) - (ebox-flex-item - (ebox-create :content "A" :width '(50) :min-width '(80)) + (ebox-test-flex :width '(200) + (ebox-test-flex-item + (ebox-test-box :content "A" :width '(50) :min-width '(80)) :flex-grow 1 :flex-basis '(50)) - (ebox-flex-item - (ebox-create :content "B" :width '(50)) + (ebox-test-flex-item + (ebox-test-box :content "B" :width '(50)) :flex-grow 1 :flex-basis '(50)))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(100 100))) @@ -294,12 +306,12 @@ (ert-deftest ebox-flex-grow-factors-below-one-partially-fill-free-space () "Grow factors whose sum is below 1 should leave some free space unclaimed." (let* ((layout - (ebox-flex :width '(300) - (ebox-flex-item - (ebox-create :content "A" :width '(50)) + (ebox-test-flex :width '(300) + (ebox-test-flex-item + (ebox-test-box :content "A" :width '(50)) :flex-grow 0.25) - (ebox-flex-item - (ebox-create :content "B" :width '(50)) + (ebox-test-flex-item + (ebox-test-box :content "B" :width '(50)) :flex-grow 0.25))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(100 100))) @@ -308,12 +320,12 @@ (ert-deftest ebox-flex-shrink-redistributes-space-after-min-width-clamp () "A min-width-clamped item should freeze and push shrinkage to siblings." (let* ((layout - (ebox-flex :width '(150) - (ebox-flex-item - (ebox-create :content "A" :width '(100) :min-width '(80)) + (ebox-test-flex :width '(150) + (ebox-test-flex-item + (ebox-test-box :content "A" :width '(100) :min-width '(80)) :flex-shrink 1 :flex-basis '(100)) - (ebox-flex-item - (ebox-create :content "B" :width '(100)) + (ebox-test-flex-item + (ebox-test-box :content "B" :width '(100)) :flex-shrink 1 :flex-basis '(100)))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(80 70))) @@ -322,13 +334,13 @@ (ert-deftest ebox-flex-shrink-starts-from-flex-base-before-max-width-clamp () "Shrink distribution should start from flex base sizes, not hypothetical sizes." (let* ((layout - (ebox-flex :width '(150) - (ebox-flex-item - (ebox-create :content "A" :width '(100) :max-width '(80) + (ebox-test-flex :width '(150) + (ebox-test-flex-item + (ebox-test-box :content "A" :width '(100) :max-width '(80) :wrap-mode 'word) :flex-shrink 1 :flex-basis '(100)) - (ebox-flex-item - (ebox-create :content "B" :width '(100) :wrap-mode 'word) + (ebox-test-flex-item + (ebox-test-box :content "B" :width '(100) :wrap-mode 'word) :flex-shrink 1 :flex-basis '(100)))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(75 75))) @@ -337,12 +349,12 @@ (ert-deftest ebox-flex-shrink-factors-below-one-partially-shrink-overflow () "Shrink factors whose sum is below 1 should leave some overflow unclaimed." (let* ((layout - (ebox-flex :width '(150) - (ebox-flex-item - (ebox-create :content "A" :width '(100)) + (ebox-test-flex :width '(150) + (ebox-test-flex-item + (ebox-test-box :content "A" :width '(100)) :flex-shrink 0.25) - (ebox-flex-item - (ebox-create :content "B" :width '(100)) + (ebox-test-flex-item + (ebox-test-box :content "B" :width '(100)) :flex-shrink 0.25))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(87 88))) @@ -352,35 +364,35 @@ "Flex width, basis, grow, and shrink factors should reject negatives." (should-error (ebox-flex-test--render - (ebox-flex :width '(-1) - (ebox-create :content "A" :width '(20)))) + (ebox-test-flex :width '(-1) + (ebox-test-box :content "A" :width '(20)))) :type 'error) (should-error (ebox-flex-test--render - (ebox-flex - (ebox-flex-item (ebox-create :content "A" :width '(20)) + (ebox-test-flex + (ebox-test-flex-item (ebox-test-box :content "A" :width '(20)) :flex-grow -1))) :type 'error) (should-error (ebox-flex-test--render - (ebox-flex - (ebox-flex-item (ebox-create :content "A" :width '(20)) + (ebox-test-flex + (ebox-test-flex-item (ebox-test-box :content "A" :width '(20)) :flex-shrink -1))) :type 'error) (should-error (ebox-flex-test--render - (ebox-flex - (ebox-flex-item (ebox-create :content "A" :width '(20)) + (ebox-test-flex + (ebox-test-flex-item (ebox-test-box :content "A" :width '(20)) :flex-basis '(-1)))) :type 'error)) (ert-deftest ebox-flex-wraps-rows-with-column-gap () "Row wrap should create new flex lines using column gaps." (let* ((layout - (ebox-flex :width '(210) :flex-wrap 'wrap :column-gap '(10) - (ebox-create :content "A" :width '(100)) - (ebox-create :content "B" :width '(100)) - (ebox-create :content "C" :width '(100)))) + (ebox-test-flex :width '(210) :flex-wrap 'wrap :column-gap '(10) + (ebox-test-box :content "A" :width '(100)) + (ebox-test-box :content "B" :width '(100)) + (ebox-test-box :content "C" :width '(100)))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 2)) (should (string-match-p "A.*B" (car lines))) @@ -393,14 +405,14 @@ (ebox-build '(flex :flex-flow (row wrap) :column-gap (12) :width (800) (column - (box :content "one" :width (220)) - (box :content "A" :width (42))) + (box :width (220) "one") + (box :width (42) "A")) (column - (box :content "two" :width (220)) - (box :content "B" :width (42))) + (box :width (220) "two") + (box :width (42) "B")) (column - (box :content "three" :width (220)) - (box :content "C" :width (42)))))) + (box :width (220) "three") + (box :width (42) "C"))))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 2)) (should (string-match-p "one.*two.*three" (car lines))) @@ -412,26 +424,34 @@ (ebox-build '(flex :flex-flow (row wrap) :column-gap (12) :width (800) (column - (box :content "wrap" :id "title" :max-width (293) + (box :id "title" :max-width (293) :padding (0 (10)) :border "#5E7F6A" - :text-align center) + :text-align center "wrap") (flex :id "body" :max-width (293) :padding (0 (8)) :border "#5E7F6A" :border-top none :flex-wrap wrap :column-gap (8) - (item :flex-shrink 0 - (box :content "A 118" :width (118))) - (item :flex-shrink 0 - (box :content "B 118" :width (118))) - (item :flex-shrink 0 - (box :content "C 118" :width (118)))))))) - (rendered (ebox-flex-test--render layout)) - (ids (ebox-region-ids layout)) - (title-width (ebox-flex-test--region-property-width - rendered (car ids) 'ebox-bt)) - (body-width (ebox-flex-test--region-property-width - rendered (cadr ids) 'ebox-bb))) - (should (> title-width 200)) - (should (> body-width 200)))) + (box :flex-shrink 0 :width (118) "A 118") + (box :flex-shrink 0 :width (118) "B 118") + (box :flex-shrink 0 :width (118) "C 118")))))) + (buffer (generate-new-buffer " *ebox-flex-viewport*"))) + (unwind-protect + (progn + (ebox-render-to-buffer buffer layout) + (let* ((rendered (with-current-buffer buffer (buffer-string))) + (title-id (plist-get + (car (ebox-selector-query-buffer buffer "#title")) + :region-id)) + (body-id (plist-get + (car (ebox-selector-query-buffer buffer "#body")) + :region-id)) + (title-width (ebox-flex-test--region-property-width + rendered title-id 'ebox-bt)) + (body-width (ebox-flex-test--region-property-width + rendered body-id 'ebox-bb))) + (should (> title-width 200)) + (should (> body-width 200)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) (ert-deftest ebox-flex-wrapper-bounds-preformatted-child-layout () "Flex container boxes should not let child layout overflow widen them." @@ -442,25 +462,25 @@ :padding (0 (12)) :border "#6F6A95" :border-top none :bgcolor "#FBF9FF" (column - (box :content ":flex-flow (row wrap)" :max-width (440) + (box :max-width (440) :padding (0 (10)) :border "#6F6A95" - :text-align center) + :text-align center ":flex-flow (row wrap)") (flex :max-width (440) :padding (0 (8)) :border "#6F6A95" :border-top none :flex-flow (row wrap) :gap (1 (12)) - (item (box :content "A" :width (170))) - (item (box :content "B" :width (170))) - (item (box :content "C wraps" :width (170))))) + (box :width (170) "A") + (box :width (170) "B") + (box :width (170) "C wraps"))) (column - (box :content ":flex-flow (column wrap)" :max-width (440) + (box :max-width (440) :padding (0 (10)) :border "#6F6A95" - :text-align center) + :text-align center ":flex-flow (column wrap)") (flex :max-width (440) :padding (0 (8)) :border "#6F6A95" :border-top none :flex-flow (column wrap) :gap (1 (12)) - (item (box :content "A" :width (140))) - (item (box :content "B" :width (140))) - (item (box :content "C new column" :width (140)))))))) + (box :width (140) "A") + (box :width (140) "B") + (box :width (140) "C new column")))))) (rendered (ebox-flex-test--render layout)) (line-widths (ebox-flex-test--line-widths layout)) (border-widths @@ -475,12 +495,12 @@ (ert-deftest ebox-flex-wraps-with-max-width-clamped-hypothetical-size () "Line collection should use max-width-clamped hypothetical main sizes." (let* ((layout - (ebox-flex :width '(210) :flex-wrap 'wrap :column-gap '(10) - (ebox-flex-item - (ebox-create :content "A" :width '(200) :max-width '(100)) + (ebox-test-flex :width '(210) :flex-wrap 'wrap :column-gap '(10) + (ebox-test-flex-item + (ebox-test-box :content "A" :width '(200) :max-width '(100)) :flex-basis '(200)) - (ebox-flex-item - (ebox-create :content "B" :width '(200) :max-width '(100)) + (ebox-test-flex-item + (ebox-test-box :content "B" :width '(200) :max-width '(100)) :flex-basis '(200)))) (lines (ebox-flex-test--plain-lines layout)) (widths (ebox-flex-test--content-widths layout))) @@ -492,23 +512,21 @@ "A row flex container without :width should use the current viewport." (let* ((ebox-viewport-width 240) (layout - (ebox-flex :flex-wrap 'wrap :column-gap '(10) - (ebox-create :content "A" :width '(100)) - (ebox-create :content "B" :width '(100)) - (ebox-create :content "C" :width '(100))))) + (ebox-test-flex :flex-wrap 'wrap :column-gap '(10) + (ebox-test-box :content "A" :width '(100)) + (ebox-test-box :content "B" :width '(100)) + (ebox-test-box :content "C" :width '(100))))) (should (equal (ebox-flex-test--line-widths layout) '(240 240))))) (ert-deftest ebox-flex-stretch-width-is-independent-of-container-wrapper () "Stretch width should use the viewport with or without a neutral wrapper." (let* ((ebox-viewport-width 200) (plain - (ebox-flex :width 'stretch - (ebox-create :content "A" :width '(40)))) + (ebox-test-flex :width 'stretch + (ebox-test-box :content "A" :width '(40)))) (padded - (ebox-flex :width 'stretch :padding 0 - (ebox-create :content "A" :width '(40))))) - (should-not (plist-get plain :box)) - (should (plist-get padded :box)) + (ebox-test-flex :width 'stretch :padding 0 + (ebox-test-box :content "A" :width '(40))))) (should (equal (mapcar #'ebox-flex-test--line-widths (list plain padded)) '((200) (200)))))) @@ -520,13 +538,11 @@ (mapcar (lambda (width) (let ((plain - (ebox-flex :width width - (ebox-create :content "A" :width '(40)))) + (ebox-test-flex :width width + (ebox-test-box :content "A" :width '(40)))) (padded - (ebox-flex :width width :padding 0 - (ebox-create :content "A" :width '(40))))) - (should-not (plist-get plain :box)) - (should (plist-get padded :box)) + (ebox-test-flex :width width :padding 0 + (ebox-test-box :content "A" :width '(40))))) (list width plain padded))) '(stretch auto))) (results @@ -544,12 +560,12 @@ "A styled flex container should lay out inside its viewport-sized box." (let* ((ebox-viewport-width 240) (layout - (ebox-flex :padding-inline '(20) + (ebox-test-flex :padding-inline '(20) :border-left '(10) :border-right '(10) :justify-content 'space-between - (ebox-create :content "A" :width '(40)) - (ebox-create :content "B" :width '(40)))) + (ebox-test-box :content "A" :width '(40)) + (ebox-test-box :content "B" :width '(40)))) (rendered (ebox-flex-test--render layout)) (display-widths (cl-loop for index below (length rendered) @@ -564,24 +580,24 @@ "A viewport-sized styled flex container should use its content box for lines." (let* ((ebox-viewport-width 240) (layout - (ebox-flex :width '(viewport) + (ebox-test-flex :width '(viewport) :padding-inline '(20) :border-left '(10) :border-right '(10) :flex-wrap 'wrap - (ebox-flex-item (ebox-create :content "A" :width '(180))) - (ebox-flex-item (ebox-create :content "B" :width '(180)))))) + (ebox-test-flex-item (ebox-test-box :content "A" :width '(180))) + (ebox-test-flex-item (ebox-test-box :content "B" :width '(180)))))) (should (cl-every (lambda (width) (<= width ebox-viewport-width)) (ebox-flex-test--line-widths layout))))) (ert-deftest ebox-flex-auto-height-is-independent-of-container-wrapper () "Auto height should preserve natural child height across wrapper branches." (let* ((plain - (ebox-flex :width '(80) :height 'auto - (ebox-create :content "A\nB" :width '(40)))) + (ebox-test-flex :width '(80) :height 'auto + (ebox-test-box :content "A\nB" :width '(40)))) (padded - (ebox-flex :width '(80) :height 'auto :padding 0 - (ebox-create :content "A\nB" :width '(40)))) + (ebox-test-flex :width '(80) :height 'auto :padding 0 + (ebox-test-box :content "A\nB" :width '(40)))) (results (mapcar (lambda (layout) @@ -589,22 +605,20 @@ (ebox-string-height (ebox-flex-test--render layout)) (error err))) (list plain padded)))) - (should-not (plist-get plain :box)) - (should (plist-get padded :box)) (should (equal results '(2 2))))) (ert-deftest ebox-flex-reuses-measured-non-box-child-render () "Flex should not fully rerender a measured non-box child during one render." (let* ((child (plist-put - (ebox-column - (ebox-create :content "A" :width '(100)) - (ebox-create :content "B" :width '(100))) + (ebox-test-column + (ebox-test-box :content "A" :width '(100)) + (ebox-test-box :content "B" :width '(100))) :key 'measured-child)) - (layout (ebox-flex :width '(240) :flex-wrap 'wrap + (layout (ebox-test-flex :width '(240) :flex-wrap 'wrap child - (ebox-column - (ebox-create :content "C" :width '(100)) - (ebox-create :content "D" :width '(100))))) + (ebox-test-column + (ebox-test-box :content "C" :width '(100)) + (ebox-test-box :content "D" :width '(100))))) (calls 0) (original-render (symbol-function 'ebox-render))) (cl-letf (((symbol-function 'ebox-render) @@ -615,56 +629,17 @@ (ebox-render layout)) (should (= calls 1)))) -(ert-deftest ebox-flex-reuses-measured-box-render-when-size-unchanged () - "Flex should reuse a measured box when target main size is unchanged." - (let* ((box (ebox-create :content "A" :width '(80))) - (layout - (ebox-flex :width '(220) :height 3 - :flex-wrap 'nowrap :align-items 'flex-start - box - (ebox-create :content "B" :width '(80)))) - (calls 0) - (original (symbol-function 'ebox--render-box))) - (cl-letf (((symbol-function 'ebox--render-box) - (lambda (candidate) - (when (equal (ebox-get candidate :content) "A") - (cl-incf calls)) - (funcall original candidate)))) - (ebox-render layout)) - (should (= calls 1)))) - -(ert-deftest ebox-flex-reuses-natural-sized-render-when-cross-is-unchanged () - "Final cross sizing should reuse an equal natural-sized item render." - (let* ((box (ebox-create :content "A" :width '(80) :flex-grow 1)) - (region-id (car (ebox-region-ids box))) - (layout (ebox-flex :width '(220) :align-items 'flex-start box)) - (calls 0) - (original (symbol-function 'ebox--render-box)) - rendered) - (cl-letf (((symbol-function 'ebox--render-box) - (lambda (candidate) - (when (equal (ebox-get candidate :content) "A") - (cl-incf calls)) - (funcall original candidate)))) - (setq rendered (ebox-render layout))) - (should (equal (mapcar #'ebox--string-pixel-width - (ebox-string-lines rendered)) - '(220))) - (should (= (ebox-flex-test--region-content-width rendered region-id) - 220)) - (should (= calls 2)))) - (ert-deftest ebox-flex-reuses-natural-non-box-render-before-cross-padding () "Final cross padding should reuse a naturally sized non-box render." (let* ((source (plist-put - (ebox-column - (ebox-create :content "A" :width '(80)) - (ebox-create :content "A2" :width '(80))) + (ebox-test-column + (ebox-test-box :content "A" :width '(80)) + (ebox-test-box :content "A2" :width '(80))) :key 'natural-child)) (layout - (ebox-flex :width '(220) - (ebox-flex-item source :flex-grow 1 :flex-basis '(80)) - (ebox-create :content "B" :width '(80) :height 4))) + (ebox-test-flex :width '(220) + (ebox-test-flex-item source :flex-grow 1 :flex-basis '(80)) + (ebox-test-box :content "B" :width '(80) :height 4))) (calls 0) (original (symbol-function 'ebox-render)) rendered) @@ -682,13 +657,13 @@ (ert-deftest ebox-flex-natural-reuse-preserves-stretched-visible-overflow-ownership () "Stretch sizing should absorb visible overflow into the final box region." - (let* ((box (ebox-create :content "A\nB" :width '(80) :height 1 + (let* ((box (ebox-test-box :content "A\nB" :width '(80) :height 1 :overflow 'visible :flex-grow 1)) (region-id (car (ebox-region-ids box))) - (rendered (ebox-render (ebox-flex :width '(120) box))) + (rendered (ebox-render (ebox-test-flex :width '(120) box))) (lines (ebox-string-lines rendered))) (should (= (length lines) 2)) - (should (= (ebox-flex-test--region-content-width + (should (= (ebox-flex-test--region-owner-width (nth 1 lines) region-id) 120)))) @@ -702,10 +677,10 @@ (box :id "root" :width (80) :height (viewport-height) :overflow scroll :flex-grow 1 (column :width (80) - (box :content "A" :height 1) - (box :content "B" :height 1) - (box :content "C" :height 1) - (box :content "D" :height 1)))))) + (box :height 1 "A") + (box :height 1 "B") + (box :height 1 "C") + (box :height 1 "D")))))) (buffer (generate-new-buffer " *ebox-flex-scroll-state*"))) (unwind-protect (progn @@ -718,40 +693,40 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-flex-natural-row-padding-uses-rendered-overflow-width () - "Natural cross padding should preserve a row item's rendered overflow width." +(ert-deftest ebox-flex-natural-row-padding-keeps-overflow-line-local () + "Visible overflow should widen only the line that actually overflows." (let* ((viewport-child - (ebox-create :content "A" :width '(viewport) + (ebox-test-box :content "A" :width '(viewport) :box-sizing 'content-box :padding-left '(10) :padding-right '(10))) (source - (ebox-column viewport-child - (ebox-create :content "A2" :width '(20)))) + (ebox-test-column viewport-child + (ebox-test-box :content "A2" :width '(20)))) (layout - (ebox-flex :width '(240) - (ebox-flex-item source :flex-basis '(20)) - (ebox-create :content "B" :width '(20) :height 4))) + (ebox-test-flex :width '(240) + (ebox-test-flex-item source :flex-basis '(20)) + (ebox-test-box :content "B" :width '(20) :height 4))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render layout))))) (should (= (length widths) 4)) - (should (= (nth 2 widths) (nth 0 widths))) - (should (= (nth 3 widths) (nth 0 widths))))) + (should (= (car widths) 260)) + (should (equal (cdr widths) '(240 240 240))))) (ert-deftest ebox-flex-composite-auto-row-respects-assigned-width () "A composite auto-width row must not duplicate its Flex item viewport." (let* ((layout - (ebox-flex :width '(120) - (ebox-row - (ebox-create :content "Left" :border t) - (ebox-create :content "Right" :border t)))) + (ebox-test-flex :width '(120) + (ebox-test-row + (ebox-test-box :content "Left" :border t) + (ebox-test-box :content "Right" :border t)))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render layout))))) (should (cl-every (lambda (width) (<= width 120)) widths)))) (ert-deftest ebox-flex-sized-persistent-hit-populates-pass-local-cache () "Persistent sized renders should become reusable in the current pass." - (let* ((source (ebox-create :content "A" :width '(80))) + (let* ((source (ebox-test-box :content "A" :width '(80))) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) (first (car (ebox--flex-collect-items (list source) 'row 220))) @@ -774,25 +749,9 @@ (setq pos (or (next-single-property-change pos 'face string) len))) (format "%S" (nreverse faces)))) -(ert-deftest ebox-flex-measurement-cache-honors-content-properties () - "Property-only content changes must invalidate flex measurement reuse." - (let* ((child (ebox-create - :content (propertize "AB" 'face '(:foreground "red")) - :width '(80))) - (layout (ebox-flex :width '(160) child)) - (ebox--render-cache-table (make-hash-table :test 'equal))) - (let ((ebox--render-cache-signature-cache (make-hash-table :test 'eq))) - (ebox-render layout)) - (plist-put child :content - (propertize "AB" 'face '(:foreground "blue"))) - (let* ((ebox--render-cache-signature-cache (make-hash-table :test 'eq)) - (faces (ebox-flex-test--face-signature (ebox-render layout)))) - (should (string-match-p "blue" faces)) - (should-not (string-match-p "red" faces))))) - (ert-deftest ebox-flex-measurement-miss-probes-cache-once () "A flex measurement miss should compute one exact cache probe." - (let* ((source (ebox-create :content "Measured once" :width '(80))) + (let* ((source (ebox-test-box :content "Measured once" :width '(80))) (props '(:flex-basis auto)) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) @@ -816,16 +775,37 @@ (should (= signature-calls 1)) (should (= lookup-calls 1)))) +(ert-deftest ebox-flex-reuses-direct-column-natural-measurement () + "A direct Column's nil-viewport natural size should survive parent resize." + (let* ((source + (ebox-test-column + (ebox-test-box :content "A" :width '(80)) + (ebox-test-box :content "B" :width '(80)))) + (props '(:flex-basis auto)) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq)) + (renders 0) + (original + (symbol-function 'ebox--flex-render-source-for-measurement))) + (cl-letf (((symbol-function 'ebox--flex-render-source-for-measurement) + (lambda (&rest arguments) + (cl-incf renders) + (apply original arguments)))) + (ebox--flex-item-measurement source props 'row 320) + (ebox--flex-item-measurement source props 'row 640)) + (should (= renders 1)))) + (ert-deftest ebox-flex-fixed-basis-wrapped-box-skips-intrinsic-render () "Known flex metrics should not render a wrapped fixed-basis box twice." (let* ((source - (ebox-create + (ebox-test-box :content "A responsive wrapped child" :width '(viewport) :min-width 0 :height 4 :wrap-mode 'word)) (item - (ebox-flex-item + (ebox-test-flex-item source :flex-grow 1 :flex-shrink 1 :flex-basis '(80))) - (layout (ebox-flex :width '(240) :height 4 item)) + (layout (ebox-test-flex :width '(240) :height 4 item)) (ebox-viewport-width 240) (measurement-renders 0) fast slow) @@ -845,17 +825,17 @@ (ert-deftest ebox-flex-fixed-basis-nowrap-box-keeps-intrinsic-measurement () "No-wrap auto minimums should retain their intrinsic render measurement." (let* ((source - (ebox-create :content "UNBREAKABLE" :min-width 0 :height 1 + (ebox-test-box :content "UNBREAKABLE" :min-width 0 :height 1 :wrap-mode nil)) (props (ebox--flex-item-props - (ebox-flex-item source :flex-basis '(40))))) + (ebox-test-flex-item source :flex-basis '(40))))) (should-not (ebox--flex-unrendered-measurement source props 'row)))) (ert-deftest ebox-flex-explicit-zero-min-width-allows-no-wrap-shrink () "An explicit zero minimum lets a no-wrap card shrink in its Flex line." (let* ((source - (ebox-create :content "A long no-wrap card title" + (ebox-test-box :content "A long no-wrap card title" :min-width 0 :wrap-mode nil)) (rendered (ebox-render source))) (should (zerop (ebox--flex-box-min-main source rendered 'row))))) @@ -863,134 +843,39 @@ (ert-deftest ebox-flex-bounded-auto-basis-keeps-intrinsic-measurement () "Clipping must not replace an auto-basis item's intrinsic contribution." (let* ((source - (ebox-create :content "UNBREAKABLE" :width '(120) :height 2 + (ebox-test-box :content "UNBREAKABLE" :width '(120) :height 2 :min-width 0 :wrap-mode nil :overflow 'hidden)) (props (ebox--flex-item-props source))) (should (eq (plist-get props :flex-basis) 'auto)) (should-not (ebox--flex-unrendered-measurement source props 'row)))) -(ert-deftest ebox-flex-yielding-prewarm-renders-one-sized-item-per-slice () - "Fixed flex cards should prewarm incrementally without changing output." - (let* ((children - (cl-loop for label in '("Alpha" "Beta" "Gamma") - collect - (ebox-flex-item - (ebox-create :content label :height 3 :min-width 0 - :wrap-mode 'word :overflow 'hidden) - :flex-grow 1 :flex-shrink 1 :flex-basis '(60)))) - (layout (apply #'ebox-flex - (append '(:width (240) :height 3 - :flex-flow (row nowrap)) - children))) - (ebox-viewport-width 240) - baseline warmed) - ;; Establish stable region ids before comparing propertized output. - (let ((ebox--render-cache-table (make-hash-table :test 'equal)) - (ebox--render-cache-signature-cache (make-hash-table :test 'eq))) - (setq baseline (ebox-render layout))) - (let ((ebox--render-cache-table (make-hash-table :test 'equal)) - (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) - (ebox--scroll-window-prewarm-state (make-hash-table :test 'eq)) - (measurement-count 0) - (sized-render-count 0) - (original-measurement - (symbol-function 'ebox--flex-item-measurement)) - (original-sized-render - (symbol-function 'ebox--flex-render-sized-entry))) - (cl-letf (((symbol-function 'ebox--flex-item-measurement) - (lambda (&rest arguments) - (cl-incf measurement-count) - (apply original-measurement arguments))) - ((symbol-function 'ebox--flex-render-sized-entry) - (lambda (&rest arguments) - (cl-incf sized-render-count) - (apply original-sized-render arguments)))) - (should (ebox--flex-prewarm-sized-item layout 240)) - (should (= measurement-count 1)) - (should (zerop sized-render-count)) - (should (ebox--flex-prewarm-sized-item layout 240)) - (should (= measurement-count 2)) - (should (zerop sized-render-count)) - (should (ebox--flex-prewarm-sized-item layout 240)) - (should (= measurement-count 3)) - (should (zerop sized-render-count)) - (should (ebox--flex-prewarm-sized-item layout 240)) - (should (= sized-render-count 1)) - (should (ebox--flex-prewarm-sized-item layout 240)) - (should (= sized-render-count 2)) - (should (ebox--flex-prewarm-sized-item layout 240)) - (should (= sized-render-count 3)) - (should-not (ebox--flex-prewarm-sized-item layout 240)) - (setq warmed (ebox-render layout))) - (should (equal-including-properties baseline warmed))))) - -(ert-deftest ebox-flex-repeated-responsive-sized-render-uses-scoped-cache () - "Repeated composite item geometry should ignore the ambient root width." - (let* ((leaf (ebox-create :content "A" :width '(viewport))) - (source - (ebox-column leaf (ebox-create :content "tail" :width '(40)))) - (ebox--render-cache-table (make-hash-table :test 'equal)) - (ebox--flex-sized-render-observation-table - (make-hash-table :test 'eq :weakness 'key)) - (calls 0) - (original (symbol-function 'ebox-render))) - (cl-labels - ((render-at - (ambient-width item-width) - (let ((ebox-viewport-width ambient-width) - (ebox--render-cache-allow-viewport-dependent nil) - (ebox--render-cache-signature-cache - (make-hash-table :test 'eq))) - (ebox--flex-render-sized-entry - (list :source source) 'row item-width nil 'flex-start)))) - (cl-letf (((symbol-function 'ebox-render) - (lambda (node) - (when (eq node source) - (cl-incf calls)) - (funcall original node)))) - ;; The first occurrence observes the geometry, the second stores it, - ;; and a third pass at another root width reuses the scoped result. - (render-at 300 120) - (render-at 600 120) - (render-at 900 120) - (should (= calls 2)) - ;; A source mutation changes the body signature even though geometry - ;; and the repeated-context observation remain warm. - (ebox-put leaf :content "B") - (render-at 900 120) - (should (= calls 3)) - ;; A different local viewport earns its own observation and cache. - (render-at 300 130) - (render-at 600 130) - (render-at 900 130) - (should (= calls 5)))))) - (ert-deftest ebox-flex-column-natural-cache-respects-final-cross-viewport () "Column flex must render composite items in the final cross viewport." (let* ((viewport-child - (ebox-create :content "VP" :width '(viewport))) + (ebox-test-box :content "VP" :width '(viewport))) (source - (ebox-column + (ebox-test-column viewport-child - (ebox-create :content "F" :width '(100)))) + (ebox-test-box :content "F" :width '(100)))) (region-id (car (ebox-region-ids viewport-child))) (layout - (ebox-flex :height 4 + (ebox-test-flex :height 4 :flex-direction 'column :align-items 'stretch - (ebox-flex-item source :flex-grow 1 :flex-basis 2))) + (ebox-test-flex-item source :flex-grow 1 :flex-basis 2))) (rendered (ebox-render layout))) - ;; Two children keep the column as a composite stack. - (should (eq (plist-get source :ebox-type) 'stack)) - (should (= (ebox-flex-test--region-content-width rendered region-id) + (should (ebox-box-node-p source)) + (should (eq (ebox-layout-config-kind (ebox-box-node-layout source)) + 'column)) + (should (= (ebox-flex-test--region-owner-width rendered region-id) 100)))) (ert-deftest ebox-flex-nowrap-explicit-cross-skips-natural-cross-measure () "Nowrap containers with fixed cross size should not measure natural cross." (let* ((layout - (ebox-flex :width '(220) :height 4 :flex-wrap 'nowrap - (ebox-create :content "A" :width '(80)) - (ebox-create :content "B" :width '(80)))) + (ebox-test-flex :width '(220) :height 4 :flex-wrap 'nowrap + (ebox-test-box :content "A" :width '(80)) + (ebox-test-box :content "B" :width '(80)))) (calls 0) (original (symbol-function 'ebox--flex-line-cross))) (cl-letf (((symbol-function 'ebox--flex-line-cross) @@ -1003,9 +888,9 @@ (ert-deftest ebox-flex-aligns-items-on-row-cross-axis () "Align-items center should move short row items within the line height." (let* ((layout - (ebox-flex :width '(180) :height 3 :align-items 'center - (ebox-create :content "A" :width '(80) :height 1) - (ebox-create :content "B\nB\nB" :width '(80) :height 3))) + (ebox-test-flex :width '(180) :height 3 :align-items 'center + (ebox-test-box :content "A" :width '(80) :height 1) + (ebox-test-box :content "B\nB\nB" :width '(80) :height 3))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 3)) (should (= (cl-position-if (lambda (line) @@ -1016,13 +901,13 @@ (ert-deftest ebox-flex-honors-order-and-reverse-directions () "Order and reverse directions should change visual item order." (let* ((ordered - (ebox-flex - (ebox-flex-item (ebox-create :content "A" :width '(40)) :order 2) - (ebox-flex-item (ebox-create :content "B" :width '(40)) :order 1))) + (ebox-test-flex + (ebox-test-flex-item (ebox-test-box :content "A" :width '(40)) :order 2) + (ebox-test-flex-item (ebox-test-box :content "B" :width '(40)) :order 1))) (column-reverse - (ebox-flex :flex-direction 'column-reverse - (ebox-create :content "A" :width '(40)) - (ebox-create :content "B" :width '(40))))) + (ebox-test-flex :flex-direction 'column-reverse + (ebox-test-box :content "A" :width '(40)) + (ebox-test-box :content "B" :width '(40))))) (should (string-match-p "B.*A" (car (ebox-flex-test--plain-lines ordered)))) (should (string-match-p "B" @@ -1031,11 +916,11 @@ (ert-deftest ebox-flex-align-self-overrides-align-items () "Align-self should override the container cross-axis alignment." (let* ((layout - (ebox-flex :width '(180) :height 3 :align-items 'flex-start - (ebox-flex-item - (ebox-create :content "A" :width '(80) :height 1) + (ebox-test-flex :width '(180) :height 3 :align-items 'flex-start + (ebox-test-flex-item + (ebox-test-box :content "A" :width '(80) :height 1) :align-self 'flex-end) - (ebox-create :content "B\nB\nB" :width '(80) :height 3))) + (ebox-test-box :content "B\nB\nB" :width '(80) :height 3))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 3)) (should (= (cl-position-if (lambda (line) @@ -1046,11 +931,11 @@ (ert-deftest ebox-flex-wrap-single-line-align-content-keeps-natural-cross-size () "A wrapped one-line container should still use multi-line align-content rules." (let* ((layout - (ebox-flex :width '(180) :height 4 + (ebox-test-flex :width '(180) :height 4 :flex-wrap 'wrap :align-content 'flex-start :align-items 'center - (ebox-create :content "A" :width '(60) :height 1))) + (ebox-test-box :content "A" :width '(60) :height 1))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 4)) (should (= (cl-position-if (lambda (line) @@ -1061,11 +946,11 @@ (ert-deftest ebox-flex-applies-container-box-properties () "Flex container visual box properties should render around the flex layout." (let* ((layout - (ebox-flex :width '(160) :box-sizing 'border-box + (ebox-test-flex :width '(160) :box-sizing 'border-box :padding '(1 (8)) :border '(1 "#ff0000") :bgcolor "#101010" - (ebox-create :content "A" :width '(60)))) + (ebox-test-box :content "A" :width '(60)))) (rendered (ebox-flex-test--render layout)) (left-face (ebox-flex-test--face-at-property rendered 'ebox-bl)) (right-face (ebox-flex-test--face-at-property rendered 'ebox-br)) @@ -1087,16 +972,17 @@ (ert-deftest ebox-flex-container-box-preserves-blank-child-bottom-border () "A visual flex container should preserve child bottom borders on blank lines." - (let* ((child (ebox-create :content "A" + (let* ((child (ebox-test-box :content "A" :width '(80) :height 2 :padding '(0 (6)) - :border '(1 "#00aa00"))) + :border '(1 "#00aa00") + :flex-basis '(80))) (child-id (car (ebox-region-ids child))) - (layout (ebox-flex :width '(140) + (layout (ebox-test-flex :width '(140) :padding '(1 (4)) :border '(1 "#444444") - (ebox-flex-item child :flex-basis '(80)))) + child)) (rendered (ebox-flex-test--render layout))) (should (memq child-id (ebox-flex-test--property-values rendered 'ebox-bb))))) @@ -1112,16 +998,16 @@ "REWRAPPED" text)))) (let* ((layout - (ebox-flex :width '(260) :height 3 :border "gray" + (ebox-test-flex :width '(260) :height 3 :border "gray" :flex-flow '(row wrap) :justify-content 'space-between - (ebox-create :content "sample box two" + (ebox-test-box :content "sample box two" :height 3 :box-sizing 'border-box :padding '(0 (8)) :border t :wrap-mode nil) - (ebox-create :content "sample box three" + (ebox-test-box :content "sample box three" :height 3 :box-sizing 'border-box :padding '(0 (8)) @@ -1135,9 +1021,9 @@ (ert-deftest ebox-flex-item-applies-box-properties-with-flex-metadata () "An item wrapper should keep flex metadata and apply visual box properties." (let* ((layout - (ebox-flex :width '(180) - (ebox-flex-item - (ebox-create :content "A" :width '(60)) + (ebox-test-flex :width '(180) + (ebox-test-flex-item + (ebox-test-box :content "A" :width '(60)) :flex 1 :box-sizing 'border-box :padding '(0 (6)) @@ -1170,11 +1056,11 @@ "REWRAPPED" text)))) (let* ((layout - (ebox-flex :width '(220) - (ebox-flex-item - (ebox-column - (ebox-create :content "Top" :width '(80)) - (ebox-create :content "Bottom" :width '(80))) + (ebox-test-flex :width '(220) + (ebox-test-flex-item + (ebox-test-column + (ebox-test-box :content "Top" :width '(80)) + (ebox-test-box :content "Bottom" :width '(80))) :width '(180) :box-sizing 'border-box :border t))) @@ -1183,83 +1069,61 @@ (should (string-match-p "Top" plain)) (should (string-match-p "Bottom" plain))))) -(ert-deftest ebox-build-flex-item-applies-box-properties-with-flex-metadata () - "The DSL item form should keep flex metadata and visual box properties." - (let* ((layout - (ebox-build - '(flex :width (180) - (item :flex 1 - :box-sizing border-box - :padding (0 (6)) - :border (1 "#00aa00") - (box :content "A" :width (60)))))) - (rendered (ebox-flex-test--render layout)) - (top-face (ebox-flex-test--face-at-property rendered 'ebox-bt)) - (bottom-face (ebox-flex-test--face-at-property rendered 'ebox-bb))) - (should (equal (ebox-flex-test--line-widths layout) '(180))) - (should (string-match-p "A" (substring-no-properties rendered))) - (should (ebox-flex-test--face-has-key-value-p top-face :overline "#00aa00")) - (should (ebox-flex-test--face-has-key-value-p - bottom-face - :underline - '(:position t :color "#00aa00"))))) - (ert-deftest ebox-flex-neutral-size-properties-keep-child-identity () "Width, height, and box-sizing alone should not wrap a flex container." (dolist (props '((:width (120)) (:height 2) (:box-sizing border-box))) - (let ((layout - (apply #'ebox-flex - (append props - (list (ebox-create :content "A" :width '(40)) - (ebox-create :content "B" :width '(40))))))) - (should-not (plist-get layout :box)) - (should (= (length (ebox-region-ids layout)) 2))))) + (let* ((left (ebox-test-box :content "A" :width '(40))) + (right (ebox-test-box :content "B" :width '(40))) + (layout + (apply #'ebox-test-flex + (append props (list left right))))) + (should (equal (ebox-box-node-children layout) (list left right))) + (should (string-match-p "A.*B" + (substring-no-properties + (ebox-flex-test--render layout))))))) (ert-deftest ebox-flex-box-sizing-alone-keeps-child-region-ids () "Box-sizing by itself should not wrap a flex container." - (let ((layout - (ebox-flex :width '(120) :box-sizing 'border-box - (ebox-create :content "A" :width '(40)) - (ebox-create :content "B" :width '(40))))) - (should (= (length (ebox-region-ids layout)) 2)) + (let* ((left (ebox-test-box :content "A" :width '(40))) + (right (ebox-test-box :content "B" :width '(40))) + (layout + (ebox-test-flex :width '(120) :box-sizing 'border-box left right))) + (should (equal (ebox-box-node-children layout) (list left right))) (should (equal (ebox-flex-test--line-widths layout) '(120))))) -(ert-deftest ebox-flex-item-box-wrapper-preserves-direct-child-item-props () - "Wrapping an item in box properties should not hide child flex metadata." - (let* ((layout - (ebox-flex :width '(160) - (ebox-flex-item - (ebox-create :content "A" :width '(60) - :flex-grow 1 :flex-basis '(60)) - :box-sizing 'border-box - :border t) - (ebox-create :content "B" :width '(40)))) - (ids (ebox-region-ids layout)) +(ert-deftest ebox-flex-direct-child-combines-frame-and-participation () + "One child Box should own both frame and Flex participation facts." + (let* ((left (ebox-test-box :content "A" :width '(60) + :flex-grow 1 :flex-basis '(60) + :box-sizing 'border-box :border t)) + (right (ebox-test-box :content "B" :width '(40))) + (layout (ebox-test-flex :width '(160) left right)) + (left-id (car (ebox-region-ids left))) + (right-id (car (ebox-region-ids right))) (rendered (ebox-flex-test--render layout)) - (wrapper-width - (ebox-flex-test--region-property-width - rendered (car ids) 'ebox-content-owner)) + (left-width + (ebox-flex-test--region-owner-width rendered left-id)) (right-width - (ebox-flex-test--region-content-width rendered (car (last ids))))) - (should (= (length ids) 3)) - (should (> wrapper-width 60)) - (should (> wrapper-width right-width)))) + (ebox-flex-test--region-owner-width rendered right-id))) + (should (equal (ebox-box-node-children layout) (list left right))) + (should (> left-width 60)) + (should (> left-width right-width)))) (ert-deftest ebox-flex-wraps-before-shrinking-no-wrap-item-below-minimum () "A no-wrap flex item should keep its rendered minimum width when wrapping." (let* ((layout - (ebox-flex :width '(160) + (ebox-test-flex :width '(160) :flex-flow '(row wrap) :column-gap '(10) - (ebox-flex-item - (ebox-create :content "wide no-wrap label" + (ebox-test-flex-item + (ebox-test-box :content "wide no-wrap label" :width '(150) :box-sizing 'border-box :wrap-mode nil) :flex 1) - (ebox-create :content "B" :width '(120)))) + (ebox-test-box :content "B" :width '(120)))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 2)) (should (cl-every (lambda (width) (<= width 160)) @@ -1272,50 +1136,37 @@ (dolist (direction '(row row-reverse column column-reverse)) (should (stringp (ebox-render - (ebox-flex :width '(220) :height 4 :flex-direction direction - (ebox-create :content "A" :width '(60) :height 1) - (ebox-create :content "B" :width '(60) :height 1)))))) + (ebox-test-flex :width '(220) :height 4 :flex-direction direction + (ebox-test-box :content "A" :width '(60) :height 1) + (ebox-test-box :content "B" :width '(60) :height 1)))))) (dolist (wrap '(nowrap wrap wrap-reverse)) (should (stringp (ebox-render - (ebox-flex :width '(130) :height 4 :flex-wrap wrap - (ebox-create :content "A" :width '(60) :height 1) - (ebox-create :content "B" :width '(60) :height 1) - (ebox-create :content "C" :width '(60) :height 1)))))) + (ebox-test-flex :width '(130) :height 4 :flex-wrap wrap + (ebox-test-box :content "A" :width '(60) :height 1) + (ebox-test-box :content "B" :width '(60) :height 1) + (ebox-test-box :content "C" :width '(60) :height 1)))))) (dolist (justify '(flex-start flex-end center space-between space-around space-evenly start end left right normal)) (should (stringp (ebox-render - (ebox-flex :width '(220) :justify-content justify - (ebox-create :content "A" :width '(60)) - (ebox-create :content "B" :width '(60))))))) + (ebox-test-flex :width '(220) :justify-content justify + (ebox-test-box :content "A" :width '(60)) + (ebox-test-box :content "B" :width '(60))))))) (dolist (align '(stretch flex-start flex-end center baseline normal start end self-start self-end)) (should (stringp (ebox-render - (ebox-flex :width '(220) :height 4 + (ebox-test-flex :width '(220) :height 4 :align-items align - (ebox-create :content "A" :width '(60) :height 1) - (ebox-create :content "B" :width '(60) :height 1)))))) + (ebox-test-box :content "A" :width '(60) :height 1) + (ebox-test-box :content "B" :width '(60) :height 1)))))) (dolist (align '(stretch flex-start flex-end center space-between space-around space-evenly baseline normal start end)) (should (stringp (ebox-render - (ebox-flex :width '(130) :height 5 :flex-wrap 'wrap + (ebox-test-flex :width '(130) :height 5 :flex-wrap 'wrap :align-content align - (ebox-create :content "A" :width '(60) :height 1) - (ebox-create :content "B" :width '(60) :height 1) - (ebox-create :content "C" :width '(60) :height 1))))))) - -(ert-deftest ebox-build-compiles-flex-and-item-dsl () - "The public DSL should expose flex and item forms." - (let* ((node - (ebox-build - '(flex :width (240) :flex-flow (row wrap) :column-gap (10) - (item :flex 1 (box :content "A" :width (80))) - (box :content "B" :width (80) :flex-grow 1 :flex-basis (80))))) - (rendered (ebox-flex-test--render node))) - (should (eq (plist-get node :ebox-type) 'flex)) - (should (= (length (ebox-region-ids node)) 2)) - (should (= (string-pixel-width (car (ebox-string-lines rendered))) 240)) - (should (string-match-p "A.*B" (substring-no-properties rendered))))) + (ebox-test-box :content "A" :width '(60) :height 1) + (ebox-test-box :content "B" :width '(60) :height 1) + (ebox-test-box :content "C" :width '(60) :height 1))))))) (provide 'ebox-flex-tests) ;;; ebox-flex-tests.el ends here diff --git a/tests/ebox-grid-tests.el b/tests/ebox-grid-tests.el index 42a7058..8ffc1af 100644 --- a/tests/ebox-grid-tests.el +++ b/tests/ebox-grid-tests.el @@ -15,34 +15,34 @@ "Grid should place unpositioned children in row-major order." (let ((plain (ebox-grid-test--plain - (ebox-grid + (ebox-test-grid :grid-template-columns '((20) (20)) :grid-template-rows '(1 1) :gap '(0 (1)) - (ebox-create :content "A") - (ebox-create :content "B") - (ebox-create :content "C") - (ebox-create :content "D"))))) + (ebox-test-box :content "A") + (ebox-test-box :content "B") + (ebox-test-box :content "C") + (ebox-test-box :content "D"))))) (should (string-match-p "A.*B" plain)) (should (string-match-p "C.*D" plain)))) (ert-deftest ebox-grid-supports-fractional-columns () "Fractional tracks should fill a definite grid width." - (let* ((node (ebox-grid + (let* ((node (ebox-test-grid :width '(120) :grid-template-columns '(1fr 2fr) - (ebox-create :content "A") - (ebox-create :content "B"))) + (ebox-test-box :content "A") + (ebox-test-box :content "B"))) (lines (ebox-string-lines (ebox-render node)))) (should (= (length lines) 1)) (should (= (ebox--string-pixel-width (car lines)) 120)))) (ert-deftest ebox-grid-repeated-render-reuses-construction-time-config () "Grid render and resize paths should not reparse canonical track config." - (let* ((node (ebox-grid :width '(120) + (let* ((node (ebox-test-grid :width '(120) :grid-template-columns '(1fr 2fr) - (ebox-create :content "A") - (ebox-create :content "B"))) + (ebox-test-box :content "A") + (ebox-test-box :content "B"))) (calls 0) (original (symbol-function 'ebox-grid--normalize-config-props))) (cl-letf (((symbol-function 'ebox-grid--normalize-config-props) @@ -56,19 +56,19 @@ (ert-deftest ebox-grid-minmax-fractional-columns-fill-and-shrink () "Zero-minimum fractional maxima should remain responsive to width." (dolist (width '(120 60)) - (let* ((node (ebox-grid + (let* ((node (ebox-test-grid :width (list width) :grid-template-columns '((minmax (0) (fr 1)) (minmax (0) (fr 2))) - (ebox-create :content "A" :width 'stretch) - (ebox-create :content "B" :width 'stretch))) + (ebox-test-box :content "A" :width 'stretch) + (ebox-test-box :content "B" :width 'stretch))) (lines (ebox-string-lines (ebox-render node)))) (should (= (ebox--string-max-pixel-width (car lines)) width))))) (ert-deftest ebox-grid-stretch-children-fill-fractional-tracks () "Stretch-width children must reflow to their assigned fractional tracks." - (let* ((left (ebox-create :content "left" :width 'stretch)) + (let* ((left (ebox-test-box :content "left" :width 'stretch)) (calls 0) (entry-left (list :node left :column 1 :column-span 1)) (rendered (make-hash-table :test #'eq))) @@ -85,36 +85,36 @@ (ert-deftest ebox-grid-constrains-auto-width-children-to-track-size () "Auto-width grid children should render within their assigned track." (let* ((ebox-viewport-width 120) - (node (ebox-grid + (node (ebox-test-grid :width '(120) :grid-template-columns '((50) (50)) :gap '(0 (10)) :border "#334155" - (ebox-create :content "A\nA-long" :padding '(0 (4))) - (ebox-create :content "B\nB-long" :padding '(0 (4))))) + (ebox-test-box :content "A\nA-long" :padding '(0 (4))) + (ebox-test-box :content "B\nB-long" :padding '(0 (4))))) (lines (ebox-string-lines (ebox-render node))) (widths (mapcar #'ebox--string-pixel-width lines))) (should (equal widths (make-list (length widths) 120))))) (ert-deftest ebox-grid-composite-auto-row-respects-track-width () "A composite auto-width row must not duplicate its Grid track viewport." - (let* ((node (ebox-grid + (let* ((node (ebox-test-grid :width '(120) :grid-template-columns '((120)) - (ebox-row - (ebox-create :content "Left" :border t) - (ebox-create :content "Right" :border t)))) + (ebox-test-row + (ebox-test-box :content "Left" :border t) + (ebox-test-box :content "Right" :border t)))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node))))) (should (equal widths '(120))))) (ert-deftest ebox-grid-sizes-auto-row-after-assigned-width-wrap () "Auto rows should use the height of width-constrained child content." - (let* ((node (ebox-grid + (let* ((node (ebox-test-grid :width '(40) :grid-template-columns '((38)) :border "#334155" - (ebox-create + (ebox-test-box :content "WIDE CONTENT WIDE CONTENT WIDE CONTENT WIDE CONTENT" :wrap-mode 'word))) (lines (ebox-string-lines (ebox-render node))) @@ -122,35 +122,57 @@ (should (= (length lines) 2)) (should (equal widths (make-list (length widths) 40))))) +(ert-deftest ebox-grid-sizes-column-row-after-assigned-width-wrap () + "A composite Column row should use its final track-width text height." + (let* ((node + (ebox-test-grid + :width '(20) + :grid-template-columns '((20)) + :grid-auto-rows 'auto + (ebox-test-column + :width '(20) + (ebox-test-box + :content "fractional symbols\n1fr distributes by weight" + :padding '(0 (2)) :border "#B85C3C" + :wrap-mode 'word) + (ebox-test-box :content "body" :height 1)))) + (lines (ebox-string-lines (ebox-render node))) + (plain (mapcar #'substring-no-properties lines))) + (should (> (length lines) 3)) + (should (equal (mapcar #'ebox--string-pixel-width lines) + (make-list (length lines) 20))) + (should (cl-some (lambda (line) (string-match-p "weight" line)) plain)) + (should (string-match-p "body" (car (last plain)))))) + (ert-deftest ebox-grid-aligns-every-line-before-border-render () "Grid wrapper borders should stay on one right edge across child lines." - (let* ((node (ebox-grid + (let* ((node (ebox-test-grid :width '(120) :grid-template-columns '((30) auto 1fr) :grid-template-rows '(2) :gap '(1 (4)) :justify-content 'space-between :border "#334155" - (ebox-create :content "FIXED\n30 PX") - (ebox-create :content "AUTO\nINTRINSIC") - (ebox-create :content "1FR\nREMAINDER"))) + (ebox-test-box :content "FIXED\n30 PX") + (ebox-test-box :content "AUTO\nINTRINSIC") + (ebox-test-box :content "1FR\nREMAINDER"))) (lines (ebox-string-lines (ebox-render node))) (widths (mapcar #'ebox--string-pixel-width lines))) (should (equal widths (make-list (length widths) 120))))) (ert-deftest ebox-grid-keeps-padded-items-inside-column-width () "Stack padding should use the widest Grid line instead of its first line." - (let* ((header (ebox-create :content "Header" :width '(720))) - (grid (ebox-grid + (let* ((header (ebox-test-box :content "Header" :width '(720))) + (grid (ebox-test-grid :width '(718) :grid-template-columns '((220) 1fr) :gap '(1 (12)) :border "#C97252" - (ebox-create :content "Fixed" :width '(196) + (ebox-test-box :content "Fixed" :width '(196) :padding '(1 (12))) - (ebox-create :content "Fractional" :width '(460) + (ebox-test-box :content "Fractional" :width '(460) :padding '(1 (12))))) - (lines (ebox-string-lines (ebox-render (ebox-column header grid)))) + (lines (ebox-string-lines (ebox-render (ebox-test-column header grid)))) (widths (mapcar #'ebox--string-pixel-width lines))) (should (= (apply #'max widths) 720)))) @@ -158,23 +180,23 @@ "Explicit placement should support a cell spanning two columns." (let ((plain (ebox-grid-test--plain - (ebox-grid + (ebox-test-grid :grid-template-columns '((20) (20)) :grid-template-rows '(1 1) - (ebox-create :content "Header" :grid-column '(1 :span 2)) - (ebox-create :content "Left" :grid-row 2 :grid-column 1) - (ebox-create :content "Right" :grid-row 2 :grid-column 2))))) + (ebox-test-box :content "Header" :grid-column '(1 :span 2)) + (ebox-test-box :content "Left" :grid-row 2 :grid-column 1) + (ebox-test-box :content "Right" :grid-row 2 :grid-column 2))))) (should (string-match-p "Header" plain)) (should (string-match-p "Left.*Right" plain)))) (ert-deftest ebox-grid-uses-implicit-track-templates () "Implicit columns should use `:grid-auto-columns' when they grow." - (let* ((node (ebox-grid + (let* ((node (ebox-test-grid :grid-template-columns '((20)) :grid-auto-columns '((30)) :grid-auto-flow 'column - (ebox-create :content "A") - (ebox-create :content "B"))) + (ebox-test-box :content "A") + (ebox-test-box :content "B"))) (line (car (ebox-string-lines (ebox-render node))))) (should (= (ebox--string-pixel-width line) 50)))) @@ -182,25 +204,25 @@ "Implicit rows should retain the declared template column count." (let ((plain (ebox-grid-test--plain - (ebox-grid + (ebox-test-grid :grid-template-columns '((20) (20) (20)) - (ebox-create :content "A") - (ebox-create :content "B") - (ebox-create :content "C") - (ebox-create :content "D") - (ebox-create :content "E") - (ebox-create :content "F"))))) + (ebox-test-box :content "A") + (ebox-test-box :content "B") + (ebox-test-box :content "C") + (ebox-test-box :content "D") + (ebox-test-box :content "E") + (ebox-test-box :content "F"))))) (dolist (letter '("A" "B" "C" "D" "E" "F")) (should (string-match-p letter plain))))) (ert-deftest ebox-grid-supports-repeat-minmax-and-content-alignment () "Grid should normalize repeat/minmax tracks and align fixed tracks." - (let* ((node (ebox-grid + (let* ((node (ebox-test-grid :width '(100) :justify-content 'center :grid-template-columns '((minmax (20) (30)) (repeat 1 (20))) - (ebox-create :content "A") - (ebox-create :content "B"))) + (ebox-test-box :content "A") + (ebox-test-box :content "B"))) (line (car (ebox-string-lines (ebox-render node))))) (should (= (ebox--string-pixel-width line) 100)) (should (= (length (seq-filter (lambda (char) (= char ?\s)) line)) @@ -210,15 +232,15 @@ "Invalid flow and non-positive spans should be rejected." (should-error (ebox-render - (ebox-grid :grid-auto-flow 'diagonal (ebox-create :content "A")))) + (ebox-test-grid :grid-auto-flow 'diagonal (ebox-test-box :content "A")))) (should-error (ebox-render - (ebox-grid - (ebox-create :content "A" :grid-column-span 0)))) + (ebox-test-grid + (ebox-test-box :content "A" :grid-column-span 0)))) (should-error (ebox-render - (ebox-grid - (ebox-create :content "A" :grid-column 0))))) + (ebox-test-grid + (ebox-test-box :content "A" :grid-column 0))))) (ert-deftest ebox-grid-rejects-invalid-ecss-placement-before-layout () "ECSS rules should reject invalid Grid placement before layout runs." @@ -233,10 +255,10 @@ (should (ebox-style-add-rule ".valid" (list :grid-column placement)))))) (ert-deftest ebox-grid-region-update-reflows-grid-child () - "Grid children should retain region updates through buffer rendering." - (let* ((left (ebox-create :content "A" :id "left" :width 20)) - (right (ebox-create :content "B" :id "right" :width 20)) - (node (ebox-grid :grid-template-columns '((20) (20)) left right)) + "Replacing one typed Grid child should reflow and preserve its sibling." + (let* ((left (ebox-test-box :content "A" :host-ref 'left :width 20)) + (right (ebox-test-box :content "B" :id "right" :width 20)) + (node (ebox-test-grid :grid-template-columns '((20) (20)) left right)) (buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-grid-update*") node))) (unwind-protect @@ -244,8 +266,11 @@ (should (with-current-buffer buffer (and (string-match-p "A" (buffer-string)) (string-match-p "B" (buffer-string))))) - (ebox-region-update (ebox-region-resolve buffer "left") - :content "Updated") + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-host-ref + candidate 'left + (ebox-test-box :content "Updated" :host-ref 'left :width 20)) + (ebox-commit buffer candidate)) (let ((updated (with-current-buffer buffer (buffer-string)))) (should (string-match-p "Updated" updated)) (should (string-match-p "B" updated)))) @@ -257,9 +282,10 @@ (let ((node (ebox-build '(grid :grid-template-columns ((20) (20)) - (box :content "A") - (box :content "B"))))) - (should (eq (plist-get node :ebox-type) 'grid)) + (box "A") + (box "B"))))) + (should (ebox-box-node-p node)) + (should (eq (ebox-layout-config-kind (ebox-box-node-layout node)) 'grid)) (should (string-match-p "A" (ebox-grid-test--plain node))) (should (string-match-p "B" (ebox-grid-test--plain node))))) @@ -268,9 +294,10 @@ (let ((node (ebox-build '(grid :grid-template-columns ((20) (20)) - (box :grid-column (1 :span 2) :content "Header"))))) + (box :grid-column (1 :span 2) "Header"))))) (should (string-match-p "Header" (ebox-grid-test--plain node))) - (should (equal (plist-get (car (plist-get node :children)) :grid-column) + (should (equal (ebox-style-node-specified-value + (car (ebox-box-node-children node)) :grid-column) '(1 :span 2))))) (provide 'ebox-grid-tests) diff --git a/tests/ebox-package-tests.el b/tests/ebox-package-tests.el index 07ab7f9..61a01ec 100644 --- a/tests/ebox-package-tests.el +++ b/tests/ebox-package-tests.el @@ -162,15 +162,6 @@ ("ebox-call-with-render-burst" . "defun") ("ebox-render" . "defun") ("ebox-region-ids" . "defun") - ("ebox-concat" . "defun") - ("ebox-spacer" . "defun") - ("ebox-row" . "defun") - ("ebox-stack" . "defun") - ("ebox-column" . "defun") - ("ebox-flex-item" . "defun") - ("ebox-flex" . "defun") - ("ebox-grid-fr" . "defun") - ("ebox-grid" . "defun") ("ebox-grid-layout-create" . "defun") ("ebox-normal-layout-create" . "defun") ("ebox-row-layout-create" . "defun") @@ -179,7 +170,6 @@ ("ebox-text-create" . "defun") ("ebox-box-create" . "defun") ("ebox-build" . "defun") - ("ebox-create" . "defun") ("ebox-scroll-down" . "defun") ("ebox-scroll-up" . "defun") ("ebox-region-update" . "defun") @@ -212,22 +202,15 @@ ebox-candidate-replace-root ebox-candidate-replace-host-ref ebox-clear-cache - ebox-column ebox-normal-layout-create ebox-row-layout-create ebox-column-layout-create ebox-text-create ebox-box-create ebox-commit - ebox-concat - ebox-create ebox-display-buffer ebox-display-signature - ebox-flex ebox-flex-layout-create - ebox-flex-item - ebox-grid - ebox-grid-fr ebox-grid-layout-create ebox-host-ref-bounds ebox-host-ref-position @@ -241,7 +224,6 @@ ebox-render-burst-end ebox-render-to-buffer ebox-rerender-buffer-with-context - ebox-row ebox-scroll-down ebox-scroll-page-down ebox-scroll-page-up @@ -252,11 +234,17 @@ ebox-selector-query-all ebox-selector-query-buffer ebox-selector-update-buffer - ebox-spacer - ebox-stack ebox-string-pixel-width)) (should (memq entry ebox-public-api)))) +(ert-deftest ebox-legacy-creation-surface-is-absent () + "Only typed constructors and `ebox-build' may create public nodes." + (dolist (entry '(ebox-test-box ebox-concat ebox-stack ebox-spacer + ebox-row ebox-column ebox-flex ebox-flex-item + ebox-grid ebox-grid-fr)) + (should-not (fboundp entry)) + (should-not (memq entry ebox-public-api)))) + (ert-deftest ebox-live-buffer-publication-uses-only-tp-surfaces () "The facade should expose no raw live-buffer writer beside TP surfaces." (require 'ebox) diff --git a/tests/ebox-selector-tests.el b/tests/ebox-selector-tests.el index 52028a0..8ca1253 100644 --- a/tests/ebox-selector-tests.el +++ b/tests/ebox-selector-tests.el @@ -21,22 +21,22 @@ (defun ebox-selector-test--width-unit-form (width) "Return a layout whose root WIDTH constrains nested auto layout." `(column :id root :width ,width - (box :content "Header text wraps when the root is narrow" - :padding (0 (12)) :border "#111111") + (box :padding (0 (12)) :border "#111111" + "Header text wraps when the root is narrow") (flex :flex-flow (row wrap) :column-gap (12) :padding (0 (12)) :border "#D97757" (column - (box :content "row" :width (220) :border "#D97757") + (box :width (220) :border "#D97757" "row") (flex :width (220) :height 4 :border "#D97757" - (item (box :content "A" :width (42))) - (item (box :content "B" :width (42))) - (item (box :content "C" :width (42))))) + (box :width (42) "A") + (box :width (42) "B") + (box :width (42) "C"))) (column - (box :content "row-reverse" :width (220) :border "#D97757") + (box :width (220) :border "#D97757" "row-reverse") (flex :width (220) :height 4 :border "#D97757" - (item (box :content "A" :width (42))) - (item (box :content "B" :width (42))) - (item (box :content "C" :width (42)))))))) + (box :width (42) "A") + (box :width (42) "B") + (box :width (42) "C")))))) (defun ebox-selector-test--rendered-widths (rendered) "Return pixel widths for RENDERED lines." @@ -69,8 +69,8 @@ (ebox-selector-test--reset-runtime-state) (let* ((node (ebox-build '(column :id root :class page - (box :id summary :class (card warning) :content "A") - (box :class card :content "B")))) + (box :id summary :class (card warning) "A") + (box :class card "B")))) (matches (ebox-selector-query-all node ".warning"))) (should (= (length matches) 1)) (should (equal (plist-get (car matches) :region-id) @@ -83,8 +83,8 @@ (let* ((node (ebox-build '(column :class page (row :class toolbar - (box :class action :content "Run")) - (box :class action :content "Loose"))))) + (box :class action "Run")) + (box :class action "Loose"))))) (should (= (length (ebox-selector-query-all node ".toolbar > box")) 1)) (should (= (length (ebox-selector-query-all node ".page .action")) 2)))) @@ -93,9 +93,9 @@ (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"))))) + (box :id first "A") + (box :id second :class later "B") + (box :id third :class later "C"))))) (should (equal (mapcar (lambda (match) (ebox-tree-node-id (plist-get match :node))) @@ -112,8 +112,8 @@ (ebox-selector-test--reset-runtime-state) (let ((node (ebox-build '(column - (box :class card :content "A") - (box :content "B")))) + (box :class card "A") + (box "B")))) (calls 0) (matcher (symbol-function 'ecss-selector-match-p))) (cl-letf (((symbol-function 'ecss-selector-match-p) @@ -144,9 +144,9 @@ (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)))))) + (let ((node (ebox-test-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]")))) @@ -155,8 +155,8 @@ (ebox-selector-test--reset-runtime-state) (let* ((node (ebox-build '(column - (box :id summary :class card :content "Summary") - (box :class card :content "Other")))) + (box :id summary :class card "Summary") + (box :class card "Other")))) buffer) (unwind-protect (progn @@ -178,8 +178,8 @@ (ebox-selector-test--reset-runtime-state) (let* ((node (ebox-build '(column - (box :id summary :class card :content "Summary") - (box :class card :content "Other")))) + (box :id summary :class card "Summary") + (box :class card "Other")))) buffer) (unwind-protect (progn @@ -200,8 +200,8 @@ (ebox-selector-test--reset-runtime-state) (let* ((node (ebox-build '(column - (box :id summary :class (card warning) :content "Summary") - (box :class card :content "Other")))) + (box :id summary :class (card warning) "Summary") + (box :class card "Other")))) buffer) (unwind-protect (progn @@ -225,8 +225,8 @@ (let* ((node (ebox-build '(column :class page (row :class toolbar - (box :class action :content "Run")) - (box :class action :content "Loose")))) + (box :class action "Run")) + (box :class action "Loose")))) buffer) (unwind-protect (progn @@ -249,8 +249,8 @@ (ebox-selector-test--reset-runtime-state) (let* ((node (ebox-build '(column :id shell - (box :class target :content "A") - (box :content "B")))) + (box :class target "A") + (box "B")))) buffer) (unwind-protect (progn @@ -262,9 +262,13 @@ (should-not (gethash 'column (plist-get state :selector-type-table))) (should (= (gethash - 'column + 'box (plist-get state :runtime-type-count-table)) - 1))) + 3)) + (should (= (gethash + 'text + (plist-get state :runtime-type-count-table)) + 2))) (should-not (ebox-selector-query-buffer buffer "column .target"))) (when (and buffer (buffer-live-p buffer)) @@ -276,8 +280,8 @@ (let* ((node (ebox-build '(column (box :id card - :content "Text wraps when narrow" - :wrap-mode word)))) + :wrap-mode word + "Text wraps when narrow")))) buffer) (unwind-protect (progn @@ -299,7 +303,7 @@ (ebox-selector-test--reset-runtime-state) (let* ((node (ebox-build '(column - (box :id card :content "Card" :border "#111111")))) + (box :id card :border "#111111" "Card")))) buffer) (unwind-protect (progn @@ -321,7 +325,7 @@ (ebox-selector-test--reset-runtime-state) (let* ((node (ebox-build '(column - (box :id card :content "Card")))) + (box :id card "Card")))) buffer) (unwind-protect (progn @@ -371,10 +375,10 @@ (ebox-selector-test--reset-runtime-state) (let* ((target-node (ebox-build '(column - (box :id target-card :content "Target")))) + (box :id target-card "Target")))) (other-node (ebox-build '(column - (box :id other-card :content "Other")))) + (box :id other-card "Other")))) target-buffer other-buffer) (unwind-protect (progn @@ -416,7 +420,7 @@ (ebox-selector-test--reset-runtime-state) (let* ((node (ebox-build '(column - (box :id card :content "Card")))) + (box :id card "Card")))) buffer) (unwind-protect (progn @@ -449,8 +453,8 @@ (ebox-selector-test--reset-runtime-state) (let* ((node (ebox-build '(column - (box :class card :content "One") - (box :class card :content "Two")))) + (box :class card "One") + (box :class card "Two")))) buffer) (unwind-protect (progn @@ -485,8 +489,8 @@ (ebox-selector-test--reset-runtime-state) (let* ((node (ebox-build '(column - (box :class card :content "One") - (box :class card :content "Two")))) + (box :class card "One") + (box :class card "Two")))) buffer) (unwind-protect (progn diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 0f46e6e..7dc81cf 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -6,7 +6,10 @@ (unless load-file-name (error "This test file must be loaded from disk, not eval'ed directly")) +(defvar ebox-native-reflow-module-path) (setq load-prefer-newer t) +(setq ebox-native-reflow-module-path + (getenv "EBOX_NATIVE_REFLOW_MODULE_PATH")) (load-file (expand-file-name "../ebox.el" (file-name-directory load-file-name))) @@ -324,44 +327,44 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (list (cons 'box (lambda () - (ebox-create :key 'box :content (ebox-surface-test--interactive-content) + (ebox-test-box :key 'box :content (ebox-surface-test--interactive-content) :width '(120) :padding '(1 (4)) :border "#334155" :bgcolor "#E2E8F0" :color "#0F172A"))) (cons 'row-column (lambda () - (ebox-column - (ebox-row - (ebox-create :key 'left :content "Left" :width '(70) + (ebox-test-column + (ebox-test-row + (ebox-test-box :key 'left :content "Left" :width '(70) :bgcolor "#DBEAFE" :color "#172554") - (ebox-create :key 'right :content "Right\nDetail" :width '(90) + (ebox-test-box :key 'right :content "Right\nDetail" :width '(90) :bgcolor "#DCFCE7" :color "#14532D")) - (ebox-create :key 'footer :content "Footer" :width '(160) + (ebox-test-box :key 'footer :content "Footer" :width '(160) :bgcolor "#F1F5F9" :color "#0F172A")))) (cons 'flex (lambda () - (ebox-flex + (ebox-test-flex :width '(210) :flex-wrap 'wrap :column-gap '(10) - (ebox-flex-item - (ebox-create :key 'grow :content "Grow" :width '(80) + (ebox-test-flex-item + (ebox-test-box :key 'grow :content "Grow" :width '(80) :bgcolor "#EDE9FE" :color "#2E1065") :flex-grow 1 :flex-basis '(80)) - (ebox-flex-item - (ebox-create :key 'fixed :content "Fixed" :width '(120) + (ebox-test-flex-item + (ebox-test-box :key 'fixed :content "Fixed" :width '(120) :bgcolor "#FFEDD5" :color "#7C2D12"))))) (cons 'grid (lambda () - (ebox-grid + (ebox-test-grid :width '(220) :grid-template-columns '((70) 1fr) :grid-template-rows '(2) :gap '(1 (8)) :border "#475569" - (ebox-create :key 'grid-left :content "A\nAA" + (ebox-test-box :key 'grid-left :content "A\nAA" :bgcolor "#E0F2FE" :color "#0C4A6E") - (ebox-create :key 'grid-right :content "B\nBB" + (ebox-test-box :key 'grid-right :content "B\nBB" :bgcolor "#FCE7F3" :color "#831843")))) (cons 'overflow-scroll (lambda () - (ebox-create :key 'scroll :content "zero\none\ntwo\nthree" + (ebox-test-box :key 'scroll :content "zero\none\ntwo\nthree" :width '(100) :height 2 :overflow 'scroll :bgcolor "#1E293B" :color "#F8FAFC"))))) @@ -585,8 +588,12 @@ candidate cannot hide mutations by restoring the old hash-table pointer." "Serialize and optionally render the Ebox default foreground reset." (let* ((ebox-viewport-width 80) (ebox-viewport-height 20) - (node (ebox-create :content "native" :width 20 - :color 'ebox/default-foreground)) + (node + (ebox-test-box + :width 20 + :ebox-content-node + (ebox-test--text + "native" '(:color ebox/default-foreground)))) (package (ebox-native-reflow--compile-layout-package node)) (document (plist-get package :document)) (styles (plist-get document :styles)) @@ -618,13 +625,47 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (plist-get frame :rendered) (ebox-render node))))))) +(ert-deftest ebox-native-canonical-text-matches-elisp-render () + "Canonical Text IR must preserve Text output and paint properties exactly." + (let* ((ebox-viewport-width 80) + (ebox-viewport-height 20) + (text (ebox-text-create :value "native" :font-weight 'bold)) + (node (ebox-box-create :layout (ebox-normal-layout-create) + :children (list text))) + (package (ebox-native-reflow--compile-layout-package node)) + (root (plist-get (plist-get package :document) :root))) + (should (eq (plist-get (plist-get package :document) :version) 2)) + (should (equal (plist-get root :type) "box")) + (should (equal (plist-get root :content-region-id) + (ebox--ensure-region-id text))) + (should (eq (plist-get root :child) :null)) + (when (ebox-native-reflow-layout-ready-p) + (let ((frame + (ebox-native-reflow-execute-sync + node + '(:key 1 :viewport-width 80 :viewport-height 20 + :root-width 80 :runtime-revision 0 + :context-hash 0 :complete t) + package)) + (normal + (let ((ebox--surface-materialization-active t) + (ebox--paint-origin-capture-p t)) + (ebox--render-layout node)))) + ;; Surface consumes the paint-origin marker into its fragment ledger; + ;; native tape already carries that baseline out of band. + (ebox-surface--rendered-fragments normal) + (should (plist-get frame :native-frame)) + (should (equal-including-properties + (plist-get frame :rendered) + normal)))))) + (ert-deftest ebox-native-layout-effect-fragments-match-render-scan () "Decode native paint fragments without scanning rendered properties." (skip-unless (ebox-native-reflow-layout-ready-p)) (let* ((ebox-viewport-width 80) (ebox-viewport-height 20) (content (propertize "native" 'face 'italic 'help-echo "source")) - (node (ebox-create :content content :width 20 + (node (ebox-test-box :content content :width 20 :color "#f0f0f0" :bgcolor "#101010")) (package (ebox-native-reflow--compile-layout-package node)) (normal @@ -755,21 +796,6 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (dolist (object captured) (should-not (tp-object-live-p object))))) -(ert-deftest ebox-surface-static-projection-skips-empty-cascade () - "Inline-only static projection should not compute per-node ECSS styles." - (let ((ebox-style-stylesheet (ecss-stylesheet-create)) - (calls 0) - (original (symbol-function 'ebox-style-compute-subject))) - (cl-letf (((symbol-function 'ebox-style-compute-subject) - (lambda (&rest args) - (cl-incf calls) - (apply original args)))) - (let ((rendered - (ebox-surface-test--render-fresh - (cdr (assq 'box (ebox-surface-test--fixtures))) t))) - (should (= calls 0)) - (should (string-match-p "Open" (substring-no-properties rendered))))))) - (ert-deftest ebox-surface-inline-inheritance-keeps-empty-cascade () "Inline inherited values should still compute without stylesheet rules." (let ((ebox-style-stylesheet (ecss-stylesheet-create)) @@ -781,25 +807,28 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (apply original args)))) (ebox-surface-test--render-fresh (lambda () - (ebox-create + (ebox-test-box :font-height 1.25 :ebox-content-node - (ebox-create :content "Inherited"))) + (ebox-test-box :content "Inherited"))) t)) (should (> calls 0)))) (ert-deftest ebox-surface-inline-inheritance-crosses-transparent-layout () "Transparent layout nodes do not consume inherited paint themselves." (let ((closed - (ebox-create + (ebox-test-box :color "red" :ebox-content-node - (ebox-column (ebox-create :content "child" :color "blue")))) + (ebox-test-column + (ebox-test-box + :ebox-content-node + (ebox-test--text "child" '(:color "blue")))))) (open - (ebox-create + (ebox-test-box :color "red" :ebox-content-node - (ebox-column (ebox-create :content "child"))))) + (ebox-test-column (ebox-test-box :content "child"))))) (should-not (ebox-surface--inline-inheritance-required-p closed)) (should (ebox-surface--inline-inheritance-required-p open)))) @@ -817,12 +846,12 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-viewport-width 240) (ebox-viewport-height 8) (child - (ebox-column - (ebox-create :key 'responsive-child + (ebox-test-column + (ebox-test-box :key 'responsive-child :content "fixed local viewport" :width '(viewport) :height 1))) - (wrapper (ebox-create :width '(160))) + (wrapper (ebox-test-box :width '(160))) (renders 0) (original (symbol-function 'ebox--render-cache-render-with-scroll-actions))) @@ -830,9 +859,11 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (lambda (node) (cl-incf renders) (funcall original node)))) - (let ((first (ebox--render-node-as-box-content child wrapper)) - (second (ebox--render-node-as-box-content child wrapper))) - (should (= renders 1)) + (let* ((first (ebox--render-node-as-box-content child wrapper)) + (first-render-count renders) + (second (ebox--render-node-as-box-content child wrapper))) + (should (> first-render-count 0)) + (should (= renders first-render-count)) (should (equal-including-properties first second)))))) (ert-deftest ebox-surface-plan-stays-free-of-runtime-state () @@ -975,7 +1006,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." "One source description should mount into two independent TP surfaces." (ebox-surface-test--reset-render-state) (let* ((source - (ebox-create :key 'shared :content "Shared" :width '(100) + (ebox-test-box :key 'shared :content "Shared" :width '(100) :bgcolor "#E2E8F0" :color "#0F172A")) (first-buffer (generate-new-buffer " *ebox-surface-first*")) (second-buffer (generate-new-buffer " *ebox-surface-second*")) @@ -1014,9 +1045,9 @@ candidate cannot hide mutations by restoring the old hash-table pointer." surface) (unwind-protect (let* ((first-source - (ebox-row - (ebox-create :key 'left :content "Left" :width '(60)) - (ebox-create :key 'right :content "Right" :width '(60)))) + (ebox-test-row + (ebox-test-box :key 'left :content "Left" :width '(60)) + (ebox-test-box :key 'right :content "Right" :width '(60)))) (_mount (setq surface (tp-surface-mount @@ -1026,9 +1057,9 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (left (ebox-surface-test--object-by-key first-state 'left)) (right (ebox-surface-test--object-by-key first-state 'right)) (next-source - (ebox-row - (ebox-create :key 'right :content "Right!" :width '(60)) - (ebox-create :key 'left :content "Left!" :width '(60))))) + (ebox-test-row + (ebox-test-box :key 'right :content "Right!" :width '(60)) + (ebox-test-box :key 'left :content "Left!" :width '(60))))) (tp-surface-update surface (ebox-surface-producer next-source first-state)) (let ((next-state (tp-surface-client-state surface))) @@ -1045,156 +1076,6 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-surface-scoped-update-reuses-copy-on-write-candidate () - "A fixed-footprint region update should hand a path candidate to the surface." - (ebox-surface-test--reset-render-state) - (let ((buffer (generate-new-buffer " *ebox-surface-isolated-candidate*")) - (copy-count 0) - (validation-count 0) - (reconcile-count 0) - (runtime-index-count 0) - (original-copy (symbol-function 'ebox-tree-copy-node-structure)) - (original-validate (symbol-function 'ebox-tree-validate-declarative-root)) - (original-reconcile (symbol-function 'ebox-tree-reconcile-runtime)) - (original-runtime-index (symbol-function 'ebox--runtime-index))) - (unwind-protect - (progn - (ebox-render-to-buffer - buffer - (ebox-column - (ebox-create :key 'target :id 'target :content "Before" - :width '(100)) - (ebox-create :key 'sibling :content "Sibling" :width '(100)))) - (let ((handle (ebox-region-resolve buffer "target"))) - (cl-letf (((symbol-function 'ebox-tree-copy-node-structure) - (lambda (&rest args) - (cl-incf copy-count) - (apply original-copy args))) - ((symbol-function 'ebox-tree-validate-declarative-root) - (lambda (&rest args) - (cl-incf validation-count) - (apply original-validate args))) - ((symbol-function 'ebox-tree-reconcile-runtime) - (lambda (&rest args) - (cl-incf reconcile-count) - (apply original-reconcile args))) - ((symbol-function 'ebox--runtime-index) - (lambda (&rest args) - (cl-incf runtime-index-count) - (apply original-runtime-index args)))) - (ebox-region-update handle :content "After"))) - (should (= copy-count 0)) - (should (= validation-count 0)) - (should (= reconcile-count 0)) - (should (= runtime-index-count 0))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - -(ert-deftest ebox-surface-span-update-renders-only-the-owner () - "A fixed-footprint span update should render its owner, not the root tree." - (ebox-surface-test--reset-render-state) - (let ((buffer (generate-new-buffer " *ebox-surface-local-span*")) - (rendered-node-ids nil) - (ensured-node-count 0)) - (unwind-protect - (progn - (ebox-render-to-buffer - buffer - (ebox-column - (ebox-create :key 'target :id 'target :content "Before" - :width '(100)) - (ebox-create :key 'sibling :content "Sibling" :width '(100)))) - (let* ((state (ebox--buffer-render-state buffer)) - (target - (cl-loop for node being the hash-values - of (plist-get state :node-table) - when (equal (plist-get node :key) 'target) - return node)) - (target-id (plist-get target :node-id)) - (handle (ebox-region-resolve buffer "target")) - (original-render (symbol-function 'ebox--render-layout)) - (original-ensure (symbol-function - 'ebox-surface--ensure-node-tree))) - (cl-letf (((symbol-function 'ebox--render-layout) - (lambda (node) - (push (plist-get node :node-id) rendered-node-ids) - (funcall original-render node))) - ((symbol-function 'ebox-surface--ensure-node-tree) - (lambda (&rest args) - (cl-incf ensured-node-count) - (apply original-ensure args)))) - (ebox-region-update handle :content "After")) - (should (equal (nreverse rendered-node-ids) (list target-id))) - (should (= ensured-node-count 0)) - (let ((expected (ebox-render (ebox--buffer-root-node buffer)))) - (should - (equal-including-properties - expected - (with-current-buffer buffer (buffer-string))))))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - -(ert-deftest ebox-surface-partial-row-span-update-renders-only-the-owner () - "A partial row owner should patch disjoint slots without full projection." - (ebox-surface-test--reset-render-state) - (let ((buffer (generate-new-buffer " *ebox-surface-partial-row*")) - (rendered-node-ids nil) - (ensured-node-count 0)) - (unwind-protect - (progn - (ebox-render-to-buffer - buffer - (ebox-row - (ebox-create :key 'left :content "Left\ndetail" :width '(52)) - (ebox-create :key 'target :id 'target - :content "Before\ndetail" :width '(52)) - (ebox-create :key 'right :content "Right\ndetail" :width '(52)))) - (let* ((state (ebox--buffer-render-state buffer)) - (target - (cl-loop for node being the hash-values - of (plist-get state :node-table) - when (equal (plist-get node :key) 'target) - return node)) - (target-id (plist-get target :node-id)) - (snapshot - (ebox--ensure-layout-snapshot-details buffer target-id)) - (spans (plist-get snapshot :buffer-spans)) - (handle (ebox-region-resolve buffer "target")) - (original-render (symbol-function 'ebox--render-layout)) - (original-ensure - (symbol-function 'ebox-surface--ensure-node-tree))) - (with-current-buffer buffer - (should (ebox-buffer--partial-line-slots-p spans)) - (should-not (ebox--spans-contiguous-lines-p spans))) - (cl-letf (((symbol-function 'ebox--render-layout) - (lambda (node) - (push (plist-get node :node-id) rendered-node-ids) - (funcall original-render node))) - ((symbol-function 'ebox-surface--ensure-node-tree) - (lambda (&rest args) - (cl-incf ensured-node-count) - (apply original-ensure args)))) - (ebox-region-update handle :content "UPDATED\ndetail")) - (should (equal (nreverse rendered-node-ids) (list target-id))) - (should (= ensured-node-count 0)) - (should (eq (plist-get (ebox-buffer-update-report buffer) - :strategy) - 'span-patch)) - (let* ((surface (with-current-buffer - buffer ebox-surface--buffer-surface)) - (report (tp-surface-report surface)) - (object-count - (plist-get (tp-surface-inspect surface) :object-count))) - (should (< (plist-get report :reconciled-objects) - object-count))) - (let ((expected (ebox-render (ebox--buffer-root-node buffer)))) - (should - (equal-including-properties - expected - (with-current-buffer buffer (buffer-string))))))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - (ert-deftest ebox-surface-cow-fallback-preserves-published-tree-on-failure () "A widened path candidate must not mutate the published tree before rollback." (ebox-surface-test--reset-render-state) @@ -1205,10 +1086,10 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (progn (ebox-render-to-buffer buffer - (ebox-column - (ebox-create :key 'target :id 'target :content "Before" + (ebox-test-column + (ebox-test-box :key 'target :id 'target :content "Before" :width '(100)) - (ebox-create :key 'sibling :content "Sibling" :width '(100)))) + (ebox-test-box :key 'sibling :content "Sibling" :width '(100)))) (let* ((state (ebox--buffer-render-state buffer)) (contents (with-current-buffer buffer @@ -1231,11 +1112,10 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (with-current-buffer buffer (buffer-substring (point-min) (point-max))) contents)) - (should (equal (ebox-get + (should (equal (ebox--region-update-content-value (ebox--root-region-box (plist-get state :root-node) - region-id) - :content) + region-id)) "Before")))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -1251,13 +1131,13 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (funcall original producer)))) (should (stringp (ebox-render - (ebox-create :content "Materialized" :width '(100)))))) + (ebox-test-box :content "Materialized" :width '(100)))))) (should (= calls 0)))) (ert-deftest ebox-render-is-repeatable-without-consuming-runtime-identities () "Ephemeral rendering should be exact and leave live identity counters alone." (ebox-surface-test--reset-render-state) - (let* ((source (ebox-create :content "Repeatable" :width '(100))) + (let* ((source (ebox-test-box :content "Repeatable" :width '(100))) (first (ebox-render source)) (second (ebox-render source))) (should (equal-including-properties first second)) @@ -1275,7 +1155,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (progn (ebox-render-to-buffer buffer - (ebox-create :content "Mounted" :width '(100))) + (ebox-test-box :content "Mounted" :width '(100))) (setq surface (with-current-buffer buffer ebox-surface--buffer-surface)) (should (tp-surface-live-p surface)) @@ -1296,7 +1176,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-render-to-buffer buffer - (ebox-create :content "Viewport" :width '(viewport))) + (ebox-test-box :content "Viewport" :width '(viewport))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (signals @@ -1340,7 +1220,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-render-to-buffer buffer (apply - #'ebox-flex + #'ebox-test-flex :key 'viewport-reflow-root :width '(viewport) :flex-wrap 'wrap @@ -1348,8 +1228,8 @@ candidate cannot hide mutations by restoring the old hash-table pointer." :row-gap 1 (cl-loop for index below 12 collect - (ebox-flex-item - (ebox-create + (ebox-test-flex-item + (ebox-test-box :key (list 'viewport-reflow-item index) :content (format "item-%02d" index) :width '(70) @@ -1396,12 +1276,164 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-viewport-resize-retains-native-frame-continuity () + "A topology-stable resize should keep the committed native frame session." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-native-viewport-continuity*"))) + (unwind-protect + (let ((ebox-viewport-width 180) + (ebox-viewport-height 6) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-render-to-buffer + buffer + (ebox-test-box :key 'native-viewport-root + :content "Viewport" + :width '(viewport))) + (let* ((state (ebox--buffer-render-state buffer)) + (session (list 'native-session)) + (candidate-session (list 'candidate-native-session)) + (pending (list :generation 1 :key 1 + :confirmed-revision 1)) + (rendered + (with-current-buffer buffer + (buffer-substring (point-min) (point-max)))) + (fragment-template [[0 1 0 nil nil nil nil nil]]) + (owned-ranges (list (list :start 0 :end 1))) + (postorder (vector (plist-get state :root-node)))) + (plist-put state :projection-kind 'native-frame) + (plist-put state :native-sync-session session) + (plist-put state :native-sync-pending pending) + (plist-put state :native-sync-confirmed-p nil) + (plist-put state :native-committed-rendered rendered) + (plist-put state :native-committed-fragment-template + fragment-template) + (plist-put state :native-committed-owned-ranges owned-ranges) + (plist-put state :native-node-postorder postorder) + (cl-letf (((symbol-function + 'ebox-native-commit-projection-eligible-p) + (lambda (&rest _) t)) + ((symbol-function 'ebox-native-reflow-fork-session) + (lambda (_session &rest _) candidate-session))) + (let* ((display-signature + (with-current-buffer buffer + (ebox--current-display-signature))) + (commit + (ebox-incremental-prepare-viewport-commit + buffer 320 6 'width display-signature t)) + (report (plist-get commit :report-base)) + (overrides (plist-get commit :state-overrides))) + (should (eq (plist-get commit :projection-kind) + 'native-frame)) + (should (= (plist-get report :target-viewport-width) 320)) + (should (= (plist-get report :target-viewport-height) 6)) + (should (eq (plist-get overrides :native-sync-session) + candidate-session)) + (should-not (eq (plist-get overrides :native-sync-session) + session)) + (should-not (plist-get overrides :native-sync-pending)) + (should-not + (plist-get overrides :native-sync-confirmed-p)) + (should (eq (plist-get overrides + :native-committed-rendered) + rendered)) + (should (eq (plist-get overrides + :native-committed-fragment-template) + fragment-template)) + (should (eq (plist-get overrides + :native-committed-owned-ranges) + owned-ranges)) + (should (eq (plist-get overrides :native-node-postorder) + postorder)) + (should (plist-get overrides :native-topology-stable-p)) + (should (plist-member overrides :native-touched-node-ids)) + (should-not (plist-get overrides :native-touched-node-ids)))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-native-viewport-resize-owns-private-session-lifecycle () + "Native resize forks, rolls back, commits, and releases sessions exactly." + (skip-unless (ebox-native-reflow-layout-ready-p)) + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-native-viewport-rollback*")) + candidate-session committed-session) + (unwind-protect + (let ((ebox-viewport-width 180) + (ebox-viewport-height 6) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-render-to-buffer + buffer + (ebox-test-box :key 'native-viewport-rollback + :content "Viewport" + :width '(viewport))) + (let* ((surface + (with-current-buffer buffer ebox-surface--buffer-surface)) + (state (tp-surface-client-state surface)) + (root (plist-get state :root-node)) + (root-object (plist-get root :surface-object)) + (session (plist-get state :native-sync-session)) + (generation + (ebox-native-reflow-session-generation session)) + (revision (tp-surface-revision surface)) + (contents + (with-current-buffer buffer + (buffer-substring (point-min) (point-max)))) + (original-fork + (symbol-function 'ebox-native-reflow-fork-session))) + (should (eq (plist-get state :projection-kind) 'native-frame)) + (cl-letf + (((symbol-function 'ebox-native-reflow-fork-session) + (lambda (&rest arguments) + (setq candidate-session + (apply original-fork arguments)))) + (tp--surface-publication-step-function + (lambda (step _surface) + (when (eq step 'client-state) + (error "Reject native viewport publication"))))) + (should-error + (ebox-rerender-buffer-with-context buffer 320 6))) + (should candidate-session) + (should (ebox-native-reflow-session-released-p + candidate-session)) + (should-not (ebox-native-reflow-session-released-p session)) + (should (= (ebox-native-reflow-session-generation session) + generation)) + (should (= (tp-surface-revision surface) revision)) + (should (eq (tp-surface-client-state surface) state)) + (should (eq (plist-get (plist-get state :root-node) + :surface-object) + root-object)) + (with-current-buffer buffer + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) + contents))) + (let ((report + (ebox-rerender-buffer-with-context buffer 320 6))) + (setq committed-session + (plist-get (ebox--buffer-render-state buffer) + :native-sync-session)) + (should (eq (plist-get report :projection-kind) 'native-frame)) + (should committed-session) + (should-not (eq committed-session session)) + (should (ebox-native-reflow-session-released-p session)) + (should-not (ebox-native-reflow-session-released-p + committed-session)) + (should (eq (plist-get + (plist-get (ebox--buffer-render-state buffer) + :root-node) + :surface-object) + root-object))))) + (when (buffer-live-p buffer) + (kill-buffer buffer)) + (when committed-session + (should (ebox-native-reflow-session-released-p + committed-session)))))) + (ert-deftest ebox-viewport-reflow-retains-final-sized-flex-child-fragments () "Viewport reflow should reuse final-sized Flex child fragments exactly." (ebox-surface-test--reset-render-state) (let ((buffer (generate-new-buffer " *ebox-flex-fragment-retention*")) - (stable-renders 0) - (growing-renders 0) stable-source growing-source) (unwind-protect (let ((ebox-viewport-width 300) @@ -1410,8 +1442,8 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (setq stable-source (let ((node - (ebox-column - (ebox-create :key 'stable-leaf + (ebox-test-column + (ebox-test-box :key 'stable-leaf :content "zero\none\ntwo" :width '(100) :height 1 @@ -1422,8 +1454,8 @@ candidate cannot hide mutations by restoring the old hash-table pointer." node)) (setq growing-source (let ((node - (ebox-column - (ebox-create :key 'growing-leaf + (ebox-test-column + (ebox-test-box :key 'growing-leaf :content "growing" :width '(viewport) :height 1 @@ -1431,26 +1463,17 @@ candidate cannot hide mutations by restoring the old hash-table pointer." :bgcolor "#DBEAFE")))) (plist-put node :key 'growing-source) node)) - (let ((original-render (symbol-function 'ebox-render))) - (cl-letf (((symbol-function 'ebox-native-commit-render) - (lambda (&rest _) nil)) - ((symbol-function 'ebox-render) - (lambda (node) - (cond - ((equal (plist-get node :key) 'stable-source) - (cl-incf stable-renders)) - ((equal (plist-get node :key) 'growing-source) - (cl-incf growing-renders))) - (funcall original-render node)))) + (cl-letf (((symbol-function 'ebox-native-commit-render) + (lambda (&rest _) nil))) (let* ((layout - (ebox-flex + (ebox-test-flex :key 'fragment-root :width '(viewport) :height 2 :flex-wrap 'nowrap - (ebox-flex-item stable-source + (ebox-test-flex-item stable-source :flex-grow 0 :flex-basis '(120)) - (ebox-flex-item growing-source + (ebox-test-flex-item growing-source :flex-grow 1 :flex-basis '(80)))) (items (plist-get layout :children))) (setq stable-source @@ -1465,10 +1488,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (old-stable-object (ebox-surface-test--object-by-key old-state 'stable-source)) - (initial-stable-renders stable-renders) - (initial-growing-renders growing-renders) - report state expected actual - candidate-stable-renders candidate-growing-renders) + report state expected actual) ;; Exercise the fragment boundary independently of the ;; broader render cache; the published fragment table remains ;; the only retained final-sized output for this reflow. @@ -1478,8 +1498,6 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (setq report (ebox-rerender-buffer-with-context buffer 360 6)) (setq state (ebox--buffer-render-state buffer) - candidate-stable-renders stable-renders - candidate-growing-renders growing-renders expected (let ((ebox-viewport-width 360) (ebox-viewport-height 6)) @@ -1496,14 +1514,12 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-surface-test--object-by-key state 'stable-source))) (should (equal-including-properties actual expected)) - (should (= candidate-stable-renders initial-stable-renders)) - (should (> candidate-growing-renders initial-growing-renders)) (should (> ebox-fragment-flex-retention-hit-count 0)) (should (> ebox-fragment-flex-retention-rerender-count 0)) (with-current-buffer buffer (goto-char (point-min)) (should (text-property-search-forward - 'help-echo "stable" t))))))) + 'help-echo "stable" t)))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -1537,7 +1553,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ert-deftest ebox-flex-fragment-key-normalizes-only-height-independent-contexts () "Retained Flex keys ignore height only after an explicit dependency proof." (let ((ebox--layout-fragments-table (make-hash-table :test 'equal)) - (node (ebox-create :content "stable" :width '(80)))) + (node (ebox-test-box :content "stable" :width '(80)))) (cl-labels ((key-at (height dependency) (let ((ebox-viewport-height height)) @@ -1551,7 +1567,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ert-deftest ebox-flex-fragment-key-includes-rendered-region-ownership () "Retained Flex output must not replay stale region text properties." (let* ((ebox--layout-fragments-table (make-hash-table :test 'equal)) - (source (ebox-create :content "stable" :width '(80))) + (source (ebox-test-box :content "stable" :width '(80))) (first-key (ebox-fragment-flex-allocation-key source 'row 80 1 'stretch 80 @@ -1580,8 +1596,8 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-fragment-flex-retention-store 'three entry effects) (should (= (hash-table-count ebox--layout-fragments-table) 2)))) -(ert-deftest ebox-viewport-reflow-falls-back-for-active-stylesheet () - "A stylesheet-dependent viewport change must rebuild styled node objects." +(ert-deftest ebox-viewport-reflow-reuses-stable-active-stylesheet () + "A viewport change should retain styles only while their signature is stable." (ebox-surface-test--reset-render-state) (let ((ebox-style-stylesheet (ecss-stylesheet-create)) (buffer (generate-new-buffer " *ebox-viewport-cascade*")) @@ -1596,7 +1612,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." :layer 'base) (ebox-render-to-buffer buffer - (ebox-create :class 'viewport-cascade + (ebox-test-box :class 'viewport-cascade :content "Cascade" :width '(viewport))) (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) @@ -1604,6 +1620,21 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (cl-incf ensured-node-count) (apply original-ensure args)))) (ebox-rerender-buffer-with-context buffer 240 6)) + (let ((report (ebox-buffer-update-report buffer))) + (should (zerop ensured-node-count)) + (should (eq (plist-get report :projection-kind) + 'viewport-reflow)) + (should (plist-get report :viewport-style-context-stable-p)) + (should (plist-get report :runtime-published))) + (ebox-style-add-rule ".viewport-cascade" + '(:background-color "#DBEAFE") + :layer 'base) + (setq ensured-node-count 0) + (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) + (lambda (&rest args) + (cl-incf ensured-node-count) + (apply original-ensure args)))) + (ebox-rerender-buffer-with-context buffer 260 6)) (let ((report (ebox-buffer-update-report buffer))) (should (> ensured-node-count 0)) (should-not (plist-get report :projection-kind)) @@ -1611,8 +1642,8 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-viewport-reflow-falls-back-for-inline-inheritance () - "An inherited inline value must keep the full styled projection path." +(ert-deftest ebox-viewport-reflow-retains-stable-inline-inheritance () + "Stable inherited Text style should retain its viewport projection exactly." (ebox-surface-test--reset-render-state) (let ((buffer (generate-new-buffer " *ebox-viewport-inheritance*")) (ensured-node-count 0) @@ -1623,20 +1654,31 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-render-to-buffer buffer - (ebox-create + (ebox-test-box :font-height 1.25 :width '(viewport) :ebox-content-node - (ebox-create :content "Inherited"))) + (ebox-test-box :content "Inherited"))) (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) (lambda (&rest args) (cl-incf ensured-node-count) (apply original-ensure args)))) (ebox-rerender-buffer-with-context buffer 240 6)) - (let ((report (ebox-buffer-update-report buffer))) - (should (> ensured-node-count 0)) - (should-not (plist-get report :projection-kind)) - (should (plist-get report :runtime-published)))) + (let* ((state (ebox--buffer-render-state buffer)) + (report (ebox-buffer-update-report buffer)) + (expected + (let ((ebox-viewport-width 240) + (ebox-viewport-height 6)) + (ebox-render (plist-get state :root-node)))) + (actual + (with-current-buffer buffer + (buffer-substring (point-min) (point-max))))) + (should (= ensured-node-count 0)) + (should (eq (plist-get report :projection-kind) + 'viewport-reflow)) + (should (plist-get report :viewport-style-context-stable-p)) + (should (plist-get state :cascade-required-p)) + (should (equal-including-properties actual expected)))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -1646,17 +1688,17 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (list (cons 'nested-scroll (lambda () - (ebox-create :content "outer" + (ebox-test-box :content "outer" :width '(viewport) :height 2 :overflow 'scroll :ebox-content-node - (ebox-create :content "zero\none\ntwo" + (ebox-test-box :content "zero\none\ntwo" :height 2 :overflow 'scroll)))) (cons 'visible-overflow (lambda () - (ebox-create :content "one\ntwo" + (ebox-test-box :content "one\ntwo" :width '(viewport) :height 1 :overflow 'visible))))) @@ -1695,7 +1737,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-render-to-buffer buffer - (ebox-create :key 'root-scroll + (ebox-test-box :key 'root-scroll :content "zero\none\ntwo\nthree" :width '(viewport) :height '(viewport-height) @@ -1741,7 +1783,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-render-to-buffer buffer - (ebox-create :key name :content "One\nTwo" + (ebox-test-box :key name :content "One\nTwo" :width (if (eq axes 'height) '(120) '(viewport)) @@ -1777,7 +1819,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-render-to-buffer buffer - (ebox-create :key 'rollback + (ebox-test-box :key 'rollback :content "zero\none\ntwo\nthree" :width '(viewport) :height '(viewport-height) @@ -1853,7 +1895,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-viewport-height 2)) (ebox-render-to-buffer responsive - (ebox-create :content "zero\none\ntwo\nthree" + (ebox-test-box :content "zero\none\ntwo\nthree" :width '(viewport) :height '(viewport-height) :overflow 'scroll)) @@ -1872,7 +1914,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should (= (tp-signal-subscriber-count signal) 1))) (ebox-render-to-buffer static - (ebox-create :content "Static" :width '(100))) + (ebox-test-box :content "Static" :width '(100))) (setq static-signals (with-current-buffer static ebox-surface--context-signals)) (should (= (tp-signal-subscriber-count @@ -1910,7 +1952,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-render-to-buffer buffer - (ebox-create :content "Static" :width '(100))) + (ebox-test-box :content "Static" :width '(100))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (signals @@ -1950,7 +1992,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-render-to-buffer buffer - (ebox-create :content "Theme" :width '(100))) + (ebox-test-box :content "Theme" :width '(100))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (signals @@ -2055,7 +2097,7 @@ changes. Geometry and non-inherited computed values must remain identical." (ert-deftest ebox-tree-source-signature-ignores-derived-width-proof () "A layout-derived exact-width flag must not dirty declarative content." - (let* ((old (ebox-create :content "Stable" :width '(100))) + (let* ((old (ebox-test-box :content "Stable" :width '(100))) (new (copy-tree old))) (plist-put old :ebox-content-width-exact-p nil) (plist-put new :ebox-content-width-exact-p t) @@ -2174,7 +2216,7 @@ changes. Geometry and non-inherited computed values must remain identical." "The public mount path should never transfer ownership of its source tree." (ebox-surface-test--reset-render-state) (let* ((source - (ebox-create :key 'shared :content "Shared" :width '(100))) + (ebox-test-box :key 'shared :content "Shared" :width '(100))) (first (generate-new-buffer " *ebox-public-first*")) (second (generate-new-buffer " *ebox-public-second*"))) (unwind-protect @@ -2204,7 +2246,7 @@ changes. Geometry and non-inherited computed values must remain identical." (ebox-surface-test--reset-render-state) (let* ((source (generate-new-buffer " *ebox-context-source*")) (target (generate-new-buffer " *ebox-context-target*")) - (node (ebox-create :content "MMMM" :width '(100))) + (node (ebox-test-box :content "MMMM" :width '(100))) (seen-buffers nil) (original (symbol-function 'ebox--render-layout))) (unwind-protect @@ -2234,7 +2276,7 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-create :key 'root :content "Before" :width '(100))) + (ebox-test-box :key 'root :content "Before" :width '(100))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (revision (tp-surface-revision surface)) @@ -2242,7 +2284,7 @@ changes. Geometry and non-inherited computed values must remain identical." (setq report (ebox-commit buffer - (ebox-create :key 'root :content "After" + (ebox-test-box :key 'root :content "After" :width '(100)))) (should (= (tp-surface-revision surface) (1+ revision))) (should (plist-get report :runtime-published)) @@ -2260,7 +2302,7 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-create :key 'root :content "Stable" :width '(100))) + (ebox-test-box :key 'root :content "Stable" :width '(100))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (state (tp-surface-client-state surface)) @@ -2271,7 +2313,7 @@ changes. Geometry and non-inherited computed values must remain identical." (should-error (ebox-commit buffer - (ebox-create :key 'root :content "Rejected" :width '(100)) + (ebox-test-box :key 'root :content "Rejected" :width '(100)) (lambda (_report) (error "Reject publication")))) (should (= (tp-surface-revision surface) revision)) (should (eq (tp-surface-client-state surface) state)) @@ -2292,7 +2334,7 @@ changes. Geometry and non-inherited computed values must remain identical." (ebox-viewport-height 4)) (ebox-render-to-buffer buffer - (ebox-create :key 'root :content "Stable" + (ebox-test-box :key 'root :content "Stable" :width '(viewport))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) @@ -2309,7 +2351,7 @@ changes. Geometry and non-inherited computed values must remain identical." (should-error (ebox-surface-mount-buffer buffer - (ebox-create :key 'root :content "Rejected" + (ebox-test-box :key 'root :content "Rejected" :width '(viewport)) (ebox--update-report nil 'root-rerender) (lambda (_report) (error "Reject context publication")) @@ -2333,12 +2375,12 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-create :key 'root :content "Stable" :width '(100))) + (ebox-test-box :key 'root :content "Stable" :width '(100))) (should (gethash buffer ebox--buffer-render-state-table)) (should-error (ebox-commit buffer - (ebox-create :key 'root :content "Rejected" :width '(100)) + (ebox-test-box :key 'root :content "Rejected" :width '(100)) (lambda (_report) (kill-buffer buffer) (error "Reject publication after teardown")))) @@ -2355,12 +2397,12 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-create :key 'root :host-ref 'root + (ebox-test-box :key 'root :host-ref 'root :content "Before" :width '(100))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'root - (ebox-create :key 'root :host-ref 'root + (ebox-test-box :key 'root :host-ref 'root :content "After" :width '(100))) (let ((report (ebox-commit buffer candidate))) (should (equal report (ebox-buffer-update-report buffer))) @@ -2399,7 +2441,7 @@ changes. Geometry and non-inherited computed values must remain identical." "One logical id should resolve to distinct handles on independent surfaces." (ebox-surface-test--reset-render-state) (let* ((source - (ebox-create :id "status" :content "Ready" :width '(100))) + (ebox-test-box :id "status" :content "Ready" :width '(100))) (first (generate-new-buffer " *ebox-handle-first*")) (second (generate-new-buffer " *ebox-handle-second*"))) (unwind-protect @@ -2422,17 +2464,17 @@ changes. Geometry and non-inherited computed values must remain identical." (when (buffer-live-p buffer) (kill-buffer buffer)))))) -(ert-deftest ebox-region-handle-update-uses-scoped-tp-publication () - "A handle update should publish a scoped TP plan." +(ert-deftest ebox-region-handle-update-publishes-through-tp () + "A canonical Text content update should publish once through TP." (ebox-surface-test--reset-render-state) (let ((buffer (generate-new-buffer " *ebox-scoped-handle*"))) (unwind-protect (progn (ebox-render-to-buffer buffer - (ebox-column - (ebox-create :id "left" :content "Left" :width '(100)) - (ebox-create :id "right" :content "Right" :width '(100)))) + (ebox-test-column + (ebox-test-box :id "left" :content "Left" :width '(100)) + (ebox-test-box :id "right" :content "Right" :width '(100)))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (revision (tp-surface-revision surface)) @@ -2441,9 +2483,10 @@ changes. Geometry and non-inherited computed values must remain identical." (setq report (ebox-region-update handle :content "Changed")) (should (= (tp-surface-revision surface) (1+ revision))) - (should-not (plist-get (tp-surface-report surface) :full-root)) - (should (> (plist-get (tp-surface-report surface) :scope-count) 0)) - (should (eq (plist-get report :tp-render-scope) 'objects)) + (should (plist-get report :runtime-published)) + (should (> (plist-get (tp-surface-report surface) + :text-operations) + 0)) (should (string-match-p "Changed" (with-current-buffer buffer (buffer-string)))))) @@ -2458,7 +2501,7 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-create :id "target" :content "before target after" + (ebox-test-box :id "target" :content "before target after" :width '(200))) (with-current-buffer buffer (goto-char 4)) (let ((point-before (with-current-buffer buffer (point)))) @@ -2477,7 +2520,7 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-create :id "target" :content "Before" :width '(100))) + (ebox-test-box :id "target" :content "Before" :width '(100))) (let* ((match (car (ebox-selector-query-buffer buffer "#target"))) (region-id (plist-get match :region-id))) (should-error (ebox-region-update region-id :content "Wrong") @@ -2498,7 +2541,7 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-create :id "action" :content "Closed" :width '(100))) + (ebox-test-box :id "action" :content "Closed" :width '(100))) (let* ((match (car (ebox-selector-query-buffer buffer "#action"))) (handle (plist-get match :region-handle))) (should (ebox-region-handle-p handle)) @@ -2517,12 +2560,12 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-create :key 'old :id "old" + (ebox-test-box :key 'old :id "old" :content "Old" :width '(100))) (let ((handle (ebox-region-resolve buffer "old"))) (ebox-commit buffer - (ebox-create :key 'new :id "new" + (ebox-test-box :key 'new :id "new" :content "New" :width '(100))) (should-error (ebox-region-update handle :content "Invalid") :type 'user-error))) @@ -2538,7 +2581,7 @@ changes. Geometry and non-inherited computed values must remain identical." (ebox-viewport-height 2)) (ebox-render-to-buffer buffer - (ebox-create :id "scroll" :content "zero\none\ntwo\nthree" + (ebox-test-box :id "scroll" :content "zero\none\ntwo\nthree" :width '(120) :height 2 :overflow 'scroll)) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) @@ -2584,7 +2627,7 @@ changes. Geometry and non-inherited computed values must remain identical." (full-renders 0)) (ebox-render-to-buffer buffer - (ebox-create :id "scroll" :content + (ebox-test-box :id "scroll" :content (mapconcat #'number-to-string (number-sequence 0 31) "\n") :width '(120) :height 2 :overflow 'scroll)) (let* ((surface (with-current-buffer @@ -2662,7 +2705,7 @@ changes. Geometry and non-inherited computed values must remain identical." (ebox-viewport-height 2)) (ebox-render-to-buffer buffer - (ebox-create :id "scroll" :content "zero\none\ntwo\nthree" + (ebox-test-box :id "scroll" :content "zero\none\ntwo\nthree" :width '(120) :height 2 :overflow 'scroll)) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) @@ -2702,13 +2745,13 @@ the staged rendered window must not rerun the Flex wrapper layout." (let* ((children (cl-loop for index below 80 collect - (ebox-create :key (intern (format "flex-cell-%03d" index)) + (ebox-test-box :key (intern (format "flex-cell-%03d" index)) :content (format "Cell %03d" index) :width '(80) :height 1))) - (flex (apply #'ebox-flex :flex-flow '(row wrap) + (flex (apply #'ebox-test-flex :flex-flow '(row wrap) :width '(180) :column-gap '(8) :row-gap 1 children)) - (root (ebox-create :key 'scroll-root :width '(180) :height 6 + (root (ebox-test-box :key 'scroll-root :width '(180) :height 6 :overflow 'scroll :ebox-content-node flex)) (buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-incomplete-flex-scroll*") @@ -2748,7 +2791,7 @@ the staged rendered window must not rerun the Flex wrapper layout." (ebox-viewport-height 2)) (ebox-render-to-buffer buffer - (ebox-create :id "scroll" :content "zero\none\ntwo\nthree" + (ebox-test-box :id "scroll" :content "zero\none\ntwo\nthree" :width '(120) :height 2 :overflow 'scroll)) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) @@ -2794,7 +2837,7 @@ participant and rollback contract without creating a real frame." (window-point 1)) (ebox-render-to-buffer buffer - (ebox-create :id "native-scroll" :content "zero\none\ntwo\nthree" + (ebox-test-box :id "native-scroll" :content "zero\none\ntwo\nthree" :width '(120) :height 2 :overflow 'scroll)) (let* ((state (ebox--buffer-render-state buffer)) (region-id (car (plist-get state :scroll-region-ids)))