refactor: enforce canonical Text and Box runtime boundaries

This commit is contained in:
Kinneyzhang 2026-08-27 12:14:51 +08:00
parent f9050cee72
commit 0c7e7b974a
36 changed files with 6808 additions and 4983 deletions

View File

@ -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

View File

@ -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` |

View File

@ -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` |

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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))

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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))

View File

@ -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 ()

View File

@ -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)))

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)))

File diff suppressed because it is too large Load Diff

View File

@ -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)

102
ebox.el
View File

@ -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)

View File

@ -230,14 +230,15 @@ fn push_edit(
});
}
fn coordinate_edits<T, F>(old: &[T], new: &[T], text_equal: &F) -> Vec<SpanEdit>
where
F: Fn(&T, &T) -> bool,
{
fn coordinate_edits(
old_len: usize,
new_len: usize,
text_matches: &[(usize, usize, usize)],
) -> Vec<SpanEdit> {
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<T, FText, FSemantic>(
fn semantic_edits<T, FSemantic>(
old: &[T],
new: &[T],
text_equal: &FText,
text_matches: &[(usize, usize, usize)],
semantic_equal: &FSemantic,
) -> Vec<SpanEdit>
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),
})
}

View File

@ -121,6 +121,14 @@ enum LayoutNode {
Box {
region_id: i64,
content: Option<Box<MeasuredText>>,
#[serde(default)]
content_region_id: Option<i64>,
#[serde(default)]
content_typography_style: Option<u32>,
#[serde(default)]
content_foreground_style: Option<u32>,
#[serde(default)]
content_surface_template_id: Option<u32>,
child: Option<Box<LayoutNode>>,
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<u32>,
foreground_style: Option<u32>,
background_style: Option<u32>,
border_left_style: Option<u32>,
@ -156,6 +166,16 @@ enum LayoutNode {
wrap_mode: WrapMode,
scroll_offset: i64,
},
Text {
region_id: i64,
content: Box<MeasuredText>,
#[serde(default)]
typography_style: Option<u32>,
foreground_style: Option<u32>,
#[serde(default)]
surface_template_id: Option<u32>,
wrap_mode: WrapMode,
},
Row {
children: Vec<LayoutNode>,
},
@ -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<SpanEdit>,
combined_payload: Vec<u8>,
metadata_records: usize,
fragment_bytes: Vec<u8>,
@ -2451,6 +2475,24 @@ struct EmacsCommitBatch {
fragment_style_delta: Option<FragmentStyleDelta>,
}
fn coalesce_publication_edits(edits: &[SpanEdit]) -> Vec<SpanEdit> {
const MAX_UNCHANGED_GAP: usize = 1;
let mut result: Vec<SpanEdit> = 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<EmacsCommitBatch, String> {
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<i64> {
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::<Vec<_>>()
@ -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))"));

View File

@ -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

View File

@ -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."

View File

@ -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)))))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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

253
tests/ebox-fixtures.el Normal file
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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)

View File

@ -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

File diff suppressed because it is too large Load Diff