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 ?= LOAD_EXTRA ?=
EMACS_BATCH = $(EMACS) -Q --batch -L . -L $(ECSS_DIR) -L $(TP_DIR) $(LOAD_EXTRA) --eval '(setq load-prefer-newer t)' 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_MANIFEST = native/Cargo.toml
NATIVE_TARGET ?= $(shell $(EMACS_BATCH) -l ebox-native-reflow.el --eval '(princ (ebox-native-reflow--rust-target))') 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 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 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: 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: 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: 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: 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: 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: 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: package-tests:
$(EMACS_BATCH) -l tests/ebox-package-tests.el -f ert-run-tests-batch-and-exit $(EMACS_BATCH) -l tests/ebox-package-tests.el -f ert-run-tests-batch-and-exit
selector-tests: 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: dsl-tests:
$(EMACS_BATCH) -l tests/ebox-dsl-tests.el -f ert-run-tests-batch-and-exit $(EMACS_BATCH) -l tests/ebox-dsl-tests.el -f ert-run-tests-batch-and-exit
flex-tests: 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 performance-evaluator: surface-tests ebox-commit-tests
$(EMACS_BATCH) -l scripts/ebox-performance-evaluator.el -f ebox-performance-evaluator-batch $(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` | | 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` | | Paint | `:color`, `:background-color`, and alias `:bgcolor` |
| Typography | `:font`, `:font-family`, `:font-height` and alias `:font-size`, `:font-weight`, `:font-slant` | | 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. | | 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` | | 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` | | 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` | | 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` | | Paint | `:color`、`:background-color`、别名 `:bgcolor` |
| Typography | `:font`、`:font-family`、`:font-height` 和别名 `:font-size`、`:font-weight`、`:font-slant` | | 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 通常由布局构造器选择。 | | 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` | | 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` | | 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-layout-config)
(require 'ebox-style) (require 'ebox-style)
(require 'ebox-node-factory) (require 'ebox-node-factory)
(require 'ebox-child-range)
(defun ebox-canonical--validate-plist (plist context) (defun ebox-canonical--validate-plist (plist context)
"Validate PLIST shape for error CONTEXT and return PLIST." "Validate PLIST shape for error CONTEXT and return PLIST."
@ -52,16 +53,40 @@
(error "%s accepts %S at most once" context field)) (error "%s accepts %S at most once" context field))
(and values (cons t (car values))))) (and values (cons t (car values)))))
(defun ebox-canonical--compiled-property-definitions (plist context) (defconst ebox-canonical--source-metadata-fields
"Return canonical property definitions compiled from PLIST for CONTEXT." '(:source-handle :key :class :id)
(condition-case err "Source facts accepted by both canonical Text and Box constructors.")
(cl-loop for (property _value)
on (ebox-style-compile-declarations plist t) by #'cddr (defvar ebox-canonical--declaration-fact-cache
collect (or (ebox-style-property property) (make-hash-table :test #'eq :weakness 'key)
(error "Missing canonical Ebox property: %S" "Declaration-identity keyed validated canonical node facts.")
property)))
(error (defun ebox-canonical--source-metadata (plist context)
(error "%s rejected properties: %s" context (error-message-string err))))) "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) (defun ebox-canonical--text-measurement-property-p (property)
"Return non-nil when PROPERTY belongs to canonical Text measurement." "Return non-nil when PROPERTY belongs to canonical Text measurement."
@ -70,20 +95,81 @@
(defun ebox-canonical--box-frame-property-p (property) (defun ebox-canonical--box-frame-property-p (property)
"Return non-nil when PROPERTY belongs to Box geometry or participation." "Return non-nil when PROPERTY belongs to Box geometry or participation."
(let ((contexts (plist-get property :contexts)) (let ((contexts (plist-get property :contexts))
(group (plist-get property :group)) (group (plist-get property :group)))
(name (plist-get property :name)))
(or (memq 'item contexts) (or (memq 'item contexts)
(and (memq 'box contexts) (and (memq 'box contexts)
(not (memq group '(paint typography))) (not (memq group '(paint typography)))))))
(not (eq name :wrap-mode))))))
(defun ebox-canonical--assert-property-role (plist predicate context) (defun ebox-canonical--node-properties
"Require every compiled PLIST property to satisfy PREDICATE for CONTEXT." (props declarations-field predicate context tag)
(dolist (property "Return declarations, engine props, and projection need for one node.
(ebox-canonical--compiled-property-definitions plist context)) PROPS are direct typed inputs. DECLARATIONS-FIELD is an optional normalized
(unless (funcall predicate property) author fact set. PREDICATE selects canonical node-owned values for CONTEXT
(error "%s does not accept %S" and author TAG."
context (plist-get property :name))))) (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) (defun ebox-node-kind (node)
"Return canonical NODE kind, or nil for a legacy runtime 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))) (ebox-layout-config--copy (plist-get node :ebox-layout-config)))
(defun ebox-box-node-children (node) (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) (unless (ebox-box-node-p node)
(error "Expected canonical BoxNode, got %S" 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 ;;;###autoload
(defun ebox-text-create (&rest plist) (defun ebox-text-create (&rest plist)
"Create a canonical TextNode from evaluated PLIST. "Create a canonical TextNode from evaluated PLIST.
`:value' is required and must be one string. `:source-handle' is optional. `:value' is required and must be one string. `:source-handle', `:key',
Only text measurement properties are accepted; paint is projected separately." `: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") (ebox-canonical--validate-plist plist "ebox-text-create")
(let* ((value (ebox-canonical--required-field (let* ((value (ebox-canonical--required-field
plist :value "Ebox Text")) plist :value "Ebox Text"))
(source-field (ebox-canonical--optional-field (declarations-field (ebox-canonical--optional-field
plist :source-handle "Ebox Text")) plist :declarations "Ebox Text"))
(source-handle (cdr source-field)) (source-metadata (ebox-canonical--source-metadata plist "Ebox Text"))
(props (ebox-canonical--without-keys (props (ebox-canonical--without-keys
plist '(:value :source-handle)))) plist (append '(:value :declarations)
ebox-canonical--source-metadata-fields))))
(unless (stringp value) (unless (stringp value)
(error "Ebox Text :value must be a string: %S" value)) (error "Ebox Text :value must be a string: %S" value))
(ebox-canonical--assert-property-role (pcase-let* ((`(,declarations ,engine-props ,style-required-p)
props #'ebox-canonical--text-measurement-property-p "Ebox Text") (ebox-canonical--node-properties
(let ((node (apply #'ebox-create (append props (list :content value))))) 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-kind 'text)
(plist-put node :ebox-text-value value) (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 :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))) node)))
;;;###autoload ;;;###autoload
@ -150,21 +295,24 @@
`:layout' must occur exactly once as an `ebox-layout-config'. `:layout' must occur exactly once as an `ebox-layout-config'.
`:children' is a list of canonical nodes. Every Layout accepts zero or more `: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") (ebox-canonical--validate-plist plist "ebox-box-create")
(let* ((layout (ebox-canonical--required-field (let* ((layout (ebox-canonical--required-field
plist :layout "Ebox Box")) plist :layout "Ebox Box"))
(children-field (ebox-canonical--optional-field (children-field (ebox-canonical--optional-field
plist :children "Ebox Box")) plist :children "Ebox Box"))
(declarations-field (ebox-canonical--optional-field
plist :declarations "Ebox Box"))
(outer-field (ebox-canonical--optional-field (outer-field (ebox-canonical--optional-field
plist :outer "Ebox Box")) plist :outer "Ebox Box"))
(source-field (ebox-canonical--optional-field (source-metadata (ebox-canonical--source-metadata plist "Ebox Box"))
plist :source-handle "Ebox Box"))
(children (if children-field (cdr children-field) nil)) (children (if children-field (cdr children-field) nil))
child-sequence
(outer (if outer-field (cdr outer-field) 'block)) (outer (if outer-field (cdr outer-field) 'block))
(source-handle (cdr source-field))
(props (ebox-canonical--without-keys (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) (unless (ebox-layout-config-p layout)
(error "Ebox Box :layout must be a typed layout config: %S" (error "Ebox Box :layout must be a typed layout config: %S"
layout)) layout))
@ -173,31 +321,47 @@ children according to its own formatting algorithm."
(error "Ebox Box :outer must be inline or block: %S" outer)) (error "Ebox Box :outer must be inline or block: %S" outer))
(unless (proper-list-p children) (unless (proper-list-p children)
(error "Ebox Box :children must be a proper list: %S" children)) (error "Ebox Box :children must be a proper list: %S" children))
(unless (cl-every (lambda (child) (pcase-let ((`(,material-children ,normalized-sequence)
(or (ebox-text-node-p child) (ebox-canonical--normalize-children children)))
(ebox-box-node-p child))) (setq children material-children)
children) (setq child-sequence normalized-sequence))
(error "Ebox Box children must be canonical Text/Box nodes: %S"
children))
(unless (memq (ebox-layout-config-kind layout) (unless (memq (ebox-layout-config-kind layout)
'(normal row column flex grid)) '(normal row column flex grid))
(error "Ebox Box Layout is not implemented: %S" (error "Ebox Box Layout is not implemented: %S"
(ebox-layout-config-kind layout))) (ebox-layout-config-kind layout)))
(ebox-canonical--assert-property-role (pcase-let* ((tag (if (eq (ebox-layout-config-kind layout) 'normal)
props #'ebox-canonical--box-frame-property-p "Ebox Box") 'box
(let ((node (apply #'ebox-create props))) (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-kind 'box)
(plist-put node :ebox-layout-config layout) (plist-put node :ebox-layout-config layout)
(plist-put node :children children) (plist-put node :children children)
(plist-put node :ebox-source-handle source-handle) (when child-sequence
;; Children own their text formatting. The Box frame consumes their (plist-put node :ebox-child-sequence child-sequence))
;; already-laid-out output and must not wrap or justify it a second time. (ebox-canonical--apply-source-metadata node source-metadata)
(plist-put node :wrap-mode nil)
(plist-put node :display (plist-put node :display
(list outer (list outer
(pcase (ebox-layout-config-kind layout) (pcase (ebox-layout-config-kind layout)
('normal 'flow) ('normal 'flow)
(kind kind)))) (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))) node)))
(provide 'ebox-canonical) (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)) (cdr (assoc key (and node (ebox-child-range--hash-node-bucket node))
#'equal)))) #'equal))))
(defun ebox-child-range--item-copy (item metrics) (defun ebox-child-range--item-copy (item metrics &optional retain-identity-p)
"Validate and copy declarative ITEM while recording METRICS." "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) (when (ebox-child-range--descriptor-p item)
(error "Nested child Range descriptors are reserved")) (error "Nested child Range descriptors are reserved"))
(unless (and (listp item) (not (stringp item))) (unless (and (listp item) (not (stringp item)))
(error "Ebox child Range item must be one declarative node")) (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-validations)
(ebox-child-range--metric-add metrics 'new-payload-visits) (ebox-child-range--metric-add metrics 'new-payload-visits)
(if retain-identity-p
item
(ebox-child-range--metric-add metrics 'new-payload-copies) (ebox-child-range--metric-add metrics 'new-payload-copies)
(copy-sequence item)) (copy-sequence item)))
(defun ebox-child-range--descriptor-create (ref items) (defun ebox-child-range--descriptor-create (ref items)
"Return a reserved Range descriptor for non-nil REF and list 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))) (signal 'wrong-type-argument (list 'proper-list-p items)))
(ebox-child-range--make-descriptor :ref ref :items 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. "Build an immutable child sequence from SEGMENTS.
Each entry is `(REF . ITEMS)'; nil REF denotes one static child item. 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) (unless (proper-list-p segments)
(signal 'wrong-type-argument (list 'proper-list-p segments))) (signal 'wrong-type-argument (list 'proper-list-p segments)))
(let* ((count (length segments)) (let* ((count (length segments))
@ -227,7 +234,8 @@ HASH-FUNCTION optionally supplies the stable key hash."
(when ref (puthash ref index refs)) (when ref (puthash ref index refs))
(let ((payload (vconcat (let ((payload (vconcat
(mapcar (lambda (item) (mapcar (lambda (item)
(ebox-child-range--item-copy item metrics)) (ebox-child-range--item-copy
item metrics retain-item-identities-p))
items)))) items))))
(setq root (ebox-child-range--segment-insert (setq root (ebox-child-range--segment-insert
root height index 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: ;;; Commentary:
;; Owns `.ebox` DSL compilation and compatibility sugar over the Ebox element ;; Owns the complete inert `.ebox' author boundary: form parsing, form-local
;; and style models. It does not own core render or buffer patch execution. ;; 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: ;;; Code:
(require 'cl-lib) (require 'cl-lib)
(require 'ebox-style) (require 'ebox-style)
(require 'ebox-tree) (require 'ebox-canonical)
(require 'ebox-flex) (require 'ebox-flex)
(require 'ebox-grid) (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) (defconst ebox-dsl--box-tags
"Valid outer participation values for the canonical Box DSL.") '(box row column flex grid)
"Author tags that construct one canonical BoxNode.")
(defconst ebox-dsl--layout-values '(normal row column flex grid) (defconst ebox-dsl--source-fields
"Valid child layout values for the canonical Box DSL.") '(: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." "Unquote one static DSL property VALUE when it is quoted data."
(if (and (consp value) (if (and (consp value)
(eq (car value) 'quote) (eq (car value) 'quote)
@ -29,218 +168,91 @@
(cadr value) (cadr value)
value)) value))
(defun ebox--dsl-unquote-properties (items) (defun ebox-dsl--unquote-properties (items)
"Unquote static property values in top-level DSL ITEMS. "Unquote static property values in top-level DSL ITEMS."
Structural child forms remain untouched; only values immediately following
keyword properties are normalized."
(let (result) (let (result)
(while items (while items
(let ((item (pop items))) (let ((item (pop items)))
(if (keywordp item) (if (keywordp item)
(progn (progn
(unless items (unless items
(error "ebox-build: missing value for %S" item)) (error "Ebox build: missing value for %S" item))
(push item result) (push item result)
(push (ebox--dsl-unquote-value (pop items)) result)) (push (ebox-dsl--unquote-value (pop items)) result))
(push item result)))) (push item result))))
(nreverse result))) (nreverse result)))
(defun ebox-dsl--axis-value (props property default allowed) (defun ebox-dsl--split (items)
"Return PROPS PROPERTY or DEFAULT after validating it against ALLOWED." "Return `(PROPERTIES . CHILDREN)' parsed from author ITEMS."
(let ((value (if (plist-member props property) (let (properties children)
(plist-get props property) (while items
default))) (let ((item (pop items)))
(unless (memq value allowed) (if (keywordp item)
(error "ebox-build: invalid %S value %S" property value)) (progn
value)) (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) (defun ebox-dsl--build-text (_source items)
"Return a shallow copy of NODE with canonical OUTER and LAYOUT display." "Build one canonical TextNode from author ITEMS."
(let ((copy (copy-sequence node))) (let* ((split (ebox-dsl--split items))
(plist-put copy :display (properties (car split))
(list outer (if (eq layout 'normal) 'flow layout))) (payloads (cdr split)))
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"))
(unless (= (length payloads) 1) (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)) (unless (stringp (car payloads))
(error "ebox-build: text payload must be a string: %S" (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 (apply #'ebox-text-create
(append props (list :value (car payloads)))))) (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) (defun ebox-dsl--build-box (tag _source items)
"Wrap LAYOUT with box PROPS for DSL TAG, or return LAYOUT unchanged." "Build canonical BoxNode TAG from author ITEMS."
(if (null props) (let* ((split (ebox-dsl--split items))
layout (properties (car split))
(when (plist-member props :content) (child-forms (cdr split))
(error "ebox-build: %S cannot combine :content with child nodes" tag)) (plan (ebox-dsl--normalize-properties
(apply #'ebox-create tag properties (ebox-dsl--source-handle)))
(append (ebox--preformatted-box-props props) (children (mapcar #'ebox-dsl-build child-forms))
(list :display '(block flow) (node
:ebox-content-node layout))))) (apply #'ebox-box-create
(append
(defun ebox--build-layout (tag constructor items) (list :layout (ebox-dsl--properties-layout plan)
"Build a row/column layout TAG with CONSTRUCTOR from DSL ITEMS." :outer (ebox-dsl--properties-outer plan)
(let* ((split (ebox--build-split-attrs items)) :children children
(props (car split)) :declarations
(children (cdr split)) (ebox-dsl--properties-declarations plan))
(layout (ebox-dsl--properties-source-metadata plan)))))
(apply constructor node))
(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 (dsl) (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: The complete grammar is String plus `text', `box', `row', `column', `flex',
(box :content \"Hello\" :width (160)) and `grid'. Strings normalize to Text. The five Box forms select a typed
(box :content \"Fits preview\" :width (viewport)) LayoutConfig; no form accepts `:content', `:layout', or a raw runtime node."
(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."
(cond (cond
((ebox--node-p dsl) dsl) ((stringp dsl)
((stringp dsl) (ebox-text-create :value dsl)) (ebox-dsl--build-text dsl (list dsl)))
((not (and (consp dsl) (symbolp (car 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 (t
(let ((tag (car dsl)) (ebox-dsl--build-box
(items (ebox--dsl-unquote-properties (cdr dsl)))) (car dsl) dsl (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)))))))
(provide 'ebox-dsl) (provide 'ebox-dsl)

View File

@ -40,9 +40,8 @@
(cache-key signature rendered &optional side-effects)) (cache-key signature rendered &optional side-effects))
(declare-function ebox--render-with-cache (declare-function ebox--render-with-cache
"ebox-incremental" (node &optional force cache-probe)) "ebox-incremental" (node &optional force cache-probe))
(declare-function ebox-tree--register-legacy-flex-item-adapter (declare-function ebox-node-factory--create-from-properties
"ebox-tree" (adapter token source content)) "ebox-node-factory" (&rest plist))
(declare-function ebox-create "ebox" (&rest plist))
(declare-function ebox-get "ebox" (box property)) (declare-function ebox-get "ebox" (box property))
(declare-function ebox--ensure-node-id "ebox" (node)) (declare-function ebox--ensure-node-id "ebox" (node))
(declare-function ebox--ensure-region-id "ebox" (box)) (declare-function ebox--ensure-region-id "ebox" (box))
@ -82,14 +81,6 @@
'(:order :flex :flex-grow :flex-shrink :flex-basis :align-self) '(:order :flex :flex-grow :flex-shrink :flex-basis :align-self)
"Properties that belong to flex item metadata.") "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 (defconst ebox--flex-container-layout-prop-keys
'(:flex-direction :flex-wrap :flex-flow '(:flex-direction :flex-wrap :flex-flow
:justify-content :align-items :align-content :justify-content :align-items :align-content
@ -176,28 +167,12 @@ That text is preformatted and must not be sent through the default wrapper."
props props
(append props (list :wrap-mode nil)))) (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) (defun ebox--flex-make-wrapper-box (props)
"Return a flex container wrapper box for PROPS, or nil." "Return a flex container wrapper box for PROPS, or nil."
(when (and props (ebox--flex-visual-box-props-p props)) (when (and props (ebox--flex-visual-box-props-p props))
(when (plist-member props :content) (when (plist-member props :content)
(error "ebox-flex: flex cannot combine :content with child nodes")) (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) (append (ebox--preformatted-box-props props)
(list :content ""))))) (list :content "")))))
@ -528,13 +503,22 @@ programmatic port and are therefore rejected here."
(plist-get source :ebox-type) (plist-get source :ebox-type)
(not (eq (plist-get source :ebox-type) 'box)))) (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. "Call THUNK in SOURCE's detached Flex-item render context.
VIEWPORT is bound only for composite SOURCE nodes; the Flex parent context is When VIEWPORT is non-nil it is the containing block assigned by the Flex
always bound because SOURCE remains a direct Flex participant while rendered parent and therefore applies to SOURCE's complete subtree, including a
as a detached geometry root." 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)) (let ((ebox--render-root-parent-kind 'flex))
(if (and viewport (ebox--flex-composite-source-p source)) (if viewport
(let ((ebox-viewport-width viewport)) (let ((ebox-viewport-width viewport))
(funcall thunk)) (funcall thunk))
(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." this avoids signature and retained-cost work for one-off responsive sizes."
(when (and ebox--render-cache-table (when (and ebox--render-cache-table
(numberp viewport) (numberp viewport)
(ebox--flex-composite-source-p source)) (or (ebox--flex-composite-source-p source)
(ebox--viewport-dependent-subtree-p source)))
(let* ((observations (let* ((observations
(or (gethash ebox--render-cache-table (or (gethash ebox--render-cache-table
ebox--flex-sized-render-observation-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 Direct box flex items are measured intrinsically. Composite items receive
VIEWPORT for descendants so auto and max-width layouts can resolve against VIEWPORT for descendants so auto and max-width layouts can resolve against
their containing block, while stack/column containers do not stretch their own 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) (if (ebox--flex-composite-source-p source)
(ebox--flex-bind-source-viewport (ebox--flex-bind-source-viewport
source viewport source viewport
@ -618,7 +603,7 @@ grapheme."
(let ((declared-min (or (ebox--resolve-size-content-pixel (let ((declared-min (or (ebox--resolve-size-content-pixel
box (ebox-get box :min-width) 0) box (ebox-get box :min-width) 0)
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 ;; CSS's automatic minimum is min-content only when the author
;; leaves `min-width' at its automatic value. A component that ;; leaves `min-width' at its automatic value. A component that
;; deliberately declares `:min-width 0' must be allowed to ;; deliberately declares `:min-width 0' must be allowed to
@ -660,13 +645,13 @@ grapheme."
(defun ebox--flex-min-main (source rendered axis) (defun ebox--flex-min-main (source rendered axis)
"Return SOURCE's minimum main-axis size for flex layout on 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-box-min-main source rendered axis)
(ebox--flex-string-main rendered axis))) (ebox--flex-string-main rendered axis)))
(defun ebox--flex-max-main (source axis) (defun ebox--flex-max-main (source axis)
"Return SOURCE's maximum main-axis size for flex layout on 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 (ebox--flex-box-main-constraint
source axis (if (eq axis 'row) :max-width :max-height)))) source axis (if (eq axis 'row) :max-width :max-height))))
@ -683,12 +668,11 @@ grapheme."
((eq basis 'auto) ((eq basis 'auto)
(ebox--flex-string-main rendered axis)) (ebox--flex-string-main rendered axis))
((eq basis 'content) ((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-box-content-basis-main source rendered axis)
(ebox--flex-string-main rendered axis))) (ebox--flex-string-main rendered axis)))
((and (eq axis 'row) ((and (eq axis 'row)
(listp source) (ebox--flex-box-source-p source))
(eq (plist-get source :ebox-type) 'box))
(let* ((normalized (let* ((normalized
(ebox--normalize-horizontal-size-value (ebox--normalize-horizontal-size-value
basis ebox--preferred-size-keywords)) basis ebox--preferred-size-keywords))
@ -727,9 +711,7 @@ grapheme."
(ebox--flex-recache-source-boxes child))) (ebox--flex-recache-source-boxes child)))
('flex ('flex
(dolist (child (ebox-tree-layout-children node)) (dolist (child (ebox-tree-layout-children node))
(ebox--flex-recache-source-boxes child))) (ebox--flex-recache-source-boxes child)))))))
('flex-item
(ebox--flex-recache-source-boxes (plist-get node :node)))))))
(defun ebox--flex-box-main-margin (box axis) (defun ebox--flex-box-main-margin (box axis)
"Return BOX's total margin on 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) (defun ebox--flex-copy-node-for-size (node axis main cross stretch)
"Return NODE or a copy with flex-computed MAIN/CROSS size." "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))) (let ((copy (copy-sequence node)))
(ebox--ensure-region-id node) (ebox--ensure-region-id node)
(plist-put copy :region-id (ebox-get node :region-id)) (plist-put copy :region-id (ebox-get node :region-id))
@ -784,7 +766,11 @@ grapheme."
(defun ebox--flex-measure-cacheable-p (source props) (defun ebox--flex-measure-cacheable-p (source props)
"Return non-nil when SOURCE and PROPS have stable flex measurements." "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 (not (ebox--viewport-dependent-size-value-p
(plist-get props :flex-basis))))) (plist-get props :flex-basis)))))
@ -793,13 +779,20 @@ grapheme."
The result is nil when SOURCE cannot use the persistent measurement cache; The result is nil when SOURCE cannot use the persistent measurement cache;
otherwise it carries the key, signature, and any cached measurement so a otherwise it carries the key, signature, and any cached measurement so a
caller can pass the same miss context to the renderer." caller can pass the same miss context to the renderer."
(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) (when (ebox--flex-measure-cacheable-p source props)
(let* ((cache-key (let* ((cache-key
(list (ebox--ensure-node-id source) 'flex-measure axis props)) (list (ebox--ensure-node-id source) 'flex-measure axis props))
(signature (ebox--flex-render-cache-signature source))) (signature (ebox--flex-render-cache-signature source)))
(list :cache-key cache-key (list :cache-key cache-key
:signature signature :signature signature
:cached (ebox--render-cache-lookup cache-key signature))))) :cached (ebox--render-cache-lookup cache-key signature)))))))
(defun ebox--flex-measure-cache-value (probe) (defun ebox--flex-measure-cache-value (probe)
"Return a copied cached measurement from PROBE, or nil on a miss." "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 render can be the first render instead of rendering the whole subtree once for
measurement and immediately rendering it again at the computed size." measurement and immediately rendering it again at the computed size."
(let ((basis (plist-get props :flex-basis))) (let ((basis (plist-get props :flex-basis)))
(when (and (listp source) (when (and (ebox--flex-box-source-p source)
(eq (plist-get source :ebox-type) 'box)
(ebox--flex-fixed-basis-value-p basis) (ebox--flex-fixed-basis-value-p basis)
(or (not (eq axis 'row)) (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)) (let* ((min-main (ebox--flex-min-main source "" axis))
(max-main (ebox--flex-max-main source axis)) (max-main (ebox--flex-max-main source axis))
(base (ebox--flex-basis-main source "" axis basis)) (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. "Render flex ITEM with MAIN and optional CROSS size.
Return a sized render entry containing the rendered string and dimensions." Return a sized render entry containing the rendered string and dimensions."
(let* ((source (plist-get item :source)) (let* ((source (plist-get item :source))
(source-box-p (source-box-p (ebox--flex-box-source-p source))
(eq (and (listp source)
(plist-get source :ebox-type))
'box))
(align (ebox--flex-resolved-align item container-align)) (align (ebox--flex-resolved-align item container-align))
(stretch (memq align '(stretch normal))) (stretch (memq align '(stretch normal)))
(render-main (plist-get item :render-main)) (render-main (plist-get item :render-main))
@ -1265,7 +1255,7 @@ Return a sized render entry containing the rendered string and dimensions."
(let ((items (let ((items
(cl-loop for child in children (cl-loop for child in children
for index from 0 for index from 0
for source = (ebox--flex-item-source-node child) for source = child
for props = (ebox--flex-item-props child) for props = (ebox--flex-item-props child)
for measurement = (ebox--flex-item-measurement for measurement = (ebox--flex-item-measurement
source props axis viewport) 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)) (puthash source-id t source-set))
(cl-loop for child in children (cl-loop for child in children
for index from 0 for index from 0
for source = (ebox--flex-item-source-node child) for source = child
for source-id = (plist-get source :node-id) for source-id = (plist-get source :node-id)
when (gethash source-id source-set) when (gethash source-id source-set)
do (let* ((props (ebox--flex-item-props child)) do (let* ((props (ebox--flex-item-props child))
@ -1640,65 +1630,6 @@ metadata and rendering decisions."
(ebox--flex-pad-width result cross-size) (ebox--flex-pad-width result cross-size)
result)))) 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) (defun ebox--render-flex-box (node rendered)
"Render flex NODE's container box around RENDERED, when present." "Render flex NODE's container box around RENDERED, when present."
(if-let* ((box (plist-get node :box))) (if-let* ((box (plist-get node :box)))
@ -1825,7 +1756,7 @@ assembles already-rendered items."
(cond (cond
((< measure-index (length children)) ((< measure-index (length children))
(let* ((child (nth measure-index children)) (let* ((child (nth measure-index children))
(source (ebox--flex-item-source-node child)) (source child)
(props (ebox--flex-item-props child)) (props (ebox--flex-item-props child))
(measurement (measurement
(ebox--flex-item-measurement (ebox--flex-item-measurement
@ -1856,6 +1787,29 @@ assembles already-rendered items."
(plist-put entry :render-index (1+ render-index)) (plist-put entry :render-index (1+ render-index))
t)))))))) 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) (defun ebox--flex-window-wrapper-supported-p (box)
"Return non-nil when BOX can safely wrap an incomplete flex prefix." "Return non-nil when BOX can safely wrap an incomplete flex prefix."
(or (null box) (or (null box)
@ -1863,7 +1817,8 @@ assembles already-rendered items."
(or (null (ebox-get box :min-height)) (or (null (ebox-get box :min-height))
(equal (ebox-get box :min-height) 0)) (equal (ebox-get box :min-height) 0))
(null (ebox-get box :max-height)) (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 :text-align) 'left)
(eq (ebox-get box :vertical-align) 'top)))) (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 When COMPLETE is nil, trailing vertical chrome is suppressed so the result is
a prefix of the eventual full wrapper render." a prefix of the eventual full wrapper render."
(let ((copy (copy-sequence box))) (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 (unless complete
(plist-put copy :padding-bottom-height 0) (plist-put copy :padding-bottom-height 0)
(plist-put copy :margin-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) (defun ebox--flex-window-render-box-lines (node content complete limit)
"Return NODE flex wrapper lines around CONTENT up to LIMIT." "Return NODE flex wrapper lines around CONTENT up to LIMIT."
(let* ((rendered (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)) (let ((copy (ebox--flex-window-wrapper-copy box complete))
(region-id (ebox--ensure-region-id box))) (region-id (ebox--ensure-region-id box)))
(plist-put copy :region-id region-id) (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 (border-top-p (and include-top
(ebox-get box :border-top-p))) (ebox-get box :border-top-p)))
(border-top-color (ebox-get box :border-top-color)) (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)) (bgcolor (ebox-get box :bgcolor))
(inner-height (+ (length content-lines) padding-top)) (inner-height (+ (length content-lines) padding-top))
result) 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) (defun ebox--flex-window-definite-main (source axis)
"Return SOURCE's definite main size for prefix flex rendering." "Return SOURCE's definite main size for prefix flex rendering."
(when (and (eq axis 'row) (when (and (eq axis 'row)
(listp source) (ebox--flex-box-source-p source)
(eq (plist-get source :ebox-type) 'box)
(plist-member source :width)) (plist-member source :width))
(ebox--flex-box-main-constraint source axis :width))) (ebox--flex-box-main-constraint source axis :width)))
(defun ebox--flex-window-cheap-item (child axis index) (defun ebox--flex-window-cheap-item (child axis index)
"Return cheap flex item metadata for CHILD, or nil when unsupported." "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)) (props (ebox--flex-item-props child))
(basis (plist-get props :flex-basis)) (basis (plist-get props :flex-basis))
(main (ebox--flex-window-definite-main source axis)) (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 :base main
:target main :target main
:window-frozen-shrinkable :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))) (not (zerop (or (plist-get props :flex-shrink) 1)))
t)))))) 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)) (memq (plist-get props :flex-wrap) '(wrap nowrap))
(not (ebox--flex-reverse-p props)) (not (ebox--flex-reverse-p props))
(ebox--flex-default-order-p children) (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) (defun ebox--flex-window-lines-from-pieces (pieces)
"Return flex content string from already rendered row 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) (defun ebox--flex-window-prefix-line-count (node pieces)
"Return visible line count for NODE prefix PIECES without wrapper rerender." "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 (if pieces
(ebox-string-height (ebox-string-height
(ebox--flex-window-lines-from-pieces pieces)) (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) (defun ebox--flex-window-completion-prefix-tail-rewrite-count (node)
"Return how many cached prefix tail lines completion rewrites for 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 (if (and box
(ebox-get box :border-bottom-p) (ebox-get box :border-bottom-p)
(= (floor (ebox-get box :padding-bottom-height)) 0)) (= (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)) (+ cached-content-count (length new-content-lines))
(length (ebox-string-lines content)))) (length (ebox-string-lines content))))
(prefix-line-count (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)) prospective-content-count))
(source-complete-visible-p (source-complete-visible-p
(and source-complete (and source-complete
@ -2331,14 +2299,14 @@ the chunk begins at the wrapper top and should include top chrome."
(wrapper-lines (wrapper-lines
(and chunk-wrapper-p new-content-lines (and chunk-wrapper-p new-content-lines
(ebox--flex-window-render-wrapper-chunk-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))) new-content-lines cached-content-count seedless-chunk-p)))
(prospective-line-count (prospective-line-count
(+ cached-line-count (length wrapper-lines))) (+ cached-line-count (length wrapper-lines)))
(completion-prefix-supported-p (completion-prefix-supported-p
(and chunk-wrapper-p effective-source-complete (and chunk-wrapper-p effective-source-complete
(ebox--flex-window-complete-prefix-supported-p (ebox--flex-window-complete-prefix-supported-p
(plist-get node :box)))) (ebox--flex-window-box node))))
(exact-append-p (exact-append-p
(or completion-prefix-supported-p (or completion-prefix-supported-p
(and chunk-wrapper-p (not effective-source-complete)))) (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 (completed-tail-line
(and completion-prefix-supported-p prospective-tail (and completion-prefix-supported-p prospective-tail
(ebox--flex-window-complete-tail-line (ebox--flex-window-complete-tail-line
(plist-get node :box) (car prospective-tail)))) (ebox--flex-window-box node) (car prospective-tail))))
content-lines content-lines
content-line-count content-line-count
content-line-tail content-line-tail
@ -2454,10 +2422,7 @@ Return nil when the flex container needs the normal full renderer."
(ebox--flex-window-cache-store (ebox--flex-window-cache-store
node limit node limit
(catch 'unsupported (catch 'unsupported
(let* ((props (ebox--flex-container-content-props (let* ((props (ebox--flex-window-props node))
(plist-get node :props)
(plist-get node :raw-props)
(plist-get node :box)))
(axis (ebox--flex-axis props)) (axis (ebox--flex-axis props))
(children (ebox-tree-layout-children node)) (children (ebox-tree-layout-children node))
(main-size (if (eq axis 'row) (main-size (if (eq axis 'row)
@ -2493,7 +2458,7 @@ Return nil when the flex container needs the normal full renderer."
(visible-line-count (visible-line-count
(or (plist-get seed :prefix-line-count) (or (plist-get seed :prefix-line-count)
(ebox--flex-window-top-chrome-lines (ebox--flex-window-top-chrome-lines
(plist-get node :box)))) (ebox--flex-window-box node))))
result result
complete) complete)
(cl-labels (cl-labels

View File

@ -13,6 +13,8 @@
(declare-function ebox--current-display-signature "ebox-measure" ()) (declare-function ebox--current-display-signature "ebox-measure" ())
(declare-function ebox--ensure-node-id "ebox" (node)) (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 (declare-function ebox--complete-layout-snapshot
"ebox-incremental" "ebox-incremental"
(buffer node snapshot &optional copy)) (buffer node snapshot &optional copy))
@ -129,18 +131,24 @@ Backend marker and buffer span data are intentionally excluded."
:border-top-color :border-bottom-color) :border-top-color :border-bottom-color)
"Box properties that affect paint without changing layout geometry.") "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 (defconst ebox--geometry-style-signature-keys
(append
'(:box-sizing '(:box-sizing
:width :min-width :max-width :width :min-width :max-width
:height :min-height :max-height :height :min-height :max-height)
:padding-left-pixel :padding-right-pixel ebox--typography-style-signature-keys
'(:padding-left-pixel :padding-right-pixel
:padding-top-height :padding-bottom-height :padding-top-height :padding-bottom-height
:margin-left-pixel :margin-right-pixel :margin-left-pixel :margin-right-pixel
:margin-top-height :margin-bottom-height :margin-top-height :margin-bottom-height
:border-left-pixel :border-right-pixel :border-left-pixel :border-right-pixel
:text-align :vertical-align :text-align :vertical-align
:overflow :wrap-mode :overflow :wrap-mode
:flex-props :flex-participation) :flex-props :flex-participation))
"Style signature keys that require layout or owner rerender work.") "Style signature keys that require layout or owner rerender work.")
(defconst ebox--structure-style-signature-keys (defconst ebox--structure-style-signature-keys
@ -196,8 +204,8 @@ Backend marker and buffer span data are intentionally excluded."
(ebox-fragment-plist-keys new))) (ebox-fragment-plist-keys new)))
(cl-pushnew key keys)) (cl-pushnew key keys))
(dolist (key keys) (dolist (key keys)
(unless (equal (plist-get old key) (unless (ebox-style--declaration-value-equal-p
(plist-get new key)) (plist-get old key) (plist-get new key))
(push key changed))) (push key changed)))
changed)) changed))

View File

@ -19,7 +19,8 @@
(declare-function ebox--ensure-node-id "ebox" (node)) (declare-function ebox--ensure-node-id "ebox" (node))
(declare-function ebox--ensure-region-id "ebox" (box)) (declare-function ebox--ensure-region-id "ebox" (box))
(declare-function ebox-get "ebox" (box property)) (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-render "ebox-layout" (node))
(declare-function ebox--lines-concat-horizontal "ebox" (&rest strings)) (declare-function ebox--lines-concat-horizontal "ebox" (&rest strings))
(declare-function ebox-string-lines "ebox" (string)) (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 (ebox-grid--visual-props-p props)
(when (plist-member props :content) (when (plist-member props :content)
(error "ebox-grid: grid cannot combine :content with child nodes")) (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) (append (ebox--preformatted-box-props props)
(list :content ""))))) (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) (defun ebox-grid--placement-part (value default)
"Return (START SPAN) parsed from placement VALUE and DEFAULT." "Return (START SPAN) parsed from placement VALUE and DEFAULT."
(cond (cond
@ -865,43 +859,6 @@ size for start/center/end alignment unless it would overflow its track."
(ebox-grid--legacy-constraints props wrapper) (ebox-grid--legacy-constraints props wrapper)
(ebox-tree-layout-children node))))) (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) (provide 'ebox-grid)
;;; ebox-grid.el ends here ;;; ebox-grid.el ends here

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,8 @@
"ebox-flex" (props)) "ebox-flex" (props))
(declare-function ebox-grid-layout-config-props-p (declare-function ebox-grid-layout-config-props-p
"ebox-grid" (props)) "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 (cl-defstruct
(ebox-layout-config (ebox-layout-config
@ -102,6 +104,34 @@
"Return canonical ColumnConfig from evaluated PLIST." "Return canonical ColumnConfig from evaluated PLIST."
(ebox-layout-config--simple-axis-create 'column 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) (provide 'ebox-layout-config)
;;; ebox-layout-config.el ends here ;;; 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. "Render BOX by owning proven exact preformatted child content directly.
Return nil unless BOX has no visual chrome or sizing behavior that requires Return nil unless BOX has no visual chrome or sizing behavior that requires
the generic box pipeline." the generic box pipeline."
(let ((layout-kind (ebox--box-layout-kind box)))
(when (and (not ebox--transparent-preformatted-box-fast-path-disabled) (when (and (not ebox--transparent-preformatted-box-fast-path-disabled)
(ebox--box-content-node box) (or (ebox--box-content-node box)
(memq layout-kind '(row column flex grid)))
(not (eq (ebox-get box :visibility) 'hidden)) (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 :text-align) 'left)
(eq (ebox-get box :vertical-align) 'top) (eq (ebox-get box :vertical-align) 'top)
(null (ebox-get box :height)) (null (ebox-get box :height))
@ -212,7 +219,8 @@ the generic box pipeline."
(equal (ebox-get box :border-right-pixel) 0) (equal (ebox-get box :border-right-pixel) 0)
(not (ebox-get box :border-top-p)) (not (ebox-get box :border-top-p))
(not (ebox-get box :border-bottom-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))) (null (ebox-get box :bgcolor)))
(let* ((content (ebox--box-content box)) (let* ((content (ebox--box-content box))
(known-width (known-width
@ -232,7 +240,7 @@ the generic box pipeline."
(puthash region-id box ebox--region-box-table) (puthash region-id box ebox--region-box-table)
(unless ebox--intrinsic-layout-measurement (unless ebox--intrinsic-layout-measurement
(ebox--scroll-clear-state region-id)) (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) (defun ebox--render-flat-preformatted-box (box)
"Render simple BOX chrome in a single line-oriented pass. "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)) (equal (ebox-get box :margin-bottom-height) 0))
(let* ((content (ebox--box-content box)) (let* ((content (ebox--box-content box))
(exact-p (and (plist-get box :ebox-content-width-exact-p) (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))) (eq (ebox-get box :text-align) 'left)))
(formatted (if exact-p (formatted (if exact-p
content content
@ -287,7 +296,9 @@ and its formatted content exactly fills the used height."
(ebox-lines-join (ebox-lines-join
(ebox--render-underfilled-simple-scroll-lines (ebox--render-underfilled-simple-scroll-lines
lines region-id content-height padding-line-filler 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-lines-join
(ebox--window-render-flat-wrapper-chunk-lines (ebox--window-render-flat-wrapper-chunk-lines
box lines 0 region-id padding-line-filler t t))))) 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) (defun ebox--render-node-window-lines (node limit)
"Return NODE's lazy window lines through the registered type renderer." "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))) (renderer (gethash type ebox--window-line-renderer-table)))
(funcall renderer node limit))) (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." START-INDEX is the first source line index represented by LINES."
(let* ((padding-line-filler (let* ((padding-line-filler
(ebox-pixel-space (ebox--content-pixel box))) (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)) (bgcolor (ebox-get box :bgcolor))
(rendered (rendered
(cl-loop for line in lines (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 nil, return TEXT without pre-wrapping.
When WRAP-MODE is `kp', use Knuth-Plass algorithm via ekp package." When WRAP-MODE is `kp', use Knuth-Plass algorithm via ekp package."
(cond (cond
((null wrap-mode) ((memq wrap-mode '(nil none))
text) text)
((eq wrap-mode 'kp) ((eq wrap-mode 'kp)
(if (and (require 'ekp nil t) (if (and (require 'ekp nil t)
@ -942,12 +962,23 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values."
(not (eq (and (listp node) (plist-get node :ebox-type)) 'box))) (not (eq (and (listp node) (plist-get node :ebox-type)) 'box)))
content)) content))
(defun ebox--normal-text-units (string) (defun ebox--normal-text-units (string wrap-mode)
"Return word/space/break units for inline Text STRING." "Return inline flow units for Text STRING under WRAP-MODE."
(let ((lines (ebox-string-lines string)) units) (let ((lines (ebox-string-lines string)) units)
(cl-loop for line in lines (cl-loop for line in lines
for line-tail on lines for line-tail on lines
do (let ((position 0) 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))) (limit (length line)))
(while (< position limit) (while (< position limit)
(let* ((space-p (let* ((space-p
@ -965,16 +996,37 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values."
:rendered part :rendered part
:width (ebox--string-pixel-width part)) :width (ebox--string-pixel-width part))
units)) units))
(setq position end)))) (setq position end)))))
unless (null (cdr line-tail)) unless (null (cdr line-tail))
do (push '(:kind break) units)) do (push '(:kind break) units))
(nreverse units))) (nreverse units)))
(defun ebox--render-normal-box-children (box children) (defun ebox--render-normal-box-children (box children)
"Render canonical CHILDREN without flattening inline Box boundaries." "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)) (let* ((resolved-width (ebox--wrapper-content-viewport-pixel box))
(content-width (and resolved-width (> resolved-width 0) (content-width (and resolved-width (> resolved-width 0)
resolved-width)) resolved-width))
(wrap-mode (ebox-get box :wrap-mode))
(soft-wrap-p (not (memq wrap-mode '(nil none kp))))
(line-width 0) (line-width 0)
pending-empty-line pending-empty-line
line-parts segments) 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))) (setq line-parts nil line-width 0)))
(append-atomic (append-atomic
(rendered width) (rendered width)
(when (and content-width line-parts (when (and soft-wrap-p content-width line-parts
(> (+ line-width width) content-width)) (> (+ line-width width) content-width))
(flush-line)) (flush-line))
(push rendered line-parts) (push rendered line-parts)
@ -996,7 +1048,8 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values."
pending-empty-line nil)) pending-empty-line nil))
(append-word (append-word
(rendered width) (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) (append-atomic rendered width)
(let ((parts (ebox--wrap-line rendered content-width 'char))) (let ((parts (ebox--wrap-line rendered content-width 'char)))
(cl-loop for part in parts (cl-loop for part in parts
@ -1033,7 +1086,7 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values."
('space ('space
(let ((width (plist-get unit :width))) (let ((width (plist-get unit :width)))
(cond (cond
((and content-width (> width content-width)) ((and soft-wrap-p content-width (> width content-width))
(let ((rendered (plist-get unit :rendered))) (let ((rendered (plist-get unit :rendered)))
(if (and (= (length rendered) 1) (if (and (= (length rendered) 1)
(get-text-property 0 'display rendered)) (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)))) (append-word rendered width))))
((null line-parts) ((null line-parts)
(append-atomic (plist-get unit :rendered) width)) (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)) (flush-line))
(t (append-atomic (plist-get unit :rendered) width))))) (t (append-atomic (plist-get unit :rendered) width)))))
('word ('word
(append-word (plist-get unit :rendered) (append-word (plist-get unit :rendered)
(plist-get unit :width))) (plist-get unit :width)))
('char
(append-word (plist-get unit :rendered)
(plist-get unit :width)))
('box ('box
(append-atomic (plist-get unit :rendered) (append-atomic (plist-get unit :rendered)
(plist-get unit :width))))) (plist-get unit :width)))))
@ -1063,7 +1120,7 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values."
(setq pending-empty-line nil) (setq pending-empty-line nil)
(push rendered segments)) (push rendered segments))
(if (eq (plist-get child :ebox-kind) 'text) (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 unit))
(append-unit (append-unit
(list :kind 'box :rendered rendered (list :kind 'box :rendered rendered
@ -1076,12 +1133,12 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values."
(flush-line)) (flush-line))
(if segments (if segments
(apply #'ebox--lines-stack-vertical (nreverse segments)) (apply #'ebox--lines-stack-vertical (nreverse segments))
""))) ""))))
(defun ebox--render-box-layout-children (box) (defun ebox--render-box-layout-children (box)
"Render canonical BOX children through its typed LayoutConfig." "Render canonical BOX children through its typed LayoutConfig."
(let* ((kind (ebox--box-layout-kind box)) (let* ((kind (ebox--box-layout-kind box))
(children (plist-get box :children)) (children (ebox-tree-layout-children box))
(content (content
(ebox--render-box-content-cached (ebox--render-box-content-cached
box box
@ -1116,6 +1173,9 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values."
(plist-get box :ebox-layout-config)) (plist-get box :ebox-layout-config))
children)) children))
(_ (error "Ebox Box has unsupported LayoutConfig: %S" kind))))))) (_ (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 (ebox--record-box-content-width-exact
box content (or (memq kind '(row column flex grid)) box content (or (memq kind '(row column flex grid))
(consp (cdr children)))) (consp (cdr children))))
@ -1123,14 +1183,20 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values."
(defun ebox--box-content (box) (defun ebox--box-content (box)
"Return BOX content, rendering any lazy child layout content if present." "Return BOX content, rendering any lazy child layout content if present."
(ebox--propertize-typography (let ((content
(cond (cond
((plist-member box :children) ((or (plist-member box :children)
(plist-get box :ebox-child-sequence))
(ebox--render-box-layout-children box)) (ebox--render-box-layout-children box))
((when-let* ((node (ebox--box-content-node box))) ((when-let* ((node (ebox--box-content-node box)))
(ebox--render-box-content-node box node))) (ebox--render-box-content-node box node)))
(t (ebox-get box :content))) (t (ebox-get box :content)))))
box)) ;; 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) (defun ebox--line-min-content-pixel (line)
"Return LINE's min-content width in content pixels." "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) (defun ebox--content-min-pixel (box)
"Return BOX's min-content width in content pixels." "Return BOX's min-content width in content pixels."
(let ((wrap-mode (ebox-get box :wrap-mode))) (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) (ebox--content-max-pixel box)
(let ((lines (ebox-string-lines (ebox--box-content box))) (let ((lines (ebox-string-lines (ebox--box-content box)))
(max-width 0)) (max-width 0))
@ -1361,7 +1427,7 @@ FALLBACK is used for nil and auto values."
(known-width (known-width
(gethash content ebox--rendered-uniform-width-table))) (gethash content ebox--rendered-uniform-width-table)))
(if (and (eq align 'left) (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) (or (plist-get box :ebox-content-width-exact-p)
(and (numberp known-width) (and (numberp known-width)
(= known-width (= known-width
@ -1371,7 +1437,10 @@ FALLBACK is used for nil and auto values."
(= known-width (ebox--content-pixel box))))) (= known-width (ebox--content-pixel box)))))
content content
(let* ((pixel-width (ebox--content-pixel box)) (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. ;; kp mode distributes inner gaps, but Ebox still owns the box geometry.
;; Pad ragged lines (notably the last line) back to the content width ;; Pad ragged lines (notably the last line) back to the content width
;; while preserving the requested alignment for those lines. ;; 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)) (border-bottom-color (ebox-get box :border-bottom-color))
;; Style and Layout ;; 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)) (bgcolor (ebox-get box :bgcolor))
(inner-height (+ content-height padding-top padding-bottom)) (inner-height (+ content-height padding-top padding-bottom))
(padding-line-filler (ebox-pixel-space content-pixel)) (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 (defvar ebox--surface-materialization-active nil
"Non-nil while an Ebox TP producer is running raw layout.") "Non-nil while an Ebox TP producer is running raw layout.")
(defvar ebox--render-root-parent-kind nil (defun ebox--render-text-node (node)
"Formatting context of a detached root rendered for its owning parent.") "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) (defun ebox--render-layout (node)
"Render layout NODE directly to a multi-line propertized string. "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)) (ebox--ensure-node-id node))
(let ((type (plist-get node :ebox-type))) (let ((type (plist-get node :ebox-type)))
(cond (cond
((eq (plist-get node :ebox-kind) 'text)
(ebox--render-text-node node))
((eq type 'box) (ebox--render-box node)) ((eq type 'box) (ebox--render-box node))
((eq type 'concat) (ebox--render-concat node)) ((eq type 'concat) (ebox--render-concat node))
((eq type 'stack) (ebox--render-stack node)) ((eq type 'stack) (ebox--render-stack node))
((eq type 'flex) (ebox--render-flex node)) ((eq type 'flex) (ebox--render-flex node))
((eq type 'grid) (ebox--render-grid 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))))))) (t (error "ebox-render: unknown node type %S" type)))))))
(declare-function ebox-surface-producer (declare-function ebox-surface-producer
@ -1840,6 +1934,7 @@ materialized surface is ephemeral and creates no live buffer state."
(require 'ebox-surface) (require 'ebox-surface)
(if (and (not (stringp node)) (if (and (not (stringp node))
(not (ebox-style-cascade-active-p)) (not (ebox-style-cascade-active-p))
(zerop (ebox-tree-author-style-count node))
(not (ebox-surface--inline-inheritance-required-p node))) (not (ebox-surface--inline-inheritance-required-p node)))
(let ((ebox--region-id-counter ebox--region-id-counter) (let ((ebox--region-id-counter ebox--region-id-counter)
(ebox--runtime-node-id-counter ebox--runtime-node-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))) (blank (ebox-pixel-space px)))
(ebox-lines-join (append lines (make-list extra blank))))))) (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) (defun ebox--render-row-children (children &optional config)
"Render flat row CHILDREN with optional typed Row CONFIG. "Render flat row CHILDREN with optional typed Row CONFIG.
Auto-width children are rendered at intrinsic inline size while explicit 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." "Render a legacy concat NODE to a string."
(ebox--render-row-children (ebox--layout-children node))) (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) (defun ebox--stack-leaves (node)
"Return NODE's vertical stack leaves in render order." "Return NODE's vertical stack leaves in render order."
(let (leaves) (let (leaves)
(cl-labels ((collect (current) (cl-labels ((collect (current)
(if (and (listp current) (cond
((and (listp current)
(eq (plist-get current :ebox-type) 'stack)) (eq (plist-get current :ebox-type) 'stack))
(dolist (child (ebox--layout-children current)) (dolist (child (ebox--layout-children current))
(collect child)) (collect child)))
(push current leaves)))) ((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)) (collect node))
(nreverse leaves))) (nreverse leaves)))
@ -2664,11 +2702,55 @@ last cached leaf instead of walking the stack from the beginning."
:complete complete-cache :complete complete-cache
:box box)))))))) :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) (defun ebox--scroll-window-stack-source (node)
"Return lazy scroll stack source metadata for NODE, or nil." "Return lazy scroll stack source metadata for NODE, or nil."
(cond (cond
((eq (plist-get node :ebox-type) 'stack) ((eq (plist-get node :ebox-type) 'stack)
(list :stack node)) (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) ((and (eq (plist-get node :ebox-type) 'box)
(eq (plist-get (plist-get node :ebox-content-node) (eq (plist-get (plist-get node :ebox-content-node)
:ebox-type) :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)) (plist-get spec :materialize-viewport-width))
(viewport-height (plist-get spec :viewport-height)) (viewport-height (plist-get spec :viewport-height))
(source (source
(ebox--scroll-window-stack-source (or (ebox--scroll-window-stack-source
(ebox--box-content-node box)))) (ebox--box-content-node box))
(ebox--scroll-window-stack-source box))))
(unless (and (numberp prefix-viewport-width) (unless (and (numberp prefix-viewport-width)
(> prefix-viewport-width 0) (> prefix-viewport-width 0)
(numberp materialize-viewport-width) (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) (defun ebox--scroll-window-source-lines-exact-p (box window)
"Return non-nil when WINDOW lines can be used as BOX formatted content." "Return non-nil when WINDOW lines can be used as BOX formatted content."
(and (plist-get window :content-width-exact-p) (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) (eq (ebox-get box :text-align) 'left)
(= (ebox--content-pixel box) (= (ebox--content-pixel box)
(or (ebox--viewport-pixel-width nil) 0)))) (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)) (or (null (ebox-get box :min-height))
(equal (ebox-get box :min-height) 0)) (equal (ebox-get box :min-height) 0))
(null (ebox-get box :max-height)) (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 :text-align) 'left)
(eq (ebox-get box :vertical-align) 'top)))) (eq (ebox-get box :vertical-align) 'top))))
@ -2783,7 +2866,8 @@ no included vertical margin."
(border-bottom-p (border-bottom-p
(and include-bottom (ebox-get box :border-bottom-p))) (and include-bottom (ebox-get box :border-bottom-p)))
(border-bottom-color (ebox-get box :border-bottom-color)) (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)) (bgcolor (ebox-get box :bgcolor))
(padding-left-line (padding-left-line
(when (> padding-left 0) (when (> padding-left 0)
@ -2966,7 +3050,8 @@ only used for incomplete lazy prefixes."
(border-top-p (and include-top (border-top-p (and include-top
(ebox-get box :border-top-p))) (ebox-get box :border-top-p)))
(border-top-color (ebox-get box :border-top-color)) (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)) (bgcolor (ebox-get box :bgcolor))
(inner-height (+ (length content-lines) padding-top)) (inner-height (+ (length content-lines) padding-top))
result) result)
@ -3250,8 +3335,9 @@ only used for incomplete lazy prefixes."
(let ((content-height (ebox--scroll-window-content-height box)) (let ((content-height (ebox--scroll-window-content-height box))
(scroll-offset (max 0 (or (ebox-get box :scroll-offset) 0))) (scroll-offset (max 0 (or (ebox-get box :scroll-offset) 0)))
(content-viewport (ebox--wrapper-content-viewport-pixel box)) (content-viewport (ebox--wrapper-content-viewport-pixel box))
(source (ebox--scroll-window-stack-source (source (or (ebox--scroll-window-stack-source
(ebox--box-content-node box)))) (ebox--box-content-node box))
(ebox--scroll-window-stack-source box))))
(when (and (not ebox--scroll-window-render-disabled) (when (and (not ebox--scroll-window-render-disabled)
(eq (ebox-get box :overflow) 'scroll) (eq (ebox-get box :overflow) 'scroll)
(ebox--viewport-height-size-value-p (ebox-get box :height)) (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)) (puthash string width cache))
width))))) 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 ;;;###autoload
(defun ebox-string-pixel-width (string) (defun ebox-string-pixel-width (string)
"Return the display pixel width of STRING's first line. "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 :width nil t)
(face-attribute 'default :foreground nil t) (face-attribute 'default :foreground nil t)
(face-attribute 'default :background 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))) (and (boundp 'text-scale-mode-amount) text-scale-mode-amount)))
(defsubst ebox--current-display-signature () (defsubst ebox--current-display-signature ()

View File

@ -20,6 +20,8 @@
(session node frame &optional layout-package state)) (session node frame &optional layout-package state))
(declare-function ebox-native-reflow-create-session (declare-function ebox-native-reflow-create-session
"ebox-native-reflow" (&rest arguments)) "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 (declare-function ebox-native-reflow-confirm-native-frame
"ebox-native-reflow" "ebox-native-reflow"
(session generation key confirmed-revision)) (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--runtime-types-supported-p state)
(ebox-native-commit--region-index-safe-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 (defun ebox-native-commit-plan-eligible-p
(old-state candidate-state prepared) (old-state candidate-state prepared)
"Return non-nil when PREPARED can bypass superseded local layout proofs." "Return non-nil when PREPARED can bypass superseded local layout proofs."
@ -249,6 +304,14 @@ type, key, parent, and sibling position are identical."
(ebox-native-commit-object-delta-node-ids (ebox-native-commit-object-delta-node-ids
old-state candidate-state prepared))) old-state candidate-state prepared)))
(state (copy-sequence candidate-state))) (state (copy-sequence candidate-state)))
(plist-put state :native-touched-node-ids layout-touched)
(plist-put state :native-topology-stable-p stable)
(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-topology-stable-p stable)
(plist-put candidate-state :native-touched-node-ids layout-touched) (plist-put candidate-state :native-touched-node-ids layout-touched)
(when context-axes-stable (when context-axes-stable
@ -259,12 +322,7 @@ type, key, parent, and sibling position are identical."
(plist-get old-state :viewport-dependent-node-id-axes))) (plist-get old-state :viewport-dependent-node-id-axes)))
(unless stable (unless stable
(cl-remf candidate-state :native-fragment-owner-cache)) (cl-remf candidate-state :native-fragment-owner-cache))
(plist-put state :native-touched-node-ids layout-touched) t)))
(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))))
(defun ebox-native-commit--root-box (node) (defun ebox-native-commit--root-box (node)
"Return NODE's outer box, or nil." "Return NODE's outer box, or nil."
@ -309,7 +367,7 @@ type, key, parent, and sibling position are identical."
(maphash (maphash
(lambda (_node-id node) (lambda (_node-id node)
(unless (memq (plist-get node :ebox-type) (unless (memq (plist-get node :ebox-type)
'(box concat stack flex flex-item)) '(box concat stack flex))
(setq supported nil))) (setq supported nil)))
nodes)) nodes))
supported)) 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." Return nil without mutating STATE when the native capability proof is absent."
(when (and (require 'ebox-native-reflow nil t) (when (and (require 'ebox-native-reflow nil t)
(ebox-native-reflow-layout-ready-p) (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--runtime-types-supported-p state)
(ebox-native-commit--region-index-safe-p state)) (ebox-native-commit--region-index-safe-p state))
(when-let* ((frame-spec (ebox-native-commit--frame-spec state node))) (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-native--module-release "ebox_native_reflow" (session))
(declare-function ebox--grapheme-clusters "ebox-layout" (string)) (declare-function ebox--grapheme-clusters "ebox-layout" (string))
(declare-function ebox--format-content-string "ebox-layout" (box content)) (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--ensure-node-id "ebox" (node))
(declare-function ebox--buffer-render-state "ebox-incremental" (buffer)) (declare-function ebox--buffer-render-state "ebox-incremental" (buffer))
(declare-function ebox--ensure-region-id "ebox" (box)) (declare-function ebox--ensure-region-id "ebox" (box))
@ -55,7 +62,6 @@
(declare-function ebox--flex-container-content-props (declare-function ebox--flex-container-content-props
"ebox-flex" (props raw-props wrapper-box)) "ebox-flex" (props raw-props wrapper-box))
(declare-function ebox--flex-item-props "ebox-flex" (item)) (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--flex-line-value "ebox-flex" (value &optional default))
(declare-function ebox--content-min-pixel "ebox-layout" (box)) (declare-function ebox--content-min-pixel "ebox-layout" (box))
(declare-function ebox--horizontal-size-pixels (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-cache (make-hash-table :test 'equal)
:layout-fragment-revision 0))) :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) (defun ebox-native-reflow--live-handle (session)
"Return SESSION's live module handle or signal an error." "Return SESSION's live module handle or signal an error."
(unless (and (ebox-native-reflow-session-p session) (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." Rust still verifies the dynamic equal-width and nonempty-line proof per frame."
(and (eq box ebox-native-reflow--compile-root-node) (and (eq box ebox-native-reflow--compile-root-node)
(eq (plist-get source-child :ebox-type) 'stack) (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 :text-align) 'left)
(eq (ebox-get box :vertical-align) 'top) (eq (ebox-get box :vertical-align) 'top)
(null (ebox-get box :height)) (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 :color))
(null (ebox-get box :bgcolor)))) (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 (defun ebox-native-reflow--compile-box
(box &optional child-override child-override-p child-resolved-p) (box &optional child-override child-override-p child-resolved-p)
"Compile normalized Ebox BOX to strict layout IR." "Compile normalized Ebox BOX to strict layout IR."
(when (plist-member box :children) (let* ((typed-box-p (eq (plist-get box :ebox-kind) 'box))
(error "Native reflow does not yet compile typed Box children")) (typed-text-p (eq (plist-get box :ebox-kind) 'text))
(let* ((source-child (plist-get box :ebox-content-node)) (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 (if (or child-override-p child-resolved-p)
child-override child-override
(if typed-box-p
(ebox-native-reflow--compile-typed-box-child box)
(and source-child (and source-child
(ebox-native-reflow--compile-node source-child)))) (ebox-native-reflow--compile-node source-child)))))
(wrap-mode (or (ebox-get box :wrap-mode) 'none)) (wrap-mode (or (ebox-get box :wrap-mode) 'none))
(kp-content (kp-content
(and (not child) (and (not child)
@ -1647,7 +1805,12 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame."
(foreground-style (foreground-style
(ebox-native-reflow--compile-add-face-style (ebox-native-reflow--compile-add-face-style
(ebox-native-reflow--compile-foreground-face (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 (background-style
(ebox-native-reflow--compile-add-face-style (ebox-native-reflow--compile-add-face-style
(let ((background (ebox-get box :bgcolor))) (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))))))))) (when color (list :color color)))))))))
(surface-template-id (surface-template-id
(ebox-native-reflow--register-property-template (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 (list
:type "box" :type "box"
:region-id region-id :region-id region-id
:content (if child :content (cond
:null (fused-text-p (plist-get child :content))
(child :null)
(t
(ebox-native-reflow--compile-text (ebox-native-reflow--compile-text
(or kp-content (ebox-get box :content) "") (or kp-content (ebox-get box :content) "")
(and kp-content t))) (and kp-content t))))
:child (or child :null) :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 :content-width-exact
(if (or child-override-p (if (or child-override-p
(memq typed-layout-kind '(row column flex grid))
(ebox-native-reflow--transparent-stack-content-exact-p (ebox-native-reflow--transparent-stack-content-exact-p
box source-child)) box source-child))
t t
:false) :false)
:width (ebox-native-reflow--compile-size :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 :min-width (ebox-native-reflow--compile-size
(ebox-get box :min-width) 'horizontal "pixels") (ebox-get box :min-width) 'horizontal "pixels")
:max-width (ebox-native-reflow--compile-size :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 :margin-bottom margin-bottom
:border-left border-left :border-left border-left
:border-right border-right :border-right border-right
:typography-style (or typography-style :null)
:foreground-style (or foreground-style :null) :foreground-style (or foreground-style :null)
:background-style (or background-style :null) :background-style (or background-style :null)
:border-left-style (or border-left-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 (item axis &optional compiled-node compiled-node-p
content-min-width content-min-width-p) content-min-width content-min-width-p)
"Compile flex ITEM metadata and source for AXIS." "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)) (props (ebox--flex-item-props item))
(order (plist-get props :order)) (order (plist-get props :order))
(grow (plist-get props :flex-grow)) (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. ;; the source subtree again during native package construction.
(when (and (eq axis 'row) (when (and (eq axis 'row)
(eq (plist-get source :ebox-type) 'box) (eq (plist-get source :ebox-type) 'box)
(ebox-get source :wrap-mode) (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; ;; A composite child already exists in the compiled fragment;
;; Rust derives its intrinsic minimum directly from that child ;; Rust derives its intrinsic minimum directly from that child
;; when the visible Elisp render did not measure it yet. ;; when the visible Elisp render did not measure it yet.
(or content-min-width-p (or content-min-width-p
(null (plist-get source :ebox-content-node)))) (and (null (plist-get source :ebox-content-node))
(null (ebox-tree-node-children source)))))
(unless content-min-width-p (unless content-min-width-p
(setq content-min-width (ebox--content-min-pixel source))) (setq content-min-width (ebox--content-min-pixel source)))
(setq compiled (setq compiled
@ -1806,27 +1988,15 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame."
(if (eq axis 'row) 'horizontal 'vertical) (if (eq axis 'row) 'horizontal 'vertical)
"auto") "auto")
:align-self :align-self
(if (eq (plist-get source :ebox-kind) 'text)
"flex-start"
(ebox-native-reflow--compile-flex-align (ebox-native-reflow--compile-flex-align
(plist-get props :align-self) 'auto "align-self")))) (plist-get props :align-self) 'auto "align-self")))))
(defun ebox-native-reflow--compile-flex (defun ebox-native-reflow--compile-flex-inner
(node &optional fragments flex-content-min-widths) (props items &optional fragments flex-content-min-widths)
"Compile normalized flex NODE, translating its wrapper to a box node." "Compile Flex PROPS and ITEMS to one backend-neutral native IR node."
(let* ((wrapper (plist-get node :box)) (let ((axis (ebox--flex-axis props)))
(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 (list
:type "flex" :type "flex"
:direction :direction
@ -1839,8 +2009,7 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame."
'(nowrap wrap wrap-reverse) "flex-wrap") '(nowrap wrap wrap-reverse) "flex-wrap")
:justify :justify
(ebox-native-reflow--compile-flex-align (ebox-native-reflow--compile-flex-align
(plist-get props :justify-content) 'flex-start (plist-get props :justify-content) 'flex-start "justify-content")
"justify-content")
:align-items :align-items
(ebox-native-reflow--compile-flex-align (ebox-native-reflow--compile-flex-align
(plist-get props :align-items) 'stretch "align-items") (plist-get props :align-items) 'stretch "align-items")
@ -1853,41 +2022,55 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame."
:height :height
(ebox-native-reflow--compile-size (ebox-native-reflow--compile-size
(plist-get props :height) 'vertical "auto") (plist-get props :height) 'vertical "auto")
:row-gap :row-gap (or (ebox--flex-line-value (plist-get props :row-gap) 0) 0)
(or (ebox--flex-line-value (plist-get props :row-gap) 0) 0)
:column-gap :column-gap
(or (ebox--horizontal-size-pixels (or (ebox--horizontal-size-pixels (plist-get props :column-gap) 0) 0)
(plist-get props :column-gap) 0)
0)
:items :items
(vconcat (vconcat
(mapcar (lambda (item) (mapcar
(lambda (item)
(if fragments (if fragments
(let* ((source (ebox--flex-item-source-node item)) (let* ((source item)
(missing (make-symbol "missing-content-min")) (missing (make-symbol "missing-content-min"))
(content-min (content-min
(if flex-content-min-widths (if flex-content-min-widths
(gethash source (gethash source flex-content-min-widths missing)
flex-content-min-widths
missing)
missing))) missing)))
(ebox-native-reflow--compile-flex-item (ebox-native-reflow--compile-flex-item
item axis item axis
(ebox-native-reflow--compiled-scene-child (ebox-native-reflow--compiled-scene-child fragments source)
fragments source) t content-min (not (eq content-min missing))))
t
content-min
(not (eq content-min missing))))
(ebox-native-reflow--compile-flex-item item axis))) (ebox-native-reflow--compile-flex-item item axis)))
(ebox-tree-layout-children node)))))) items)))))
(defun ebox-native-reflow--compile-flex
(node &optional fragments flex-content-min-widths)
"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)))
(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 (if wrapper
(ebox-native-reflow--compile-box wrapper inner t) (ebox-native-reflow--compile-box wrapper inner t)
inner))) inner))))
(defun ebox-native-reflow--compile-node (node) (defun ebox-native-reflow--compile-node (node)
"Compile supported normalized Ebox NODE to strict layout IR." "Compile supported normalized Ebox NODE to strict layout IR."
(pcase (and (listp node) (plist-get node :ebox-type)) (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 ('concat
(list :type "row" (list :type "row"
:children :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 (vconcat (mapcar #'ebox-native-reflow--compile-node
(ebox--layout-children node))))) (ebox--layout-children node)))))
('flex (ebox-native-reflow--compile-flex 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" (_ (error "Native reflow unsupported node type: %S"
(and (listp node) (plist-get node :ebox-type)))))) (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) ((plist-get node :ebox-child-sequence) nil)
((eq (plist-get node :ebox-type) 'grid) nil) ((eq (plist-get node :ebox-type) 'grid) nil)
((eq (plist-get node :ebox-type) 'box) ((eq (plist-get node :ebox-type) 'box)
(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)) (and (not (plist-member node :children))
(ebox-native-reflow--native-node-supported-p (ebox-native-reflow--native-node-supported-p
(plist-get node :ebox-content-node)))) (plist-get node :ebox-content-node)))))
((memq (plist-get node :ebox-type) '(concat stack)) ((memq (plist-get node :ebox-type) '(concat stack))
(cl-every #'ebox-native-reflow--native-node-supported-p (cl-every #'ebox-native-reflow--native-node-supported-p
(ebox--layout-children node))) (ebox--layout-children node)))
@ -1923,16 +2117,40 @@ backend has a matching two-dimensional layout contract."
(append (when-let* ((box (plist-get node :box))) (append (when-let* ((box (plist-get node :box)))
(list box)) (list box))
(ebox-tree-layout-children node)))) (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))) (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) (defun ebox-native-reflow--compiled-scene-child (fragments node)
"Return NODE's compiled fragment from FRAGMENTS, failing closed." "Return NODE's compiled fragment from FRAGMENTS, failing closed."
(let ((missing (make-symbol "missing-native-scene-fragment"))) (let ((missing (make-symbol "missing-native-scene-fragment")))
(let ((fragment (gethash node fragments missing))) (let ((fragment (gethash node fragments missing)))
(when (eq fragment 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))) fragment)))
(defun ebox-native-reflow--compile-scene-node (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." "Compile one flat scene NODE from already compiled FRAGMENTS."
(pcase (and (listp node) (plist-get node :ebox-type)) (pcase (and (listp node) (plist-get node :ebox-type))
('box ('box
(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))) (let ((child (plist-get node :ebox-content-node)))
(ebox-native-reflow--compile-box (ebox-native-reflow--compile-box
node node
(and child (and child
(ebox-native-reflow--compiled-scene-child fragments child)) (ebox-native-reflow--compiled-scene-child fragments child))
nil t))) nil t)))))
('concat ('concat
(list :type "row" (list :type "row"
:children :children
@ -1965,9 +2191,6 @@ backend has a matching two-dimensional layout contract."
('flex ('flex
(ebox-native-reflow--compile-flex (ebox-native-reflow--compile-flex
node fragments flex-content-min-widths)) 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" (_ (error "Native reflow unsupported scene node type: %S"
(and (listp node) (plist-get node :ebox-type)))))) (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) (defun ebox-native-reflow--retained-layout-children (node)
"Return NODE children referenced by one compiled native fragment." "Return NODE children referenced by one compiled native fragment."
(pcase (plist-get node :ebox-type) (pcase (plist-get node :ebox-type)
('box (when-let* ((child (plist-get node :ebox-content-node))) ('box
(list child))) (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)) ((or 'concat 'stack) (ebox--layout-children node))
('flex ('flex (ebox-tree-layout-children node))
(mapcar #'ebox--flex-item-source-node
(ebox-tree-layout-children node)))
(_ nil))) (_ nil)))
(defun ebox-native-reflow--retained-layout-postorder (root) (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 (dolist (child
(ebox-native-reflow--retained-layout-children node)) (ebox-native-reflow--retained-layout-children node))
(visit child)) (visit child))
(unless (eq (plist-get node :ebox-type) 'flex-item) (push node nodes)))
(push node nodes))))
(visit root)) (visit root))
(vconcat (nreverse nodes)))) (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-ids (plist-get state :native-touched-node-ids))
(touched-set (touched-set
(and (plist-get state :native-topology-stable-p) (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))) (make-hash-table :test 'equal)))
(retained-fast-p (retained-fast-p
(and touched-set (and touched-set
@ -3646,8 +3870,11 @@ metadata together with the text."
(let ((style (aref styles style-id))) (let ((style (aref styles style-id)))
(pcase (plist-get style :mode) (pcase (plist-get style :mode)
('set (setq faces (list (plist-get style :face)))) ('set (setq faces (list (plist-get style :face))))
('add (setq faces ('add
(append faces (list (plist-get style :face))))) (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"))))))) (_ (error "Native reflow frame has an invalid style mode")))))))
(defun ebox-native-reflow--fragment-face-baseline (defun ebox-native-reflow--fragment-face-baseline

View File

@ -31,6 +31,29 @@
:wrap-mode word :scroll-offset 0) :wrap-mode word :scroll-offset 0)
"Default longhand fields for the current runtime box encoding.") "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) (defun ebox--longhand-create (&optional plist)
"Return a runtime box field plist overridden by PLIST." "Return a runtime box field plist overridden by PLIST."
(let ((box (copy-sequence ebox--longhand))) (let ((box (copy-sequence ebox--longhand)))
@ -40,23 +63,42 @@
(plist-put box key value))) (plist-put box key value)))
box)) box))
;;;###autoload (defun ebox-node-factory--create-expanded (expanded declarations)
(defun ebox-create (&rest plist) "Create one runtime box from EXPANDED engine facts and DECLARATIONS."
"Create one legacy runtime box from PLIST. (let ((controlled-scroll-offset-p
(plist-member expanded :scroll-offset)))
New framework integrations should use `ebox-text-create' and (let ((box (copy-sequence ebox-node-factory--box-longhand)))
`ebox-box-create'. This factory remains public during source-tree migration." (cl-loop for (property value) on expanded by #'cddr
(let ((declarations (ebox-style-compile-declarations plist)) do (plist-put box property value))
(expanded (ebox-style-expand-ebox-plist plist))
(controlled-scroll-offset-p (plist-member plist :scroll-offset)))
(let ((box (ebox--longhand-create expanded)))
(plist-put box :ebox-type 'box) (plist-put box :ebox-type 'box)
(plist-put box :display '(block flow)) (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 (when controlled-scroll-offset-p
(plist-put box :ebox-scroll-offset-controlled-p t)) (plist-put box :ebox-scroll-offset-controlled-p t))
box))) 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) (provide 'ebox-node-factory)
;;; ebox-node-factory.el ends here ;;; ebox-node-factory.el ends here

View File

@ -11,6 +11,8 @@
(defvar ebox-viewport-height nil) (defvar ebox-viewport-height nil)
(defvar ebox--render-runtime-revision nil) (defvar ebox--render-runtime-revision nil)
(defvar ebox--surface-materialization-active 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-table nil)
(defvar ebox--render-cache-signature-cache nil) (defvar ebox--render-cache-signature-cache nil)
(defvar ebox--viewport-dependent-node-ids-cache nil) (defvar ebox--viewport-dependent-node-ids-cache nil)

View File

@ -111,8 +111,6 @@
('grid ('grid
(when-let* ((box (plist-get node :box))) (when-let* ((box (plist-get node :box)))
(ebox--ensure-region-id box))) (ebox--ensure-region-id box)))
('flex-item
(ebox-selector--node-region-id (plist-get node :node)))
(_ nil))) (_ nil)))
;;;###autoload ;;;###autoload

View File

@ -13,7 +13,8 @@
(defvar ebox--longhand) (defvar ebox--longhand)
(declare-function ebox-selector-parse "ebox-selector" (selector)) (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 (declare-function ebox--flex-normalize-container-props
"ebox-flex" (props)) "ebox-flex" (props))
(declare-function ebox-grid--normalize-config-props (declare-function ebox-grid--normalize-config-props
@ -156,8 +157,10 @@
:normalizer vertical-align) :normalizer vertical-align)
(:name :overflow :id ebox/overflow :initial scroll :contexts (box) (:name :overflow :id ebox/overflow :initial scroll :contexts (box)
:group structure :dirty-kind geometry :signature layout) :group structure :dirty-kind geometry :signature layout)
(:name :wrap-mode :id ebox/wrap-mode :initial word :contexts (box) (:name :wrap-mode :id ebox/wrap-mode :initial word :inherits t
:group geometry :dirty-kind geometry :signature layout) :contexts (box)
:group geometry :dirty-kind geometry :signature layout
:validator wrap-mode)
(:name :visibility :id ebox/visibility :initial visible :contexts (box) (:name :visibility :id ebox/visibility :initial visible :contexts (box)
:group paint :dirty-kind paint :signature paint) :group paint :dirty-kind paint :signature paint)
(:name :display :id ebox/display :initial nil :contexts (node) (: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))) (when-let* ((property (ebox-style-property name)))
(plist-get 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) (defun ebox-style-schema-id (name)
"Return namespaced ECSS schema id for Ebox property NAME." "Return namespaced ECSS schema id for Ebox property NAME."
(when-let* ((property (ebox-style-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." "Return non-nil when VALUE is a symbolic font attribute."
(or (null value) (symbolp value))) (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) (defun ebox-style--validator (name)
"Return schema validator named NAME." "Return schema validator named NAME."
(pcase 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-family #'ebox-style--font-family-p)
('font-height #'ebox-style--font-height-p) ('font-height #'ebox-style--font-height-p)
('font-attribute #'ebox-style--font-attribute-p) ('font-attribute #'ebox-style--font-attribute-p)
('wrap-mode #'ebox-style--wrap-mode-p)
('boolean (lambda (value) (memq value '(nil t)))) ('boolean (lambda (value) (memq value '(nil t))))
(_ (lambda (_value) 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) (defvar ebox-style--closed-computed-cache (make-hash-table :test #'equal)
"Bounded cache of selector-free, dependency-free computed styles.") "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 (defconst ebox-style--closed-computed-cache-limit 512
"Maximum selector-free computed styles retained across projections.") "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-schemas (plist-get property :id)
(ebox-style--schema-options property))) (ebox-style--schema-options property)))
(clrhash ebox-style--closed-computed-cache) (clrhash ebox-style--closed-computed-cache)
(clrhash ebox-style--closed-inheritance-cache)
(setq ebox-style--registered-schemas ebox-style-schemas)) (setq ebox-style--registered-schemas ebox-style-schemas))
(defun ebox-style--ensure-properties () (defun ebox-style--ensure-properties ()
@ -729,6 +757,53 @@ cascade pass during its initial static projection."
(and (symbolp property) (and (symbolp property)
(string-prefix-p "--" (symbol-name 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) (defun ebox-style--valid-plist-p (plist)
"Return non-nil when PLIST is a proper even-length list." "Return non-nil when PLIST is a proper even-length list."
(and (proper-list-p plist) (zerop (% (length plist) 2)))) (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)))) (and (numberp value) (not (ebox-style--positive-integer-p value))))
('grid-placement ('grid-placement
(and (or (numberp value) (proper-list-p value)) (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) (defun ebox-style--validate-declaration-values (declarations)
"Return DECLARATIONS after static Ebox-owned value validation." "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) (puthash cache-key compiled ebox-style--declaration-cache)
(copy-sequence compiled)))))) (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) (defun ebox-style-merge-declarations (base overrides)
"Merge canonical BASE and OVERRIDES through ECSS property schemas." "Merge canonical BASE and OVERRIDES through ECSS property schemas."
(ebox-style--ensure-properties) (ebox-style--ensure-properties)
@ -949,9 +1085,10 @@ winner is required so parent inherited geometry/paint cannot be stale."
(plist-member new 'ebox/color))) (plist-member new 'ebox/color)))
(defconst ebox-style--inherited-properties (defconst ebox-style--inherited-properties
'(ebox/color ebox/font ebox/font-family ebox/font-height (cl-loop for property in ebox-style--property-definitions
ebox/font-weight ebox/font-slant) when (plist-get property :inherits)
"Inherited ECSS properties whose parent values guard Theme deltas.") collect (plist-get property :id))
"Canonical ECSS properties inherited according to the shared schema.")
(defun ebox-style--inherited-style-signature (style) (defun ebox-style--inherited-style-signature (style)
"Return the inherited-value fingerprint of computed STYLE, or nil. "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)) (ecss-computed-style-specified-p style property))
(defun ebox-style-node-declarations (node) (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))) (let ((declarations (plist-get node :ebox-style-declarations)))
(if (memq (plist-get node :ebox-type) '(flex grid)) (if (memq (plist-get node :ebox-type) '(flex grid))
(ebox-style-merge-declarations (ebox-style-merge-declarations
declarations declarations
(plist-get (plist-get node :box) :ebox-style-overrides)) (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) (defun ebox-style--remove-style-properties (plist)
"Return PLIST without Ebox style or ECSS custom properties." "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))) do (plist-put box property value)))
box) 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) (defun ebox-style--box-values (style &optional snapshot)
"Return computed box values ready for Ebox engine projection." "Return computed box values ready for Ebox engine projection."
(let* ((snapshot (or snapshot (ebox-style--computed-snapshot style))) (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))) (plist-put values :color ebox-style--default-foreground)))
values)) 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 (defconst ebox-style--container-wrapper-neutral-properties
'(:width :height :box-sizing :color '(:width :height :box-sizing :color
:font :font-family :font-height :font-weight :font-slant) :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 () (defun ebox-style--new-container-wrapper ()
"Return a stable internal box for container-owned box paint." "Return a stable internal box for container-owned box paint."
(let ((wrapper (ebox-create :content "" :wrap-mode nil (let ((wrapper
:key 'ebox/style-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-wrapper t)
(plist-put wrapper :ebox-style-generated-wrapper t) (plist-put wrapper :ebox-style-generated-wrapper t)
wrapper)) wrapper))
@ -1349,32 +1572,16 @@ SNAPSHOT reuses a previously detached ECSS values snapshot when supplied."
(ebox-style--apply-display-axes node style snapshot) (ebox-style--apply-display-axes node style snapshot)
(pcase type (pcase type
('box ('box
(ebox-style--apply-engine-values (ebox-style--apply-engine-longhands
node node
(ebox-style--box-values style snapshot))) (ebox-style--cached-engine-longhands
style (eq (plist-get node :ebox-kind) 'text) snapshot))
)
((or 'flex 'grid) ((or 'flex 'grid)
(ebox-style--apply-container node style snapshot))) (ebox-style--apply-container node style snapshot)))
(ebox-style--apply-item node style snapshot)) (ebox-style--apply-item node style snapshot))
node) 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) (defun ebox-style-dirty-kind (name)
"Return dirty kind for canonical property or alias NAME." "Return dirty kind for canonical property or alias NAME."
(when-let* ((property (ebox-style-property 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. "Dynamic snapshot-detail-local node -> region ids cache.
This is an optimization for snapshot capture, not buffer runtime state.") This is an optimization for snapshot capture, not buffer runtime state.")
(defvar ebox--flex-item-prop-keys) (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) (defun ebox-tree-computed-display (node)
"Return NODE's canonical CSS-like display pair." "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)) ('stack '(block column))
('flex '(block flex)) ('flex '(block flex))
('grid '(block grid)) ('grid '(block grid))
('flex-item '(block flow))
(_ ebox--default-display)))) (_ ebox--default-display))))
(defun ebox-tree-display-outer (node) (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." "Return non-nil when NODE establishes a child layout context."
(memq (ebox-tree-display-inner node) '(row column flex grid))) (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) (defun ebox-tree-node-selector-type (node)
"Return NODE's CSS-like selector type symbol." "Return NODE's CSS-like selector type symbol."
(or (pcase (and (listp node) (plist-get node :ebox-kind))
('text 'text)
('box 'box)
(_ nil))
(pcase (and (listp node) (plist-get node :ebox-type)) (pcase (and (listp node) (plist-get node :ebox-type))
('box 'box) ('box 'box)
('concat 'row) ('concat 'row)
('stack 'column) ('stack 'column)
('flex 'flex) ('flex 'flex)
('grid 'grid) ('grid 'grid)
('flex-item 'item) (_ nil))))
(_ nil)))
(defun ebox-tree-metadata-string (value) (defun ebox-tree-metadata-string (value)
"Return VALUE normalized to a selector metadata string." "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) (defun ebox-tree--children-raw (node)
"Return NODE's direct child nodes without assigning runtime ids." "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 ('box
(if (plist-member node :children) (if (plist-member node :children)
(plist-get 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)) (ebox-tree-layout-children node))
('stack ('stack
(ebox-tree-layout-children node)) (ebox-tree-layout-children node))
('flex (_ nil))))))
(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)))
(defun ebox-tree-node-children (node) (defun ebox-tree-node-children (node)
"Return NODE's direct declarative children without mutating them. "Return NODE's direct declarative children without mutating them.
@ -189,7 +199,10 @@ must not modify a published runtime tree through this accessor."
('box ('box
(if (plist-member node :children) (if (plist-member node :children)
(dolist (child (plist-get 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))) (when-let* ((child (plist-get node :ebox-content-node)))
(funcall function child)))) (funcall function child))))
((or 'concat 'stack 'flex 'grid) ((or 'concat 'stack 'flex 'grid)
@ -202,18 +215,75 @@ must not modify a published runtime tree through this accessor."
('stack ('stack
(delq nil (list (plist-get node :top) (delq nil (list (plist-get node :top)
(plist-get node :bottom))))))) (plist-get node :bottom)))))))
(funcall function child))) (if (ebox-child-range--descriptor-p child)
('flex-item (dolist (item (ebox-child-range--descriptor-items child))
(when-let* ((child (plist-get node :node))) (funcall function item))
(funcall function child)))))) (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) node)
(defun ebox-tree-layout-children (node) (defun ebox-tree-layout-children (node)
"Return layout container children for NODE in render order. "Return layout container children for NODE in render order.
New row/column containers store flat `:children'. Legacy `ebox-concat' and New row/column containers store flat `:children'. Legacy `ebox-concat' and
`ebox-stack' nodes may still carry `:left'/`:right' or `:top'/`:bottom'." `ebox-stack' nodes may still carry `:left'/`:right' or `:top'/`:bottom'."
(if (plist-member node :children)
(plist-get node :children)
(if (plist-member node :ebox-child-sequence) (if (plist-member node :ebox-child-sequence)
(ebox-child-range--flatten (plist-get 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) (or (plist-get node :children)
(pcase (and (listp node) (plist-get node :ebox-type)) (pcase (and (listp node) (plist-get node :ebox-type))
('concat ('concat
@ -222,7 +292,7 @@ New row/column containers store flat `:children'. Legacy `ebox-concat' and
('stack ('stack
(delq nil (list (plist-get node :top) (delq nil (list (plist-get node :top)
(plist-get node :bottom)))) (plist-get node :bottom))))
(_ nil))))) (_ nil))))))
(defun ebox-tree--replace-direct-child (child replacements) (defun ebox-tree--replace-direct-child (child replacements)
"Return CHILD's identity replacement from REPLACEMENTS, when present. "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) (cdr replacement)
child)) child))
(defun ebox-tree--replace-direct-child-list (children replacements) (defun ebox-tree--replace-direct-child-list-result (children replacements)
"Replace CHILDREN found by identity in REPLACEMENTS. "Return `(CHILDREN . STYLE-DELTA)' after identity 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."
(if (null replacements) (if (null replacements)
children (cons children 0)
(let (changed replaced) (let ((style-delta 0) changed replaced)
(dolist (child children) (dolist (child children)
(let ((replacement (let ((replacement
(ebox-tree--replace-direct-child child replacements))) (ebox-tree--replace-direct-child child replacements)))
(unless (eq replacement child) (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 (when replacement
(push replacement replaced)))) (push replacement replaced))))
(if changed (cons (if changed (nreverse replaced) children)
(nreverse replaced) style-delta))))
children))))
(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 (defun ebox-tree--replace-direct-child-property
(node property replacements) (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)) (error "Ebox can only path-copy a node plist, got %S" node))
(let* ((type (plist-get node :ebox-type)) (let* ((type (plist-get node :ebox-type))
(copy (copy-sequence node)) (copy (copy-sequence node))
(sequence (plist-get copy :ebox-child-sequence))
(style-delta 0)
(scalar-properties (scalar-properties
(pcase type (pcase type
('box '(:ebox-content-node)) ('box '(:ebox-content-node))
('concat '(:left :right)) ('concat '(:left :right))
('stack '(:top :bottom)) ('stack '(:top :bottom))
('flex '(:box)) ('flex '(:box))
('grid '(:box)) ('grid '(:box)))))
('flex-item '(:node)))))
(dolist (property scalar-properties) (dolist (property scalar-properties)
(setq copy (setq copy
(ebox-tree--replace-direct-child-property (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))) (and (eq type 'box) (plist-member copy :children)))
(plist-member copy :children)) (plist-member copy :children))
(let* ((children (plist-get copy :children)) (let* ((children (plist-get copy :children))
(replaced (result
(ebox-tree--replace-direct-child-list children replacements))) (ebox-tree--replace-direct-child-list-result
children replacements))
(replaced (car result)))
(unless (eq replaced children) (unless (eq replaced children)
(setq copy (plist-put copy :children replaced))))) (setq copy (plist-put copy :children replaced)))
(when-let* ((sequence (and (memq type '(concat stack flex grid)) (unless sequence
(plist-get copy :ebox-child-sequence)))) (setq style-delta (cdr result)))))
(let ((updated sequence) changed fallback) (when sequence
(let ((updated sequence) changed fallback identity-locations)
(dolist (replacement replacements) (dolist (replacement replacements)
(let* ((old (car replacement)) (new (cdr replacement)) (let* ((old (car replacement)) (new (cdr replacement))
(location (and old (plist-get old :ebox-sequence-location))) (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) (list :parent-node-id (plist-get node :node-id)
:segment-index (car key-location) :segment-index (car key-location)
:offset (cdr 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 (when (and location
(equal (plist-get location :parent-node-id) (equal (plist-get location :parent-node-id)
(plist-get node :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 (if new
(setq updated (setq updated
(ebox-child-range--replace-item-at (ebox-child-range--replace-item-at
@ -345,10 +441,19 @@ still copied while every nested value remains shared."
(ebox-tree--replace-direct-child-list (ebox-tree--replace-direct-child-list
items replacements)) items replacements))
segments))) segments)))
(setq updated (ebox-child-range--build (nreverse segments)) (setq updated (ebox-child-range--build
(nreverse segments) nil t)
changed t))) changed t)))
(when changed (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)) copy))
(defun ebox-tree-copy-node-structure (root) (defun ebox-tree-copy-node-structure (root)
@ -365,7 +470,8 @@ original object identity."
(if (or (not (listp node)) (stringp node)) (if (or (not (listp node)) (stringp node))
node node
(let* ((type (plist-get node :ebox-type)) (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))) (raw (and material-p (plist-get node :children)))
(sequence (and material-p (sequence (and material-p
(plist-get node :ebox-child-sequence))) (plist-get node :ebox-child-sequence)))
@ -393,7 +499,7 @@ original object identity."
(ebox-child-range--descriptor-items entry))) (ebox-child-range--descriptor-items entry)))
segments) segments)
(push (cons nil (list (copy-node entry))) segments)))) (push (cons nil (list (copy-node entry))) segments))))
(setq copy (without copy :children)) (setq segments (nreverse segments))
(pcase type (pcase type
((or 'flex 'grid) ((or 'flex 'grid)
(when-let* ((box (plist-get node :box))) (when-let* ((box (plist-get node :box)))
@ -402,19 +508,19 @@ original object identity."
(setq copy (without (without copy :left) :right))) (setq copy (without (without copy :left) :right)))
('stack ('stack
(setq copy (without (without copy :top) :bottom)))) (setq copy (without (without copy :top) :bottom))))
(plist-put copy :ebox-child-sequence (ebox-tree--put-child-sequence
(ebox-child-range--build (nreverse segments)))) copy
(ebox-child-range--build segments nil t)
(cl-mapcan (lambda (segment)
(copy-sequence (cdr segment)))
segments)))
(let ((copy (let ((copy
(ebox-tree-copy-with-direct-child-replacements (ebox-tree-copy-with-direct-child-replacements
node node
(mapcar (lambda (child) (mapcar (lambda (child)
(cons child (copy-node child))) (cons child (copy-node child)))
(ebox-tree--children-raw node))))) (ebox-tree--children-raw node)))))
;; Only this exact deep-copy traversal has proven every copied copy))))))
;; 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-node root))) (copy-node root)))
(defun ebox-tree--runtime-identity-node-shell (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))) node node-id-set region-id-set)))
(pcase (plist-get node :ebox-type) (pcase (plist-get node :ebox-type)
('box ('box
(if (plist-member node :children) (if (or (plist-member node :children)
(plist-get node :ebox-child-sequence))
(plist-put shell :children (plist-put shell :children
(mapcar (ebox-tree-map-direct-children
node
(lambda (child) (lambda (child)
(ebox-tree--runtime-identity-skeleton (ebox-tree--runtime-identity-skeleton
child node-id-set region-id-set)) child node-id-set region-id-set))))
(plist-get node :children)))
(when-let* ((child (plist-get node :ebox-content-node))) (when-let* ((child (plist-get node :ebox-content-node)))
(plist-put shell :ebox-content-node (plist-put shell :ebox-content-node
(ebox-tree--runtime-identity-skeleton (ebox-tree--runtime-identity-skeleton
@ -475,12 +582,7 @@ Record its identities in NODE-ID-SET and REGION-ID-SET."
(lambda (child) (lambda (child)
(ebox-tree--runtime-identity-skeleton (ebox-tree--runtime-identity-skeleton
child node-id-set region-id-set)) child node-id-set region-id-set))
(ebox-tree-layout-children node)))) (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)))))
shell)) shell))
(defun ebox-tree-runtime-identity-snapshot (root) (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) (unless (gethash node seen)
(puthash node t seen) (puthash node t seen)
(puthash node t active) (puthash node t active)
(dolist (child (ebox-tree--children-raw node)) (ebox-tree-for-each-direct-child node #'visit)
(if (ebox-child-range--descriptor-p child)
(dolist (item (ebox-child-range--descriptor-items child))
(visit item))
(visit child)))
(remhash node active)))))) (remhash node active))))))
(visit root)) (visit root))
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) (defun ebox-tree--author-specifies-participation-p (node key)
"Return non-nil when NODE's author source specifies participation 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) (or (ebox-tree--declarations-specify-participation-p node key)
(and (null (plist-get node :ebox-computed-style)) (and (null (plist-get node :ebox-computed-style))
(plist-member node key))))) (plist-member node key))))
(defun ebox-tree--node-participation-keys (node source) (defun ebox-tree--node-participation-keys (node source)
"Return NODE participation keys from AUTHOR or COMPUTED SOURCE." "Return NODE participation keys from AUTHOR or COMPUTED SOURCE."
(let ((flex-item-p (eq (plist-get node :ebox-type) 'flex-item)) (let ((style (plist-get node :ebox-computed-style))
(style (plist-get node :ebox-computed-style))
keys) keys)
(dolist (key ebox-tree--participation-keys) (dolist (key ebox-tree--participation-keys)
(when (when
@ -599,33 +682,16 @@ Host references are root-global opaque metadata and are compared with `equal'."
('author ('author
(ebox-tree--author-specifies-participation-p node key)) (ebox-tree--author-specifies-participation-p node key))
('computed ('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) (and (plist-member node key)
(ebox-tree--style-specifies-participation-p style key)))) (ebox-tree--style-specifies-participation-p style key)))
(_ (error "Unknown Ebox participation source: %S" source))) (_ (error "Unknown Ebox participation source: %S" source)))
(push key keys))) (push key keys)))
(nreverse keys))) (nreverse keys)))
(defun ebox-tree--child-layout-kind (node parent-kind) (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) (pcase (plist-get node :ebox-type)
('flex-item parent-kind)
('flex 'flex) ('flex 'flex)
('grid 'grid) ('grid 'grid)
(_ (pcase (if (and (eq (plist-get node :ebox-kind) 'box) (_ (pcase (if (and (eq (plist-get node :ebox-kind) 'box)
@ -636,76 +702,9 @@ Host references are root-global opaque metadata and are compared with `equal'."
('flow 'normal) ('flow 'normal)
(kind kind))))) (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 (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." "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))) (let ((keys (ebox-tree--node-participation-keys node source)))
(when keys (when keys
(when (eq (plist-get node :ebox-kind) 'text) (when (eq (plist-get node :ebox-kind) 'text)
@ -722,26 +721,15 @@ Host references are root-global opaque metadata and are compared with `equal'."
(error "Flex participation requires a direct Flex parent: %S" (error "Flex participation requires a direct Flex parent: %S"
keys))) keys)))
(_ (error "Ebox participation requires a Flex or Grid parent: %S" (_ (error "Ebox participation requires a Flex or Grid parent: %S"
keys)))))) keys)))))
node) node)
(defun ebox-tree--indexed-parent-context (defun ebox-tree--indexed-parent-context
(node-id node-table parent-table) (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)) (let* ((parent-id (gethash node-id parent-table))
(parent (and parent-id (gethash parent-id node-table))) (parent (and parent-id (gethash parent-id node-table))))
(grandparent-id (and parent-id (gethash parent-id parent-table))) (and parent (ebox-tree--child-layout-kind parent nil))))
(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)))
(defun ebox-tree-validate-indexed-participation (defun ebox-tree-validate-indexed-participation
(node-table parent-table node-ids &optional source) (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)) (setq source (or source 'author))
(dolist (node-id (delete-dups (copy-sequence node-ids))) (dolist (node-id (delete-dups (copy-sequence node-ids)))
(when-let* ((node (gethash node-id node-table))) (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 (ebox-tree--validate-node-parent-participation
node parent-kind parent grandparent source)))) node
(ebox-tree--indexed-parent-context node-id node-table parent-table)
source)))
node-table) node-table)
(defun ebox-tree-validate-declarative-root (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)) (active (make-hash-table :test 'eq))
(range-refs (make-hash-table :test 'equal))) (range-refs (make-hash-table :test 'equal)))
(cl-labels (cl-labels
((visit (node parent-kind parent grandparent) ((visit (node parent-kind)
(when (and (listp node) (not (stringp node))) (when (and (listp node) (not (stringp node)))
(when (gethash node active) (when (gethash node active)
(error "Ebox declarative tree contains a cycle")) (error "Ebox declarative tree contains a cycle"))
@ -781,14 +768,26 @@ and all explicit identities are compared with `equal'. Return ROOT on success."
(puthash node t active) (puthash node t active)
(when validate-participation-p (when validate-participation-p
(ebox-tree--validate-node-parent-participation (ebox-tree--validate-node-parent-participation
node parent-kind parent grandparent 'author)) node parent-kind 'author))
(let* ((keys (make-hash-table :test 'equal)) (let* ((keys (make-hash-table :test 'equal))
(type (plist-get node :ebox-type)) (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))) (plist-get node :children)))
children) children)
(when (and raw (not (proper-list-p raw))) (when (and raw (not (proper-list-p raw)))
(error "Ebox material children must be a proper list")) (error "Ebox material children must be a proper list"))
(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)) (dolist (entry (ebox-tree--children-raw node))
(if (ebox-child-range--descriptor-p entry) (if (ebox-child-range--descriptor-p entry)
(progn (progn
@ -807,11 +806,14 @@ and all explicit identities are compared with `equal'. Return ROOT on success."
(unless (and (listp item) (not (stringp item))) (unless (and (listp item) (not (stringp item)))
(error "Ebox child Range item must be a node")) (error "Ebox child Range item must be a node"))
(push item children)))) (push item children))))
(progn
(when (ebox-child-range--descriptor-p entry)
(error "Ebox child Range descriptor is invalid here"))
(push entry children)))) (push entry children))))
(dolist (child (nreverse 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) (when-let* ((key (and (listp child)
(not (stringp child)) (not (stringp child))
(plist-get child :key)))) (plist-get child :key))))
@ -820,10 +822,9 @@ and all explicit identities are compared with `equal'. Return ROOT on success."
key)) key))
(puthash key t keys)) (puthash key t keys))
(visit child (visit child
(ebox-tree--child-layout-kind node parent-kind) (ebox-tree--child-layout-kind node parent-kind)))
node parent))) (remhash node active)))))
(remhash node active)))) (visit root root-parent-kind))
(visit root root-parent-kind nil nil))
root)) root))
(defun ebox-tree-clear-runtime-identities (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)) (plist-put node :surface-object nil))
(when (plist-member node :ebox-sequence-location) (when (plist-member node :ebox-sequence-location)
(plist-put node :ebox-sequence-location nil)) (plist-put node :ebox-sequence-location nil))
(dolist (child (ebox-tree--children-raw node)) (ebox-tree-for-each-direct-child node #'visit))))
(visit child)))))
(visit root)) (visit root))
root) root)
(defconst ebox-tree--runtime-source-keys (defconst ebox-tree--runtime-source-keys
'(:node-id :region-id :surface-object :render-cache :ebox-sequence-location '(: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.") "Runtime-owned plist keys excluded from declarative source signatures.")
(defconst ebox-tree--child-source-keys (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 (defconst ebox-tree--excluded-source-keys
(append ebox-tree--runtime-source-keys (append ebox-tree--runtime-source-keys
ebox-tree--child-source-keys ebox-tree--child-source-keys
'(:ebox-style-declarations :ebox-style-overrides '(:ebox-style-overrides
:ebox-grid-config :ebox-grid-config
:ebox-computed-style :ebox-style-wrapper :ebox-computed-style :ebox-style-wrapper
:ebox-style-generated-wrapper)) :ebox-style-generated-wrapper
"Non-rendering keys excluded from node-local source comparison. :ebox-author-style-count :ebox-author-style-required-p
Cascade declarations and computed-style records are side state; their :ebox-text-value :ebox-candidate-computed-style-p))
projected Ebox longhands carry the actual paint and layout consequences.") "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 (defconst ebox-tree-metadata-source-keys
'(:key :id :class :host-ref) '(:key :id :class :host-ref)
@ -1073,12 +1077,6 @@ candidate runtime index agree."
(setq props (append props (list key (plist-get node key))))))) (setq props (append props (list key (plist-get node key)))))))
props)) 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) (defun ebox-tree--semantic-layout-leaves (node)
"Return semantic leaves below internal layout adapter NODE." "Return semantic leaves below internal layout adapter NODE."
(pcase (and (listp node) (plist-get node :ebox-type)) (pcase (and (listp node) (plist-get node :ebox-type))
@ -1086,24 +1084,22 @@ candidate runtime index agree."
(apply #'append (apply #'append
(mapcar #'ebox-tree--semantic-layout-leaves (mapcar #'ebox-tree--semantic-layout-leaves
(ebox-tree-layout-children node)))) (ebox-tree-layout-children node))))
('flex-item
(ebox-tree--semantic-layout-leaves (plist-get node :node)))
(_ (and (listp node) (list node))))) (_ (and (listp node) (list node)))))
(defun ebox-tree-semantic-children (node) (defun ebox-tree-semantic-children (node)
"Return NODE's logical children without internal layout adapters." "Return NODE's logical children without internal layout adapters."
(pcase (and (listp node) (plist-get node :ebox-type)) (pcase (and (listp node) (plist-get node :ebox-type))
('box ('box
(if (plist-get node :ebox-child-sequence)
(ebox-tree-layout-children node)
(if (plist-member node :children) (if (plist-member node :children)
(plist-get node :children) (plist-get node :children)
(when-let* ((content-node (plist-get node :ebox-content-node))) (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) ((or 'concat 'stack)
(ebox-tree--semantic-layout-leaves node)) (ebox-tree--semantic-layout-leaves node))
('flex ('flex (ebox-tree-layout-children node))
(mapcar #'ebox-tree-flex-item-source-node (ebox-tree-layout-children node)))
('grid (ebox-tree-layout-children node)) ('grid (ebox-tree-layout-children node))
('flex-item (delq nil (list (plist-get node :node))))
(_ (ebox-tree-children node)))) (_ (ebox-tree-children node))))
(defun ebox-tree-selector-index (root) (defun ebox-tree-selector-index (root)
@ -1184,14 +1180,8 @@ candidate runtime index agree."
(defun ebox-tree-flex-participation-props (node) (defun ebox-tree-flex-participation-props (node)
"Return raw flex item participation props attached to NODE." "Return raw flex item participation props attached to NODE."
(cond (and (listp node)
((and (listp node) (eq (plist-get node :ebox-type) 'flex-item)) (ebox-tree-flex-direct-participation-props node)))
(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)))
(defun ebox-tree-node-all-region-ids (node) (defun ebox-tree-node-all-region-ids (node)
"Return every box region id contained by NODE. "Return every box region id contained by NODE.
@ -1238,8 +1228,6 @@ not only the public update ids."
(apply #'append (apply #'append
(mapcar #'ebox-tree-node-all-region-ids (mapcar #'ebox-tree-node-all-region-ids
(ebox-tree-layout-children node))))) (ebox-tree-layout-children node)))))
('flex-item
(ebox-tree-node-all-region-ids (plist-get node :node)))
(_ nil)))) (_ nil))))
(when ebox--node-region-ids-cache (when ebox--node-region-ids-cache
(puthash node region-ids 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) (cl-some (lambda (child)
(ebox-tree-node-paint-conflict-p child paint-key t)) (ebox-tree-node-paint-conflict-p child paint-key t))
(ebox-tree-layout-children node)))) (ebox-tree-layout-children node))))
('flex-item
(ebox-tree-node-paint-conflict-p
(plist-get node :node) paint-key inherited))
(_ nil))))) (_ nil)))))
(defun ebox-tree-node-path-to-region (node region-id) (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))) (when-let* ((path (ebox-tree-node-path-to-region child region-id)))
(throw 'found (append path (list node))))) (throw 'found (append path (list node)))))
nil)))) nil))))
('flex-item
(ebox-tree-node-path-to-region (plist-get node :node) region-id))
(_ nil))))) (_ nil)))))
(defun ebox-tree-node-visible-overflow-p (node) (defun ebox-tree-node-visible-overflow-p (node)
"Return non-nil when NODE contains visible overflow that cannot be span patched." "Return non-nil when NODE contains visible overflow that cannot be span patched."
(cond (cond
((or (stringp node) (not (listp node))) nil) ((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 (t
(pcase (plist-get node :ebox-type) (pcase (plist-get node :ebox-type)
('box ('box
@ -1371,20 +1358,18 @@ wrapper's visible content is produced by the flex renderer."
(ebox--box-visible-overflow-p (plist-get node :box))) (ebox--box-visible-overflow-p (plist-get node :box)))
(cl-some #'ebox-tree-node-visible-overflow-p (cl-some #'ebox-tree-node-visible-overflow-p
(ebox-tree-layout-children node)))) (ebox-tree-layout-children node))))
('flex-item
(ebox-tree-node-visible-overflow-p (plist-get node :node)))
(_ nil))))) (_ nil)))))
(defalias 'ebox--computed-display #'ebox-tree-computed-display) (defalias 'ebox--computed-display #'ebox-tree-computed-display)
(defalias 'ebox--display-outer #'ebox-tree-display-outer) (defalias 'ebox--display-outer #'ebox-tree-display-outer)
(defalias 'ebox--display-inner #'ebox-tree-display-inner) (defalias 'ebox--display-inner #'ebox-tree-display-inner)
(defalias 'ebox--formatting-context-p #'ebox-tree-formatting-context-p) (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--node-selector-type #'ebox-tree-node-selector-type)
(defalias 'ebox--layout-children #'ebox-tree-layout-children) (defalias 'ebox--layout-children #'ebox-tree-layout-children)
(defalias 'ebox--node-children #'ebox-tree-children) (defalias 'ebox--node-children #'ebox-tree-children)
(defalias 'ebox--flex-direct-participation-props (defalias 'ebox--flex-direct-participation-props
#'ebox-tree-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 (defalias 'ebox--flex-participation-props
#'ebox-tree-flex-participation-props) #'ebox-tree-flex-participation-props)
(defalias 'ebox--node-all-region-ids #'ebox-tree-node-all-region-ids) (defalias 'ebox--node-all-region-ids #'ebox-tree-node-all-region-ids)

100
ebox.el
View File

@ -432,6 +432,7 @@ publication."
(defun ebox--ensure-region-id (box) (defun ebox--ensure-region-id (box)
"Return BOX's stable region id, creating one if needed." "Return BOX's stable region id, creating one if needed."
(ebox--ensure-node-id box)
(or (ebox-get box :region-id) (or (ebox-get box :region-id)
(let ((id (ebox--next-region-id))) (let ((id (ebox--next-region-id)))
(ebox-put box :region-id 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. "Return NODE's region ids in document order.
NODE can be an unrendered layout tree or an already-rendered string. 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 layout trees, missing ids are assigned to box plists and remain stable
for later renders and dynamic updates. 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
)"
(cond (cond
((stringp node) ((stringp node)
(ebox--string-region-ids node)) (ebox--string-region-ids node))
@ -677,8 +667,6 @@ Use this to capture ids *before* inserting into a buffer:
(apply #'append (apply #'append
(mapcar #'ebox-region-ids (mapcar #'ebox-region-ids
(ebox-tree-layout-children node))))) (ebox-tree-layout-children node)))))
((eq type 'flex-item)
(ebox-region-ids (plist-get node :node)))
(t nil)))))) (t nil))))))
(defun ebox--runtime-node-ids (node) (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) (defun ebox--clear-buffer-runtime-state (&optional buffer)
"Remove all runtime caches owned by BUFFER. "Remove all runtime caches owned by BUFFER.
Defaults to the current 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) (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--clear-region-runtime-caches
(ebox--node-all-region-ids root))) (ebox--node-all-region-ids root)))
(ebox--clear-buffer-render-state buffer))) (ebox--clear-buffer-render-state buffer)))
@ -3849,6 +3838,19 @@ Defaults to the current buffer."
(:background-color :bgcolor) (:background-color :bgcolor)
(_ property))) (_ 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) (defun ebox--region-update-analysis (box expanded declarations)
"Return change analysis for BOX, EXPANDED fields, and DECLARATIONS." "Return change analysis for BOX, EXPANDED fields, and DECLARATIONS."
(let (content-seen (let (content-seen
@ -3867,12 +3869,18 @@ Defaults to the current buffer."
(setq style-changed-p t))) (setq style-changed-p t)))
(t (t
(push key unknown-keys)))) (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 (list :changed-p
(or (not (equal declarations (or (not (equal declarations
(plist-get box :ebox-style-declarations))) (plist-get box :ebox-style-declarations)))
style-changed-p style-changed-p
(and content-seen (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)))) :unknown-keys (nreverse unknown-keys))))
(defun ebox--schedule-runtime-prewarm-after-batch-flush (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)) (ebox-tree-copy-node-structure batch-root))
(and path-copy-p (and path-copy-p
(ebox-incremental-surface-region-candidate-root (ebox-incremental-surface-region-candidate-root
buffer region-id)) buffer region-id t))
(ebox-tree-copy-node-structure (plist-get state :root-node))))) (ebox-tree-copy-node-structure (plist-get state :root-node)))))
(defun ebox--surface-region-apply-props (defun ebox--surface-region-apply-props
@ -3910,7 +3918,8 @@ cannot be changed through the unpublished batch root."
(unless box (unless box
(user-error "Ebox region handle no longer resolves to a box")) (user-error "Ebox region handle no longer resolves to a box"))
(let ((declarations-changed (let ((declarations-changed
(not (equal declarations (not (ebox-style-declarations-equal-p
declarations
(plist-get box :ebox-style-declarations))))) (plist-get box :ebox-style-declarations)))))
(when declarations-changed (when declarations-changed
(plist-put box :ebox-style-declarations declarations)) (plist-put box :ebox-style-declarations declarations))
@ -3918,10 +3927,19 @@ cannot be changed through the unpublished batch root."
for target-key = (ebox--region-update-normalize-property key) for target-key = (ebox--region-update-normalize-property key)
when (or (eq target-key :content) when (or (eq target-key :content)
(memq target-key ebox--region-update-longhand-props)) (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 (and declarations-changed
(ebox-style-property key))) (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))) and do (push target-key changed-keys)))
(nreverse changed-keys))) (nreverse changed-keys)))
@ -4176,7 +4194,7 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch."
:content) :content)
(stringp (cadr expanded)) (stringp (cadr expanded))
(numberp (ebox-get candidate-box :width)) (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 (ebox-style-cascade-active-p))
(not (plist-get state :cascade-required-p)) (not (plist-get state :cascade-required-p))
(null (plist-get state :scroll-region-ids)))) (null (plist-get state :scroll-region-ids))))
@ -4296,8 +4314,7 @@ Examples:
;;;###autoload ;;;###autoload
(defun ebox-render-to-buffer (buffer-or-name node) (defun ebox-render-to-buffer (buffer-or-name node)
"Render layout NODE into BUFFER-OR-NAME and return the buffer. "Render layout NODE into BUFFER-OR-NAME and return the buffer.
NODE can be any box/concat/stack node created by `ebox-create', NODE is a typed TextNode/BoxNode or a pre-rendered string."
`ebox-concat', or `ebox-stack', or a pre-rendered string."
(declare (indent 1)) (declare (indent 1))
(unless (stringp node) (unless (stringp node)
;; Validate opaque host identities before preparing a target generation. ;; 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-replace-host-ref
ebox-candidate-patch-host-paint ebox-candidate-patch-host-paint
ebox-clear-cache ebox-clear-cache
ebox-column
ebox-column-layout-create ebox-column-layout-create
ebox-normal-layout-create ebox-normal-layout-create
ebox-row-layout-create ebox-row-layout-create
ebox-text-create ebox-text-create
ebox-box-create ebox-box-create
ebox-commit ebox-commit
ebox-concat
ebox-create
ebox-display-buffer ebox-display-buffer
ebox-display-signature ebox-display-signature
ebox-flex
ebox-flex-layout-create ebox-flex-layout-create
ebox-flex-item
ebox-grid
ebox-grid-fr
ebox-grid-layout-create ebox-grid-layout-create
ebox-host-ref-bounds ebox-host-ref-bounds
ebox-host-ref-position 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-burst-end
ebox-render-to-buffer ebox-render-to-buffer
ebox-rerender-buffer-with-context ebox-rerender-buffer-with-context
ebox-row
ebox-scroll-down ebox-scroll-down
ebox-scroll-map ebox-scroll-map
ebox-scroll-page-down 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-all
ebox-selector-query-buffer ebox-selector-query-buffer
ebox-selector-update-buffer ebox-selector-update-buffer
ebox-spacer
ebox-stack
ebox-string-pixel-width ebox-string-pixel-width
ebox-update-selector ebox-update-selector
ebox-viewport-window-width ebox-viewport-window-width
@ -4684,29 +4691,10 @@ and are documented separately in the public API reference.")
;; ebox-normal-layout-create ;; ebox-normal-layout-create
;; Create the typed Normal LayoutConfig. ;; Create the typed Normal LayoutConfig.
;; ebox-box-create :layout config :children nodes &rest geometry-props ;; ebox-box-create :layout config :children nodes &rest geometry-props
;; Create one typed canonical BoxNode. The current vertical slice ;; Create one typed canonical BoxNode.
;; 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
;; ebox-build dsl ;; ebox-build dsl
;; Compile ETML-style (box/row/column/flex/spacer ...) forms into ;; Compile String/text/box/row/column/flex/grid author forms into
;; normal ebox layout nodes. ;; typed TextNode/BoxNode values.
;;
;; ── 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.
;; ;;
;; ── Rendering ───────────────────────────────────────────────── ;; ── Rendering ─────────────────────────────────────────────────
;; ebox-render box → propertized string (no buffer side-effect) ;; 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> fn coordinate_edits(
where old_len: usize,
F: Fn(&T, &T) -> bool, new_len: usize,
{ text_matches: &[(usize, usize, usize)],
) -> Vec<SpanEdit> {
let mut old_position = 0; let mut old_position = 0;
let mut new_position = 0; let mut new_position = 0;
let mut edits = Vec::new(); 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 { if old_position < matched_old || new_position < matched_new {
push_edit( push_edit(
&mut edits, &mut edits,
@ -253,28 +254,26 @@ where
push_edit( push_edit(
&mut edits, &mut edits,
old_position, old_position,
old.len(), old_len,
new_position, new_position,
new.len(), new_len,
); );
edits edits
} }
fn semantic_edits<T, FText, FSemantic>( fn semantic_edits<T, FSemantic>(
old: &[T], old: &[T],
new: &[T], new: &[T],
text_equal: &FText, text_matches: &[(usize, usize, usize)],
semantic_equal: &FSemantic, semantic_equal: &FSemantic,
) -> Vec<SpanEdit> ) -> Vec<SpanEdit>
where where
FText: Fn(&T, &T) -> bool,
FSemantic: Fn(&T, &T) -> bool, FSemantic: Fn(&T, &T) -> bool,
{ {
let text_matches = matches(old, new, text_equal);
let mut old_position = 0; let mut old_position = 0;
let mut new_position = 0; let mut new_position = 0;
let mut edits = Vec::new(); 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 { if old_position < matched_old || new_position < matched_new {
push_edit( push_edit(
&mut edits, &mut edits,
@ -343,13 +342,14 @@ where
target: target_revision, target: target_revision,
}); });
} }
let text_matches = matches(old, new, &text_equal);
Ok(CommitBatch { Ok(CommitBatch {
base_revision, base_revision,
target_revision, target_revision,
base_extent: old.len(), base_extent: old.len(),
target_extent: new.len(), target_extent: new.len(),
semantic_edits: semantic_edits(old, new, &text_equal, &semantic_equal), semantic_edits: semantic_edits(old, new, &text_matches, &semantic_equal),
coordinate_edits: coordinate_edits(old, new, &text_equal), coordinate_edits: coordinate_edits(old.len(), new.len(), &text_matches),
}) })
} }

View File

@ -121,6 +121,14 @@ enum LayoutNode {
Box { Box {
region_id: i64, region_id: i64,
content: Option<Box<MeasuredText>>, 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>>, child: Option<Box<LayoutNode>>,
content_width_exact: bool, content_width_exact: bool,
#[serde(default)] #[serde(default)]
@ -142,6 +150,8 @@ enum LayoutNode {
margin_bottom: i64, margin_bottom: i64,
border_left: i64, border_left: i64,
border_right: i64, border_right: i64,
#[serde(default)]
typography_style: Option<u32>,
foreground_style: Option<u32>, foreground_style: Option<u32>,
background_style: Option<u32>, background_style: Option<u32>,
border_left_style: Option<u32>, border_left_style: Option<u32>,
@ -156,6 +166,16 @@ enum LayoutNode {
wrap_mode: WrapMode, wrap_mode: WrapMode,
scroll_offset: i64, 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 { Row {
children: Vec<LayoutNode>, children: Vec<LayoutNode>,
}, },
@ -1249,6 +1269,9 @@ fn composed_face(
list_value = false; list_value = false;
} }
StyleMode::Add => { StyleMode::Add => {
if faces.len() == 1 && styles[faces[0]].face == style.face {
continue;
}
if !faces.is_empty() { if !faces.is_empty() {
list_value = true; list_value = true;
} }
@ -2441,6 +2464,7 @@ fn encode_patch_combined_payload(
/// Emacs adapter payload built on the editor-independent core batch. /// Emacs adapter payload built on the editor-independent core batch.
struct EmacsCommitBatch { struct EmacsCommitBatch {
core: CommitBatch, core: CommitBatch,
publication_edits: Vec<SpanEdit>,
combined_payload: Vec<u8>, combined_payload: Vec<u8>,
metadata_records: usize, metadata_records: usize,
fragment_bytes: Vec<u8>, fragment_bytes: Vec<u8>,
@ -2451,6 +2475,24 @@ struct EmacsCommitBatch {
fragment_style_delta: Option<FragmentStyleDelta>, 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( fn build_emacs_commit_batch(
old: &FlatLayoutTape, old: &FlatLayoutTape,
target: &FlatLayoutTape, target: &FlatLayoutTape,
@ -2460,8 +2502,8 @@ fn build_emacs_commit_batch(
max_bytes: usize, max_bytes: usize,
) -> Result<EmacsCommitBatch, String> { ) -> Result<EmacsCommitBatch, String> {
let core = tape_commit_batch(&old.characters, &target.characters); let core = tape_commit_batch(&old.characters, &target.characters);
let descriptor_bytes = core let publication_edits = coalesce_publication_edits(&core.semantic_edits);
.semantic_edits let descriptor_bytes = publication_edits
.len() .len()
.checked_add(core.coordinate_edits.len()) .checked_add(core.coordinate_edits.len())
.ok_or_else(|| "Native layout patch descriptor count overflow".to_owned())? .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( let replacements = encode_patch_replacement_body(
&target.characters, &target.characters,
&core.semantic_edits, &publication_edits,
compiled_styles, compiled_styles,
payload_limit, payload_limit,
)?; )?;
@ -2552,6 +2594,7 @@ fn build_emacs_commit_batch(
}; };
Ok(EmacsCommitBatch { Ok(EmacsCommitBatch {
core, core,
publication_edits,
combined_payload, combined_payload,
metadata_records, metadata_records,
fragment_bytes, fragment_bytes,
@ -2588,7 +2631,7 @@ pub(crate) fn encode_layout_patch_tape(
root_metadata, root_metadata,
max_bytes, 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 = let coordinate_patch_count =
count_u32(batch.core.coordinate_edits.len(), "coordinate patches")?; count_u32(batch.core.coordinate_edits.len(), "coordinate patches")?;
let target_character_count = u64::try_from(target.characters.len()) 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)?; let mut writer = TapeWriter::new(max_bytes)?;
writer.push_u64(base_character_count)?; writer.push_u64(base_character_count)?;
writer.push_u32(patch_count)?; writer.push_u32(patch_count)?;
// Publication remains a minimal text/property patch. The compact target // Publication coalesces only one-character equal-coordinate gaps. The
// role sidecar lets Emacs swap exact runtime indexes without synchronously // pure core retains its exact minimal semantic edits, while the compact
// rescanning the page or rebuilding thousands of markers per frame. // target role sidecar lets Emacs swap exact runtime indexes without
// synchronously rescanning the page or rebuilding thousands of markers.
writer.push_u32( writer.push_u32(
u32::from(batch.reuse_fragment_template) u32::from(batch.reuse_fragment_template)
| (u32::from(batch.reuse_ownership_template) << 1) | (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(coordinate_patch_count)?;
writer.push_u32(0)?; 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_start as u64)?;
writer.push_u64(patch.old_end as u64)?; writer.push_u64(patch.old_end as u64)?;
writer.push_u64(patch.new_start as u64)?; writer.push_u64(patch.new_start as u64)?;
@ -2991,6 +3035,7 @@ fn add_context_work(
work_units: &mut usize, work_units: &mut usize,
) -> Result<(), String> { ) -> Result<(), String> {
match node { match node {
LayoutNode::Text { .. } => {}
LayoutNode::Box { LayoutNode::Box {
child, child,
height, height,
@ -3063,9 +3108,59 @@ fn validate_node(
} }
add_work_units(work_units, 1)?; add_work_units(work_units, 1)?;
match node { 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 { LayoutNode::Box {
region_id, region_id,
content, content,
content_region_id,
content_typography_style,
content_foreground_style,
content_surface_template_id,
child, child,
content_width_exact: _, content_width_exact: _,
content_min_width, content_min_width,
@ -3085,6 +3180,7 @@ fn validate_node(
margin_bottom, margin_bottom,
border_left, border_left,
border_right, border_right,
typography_style,
foreground_style, foreground_style,
background_style, background_style,
border_left_style, border_left_style,
@ -3106,6 +3202,14 @@ fn validate_node(
"Native layout box must contain exactly one text or child value".to_owned(), "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 [ for (name, size) in [
("width", width), ("width", width),
("min-width", min_width), ("min-width", min_width),
@ -3140,6 +3244,9 @@ fn validate_node(
.unwrap_or(usize::MAX), .unwrap_or(usize::MAX),
)?; )?;
for style_id in [ for style_id in [
content_typography_style,
content_foreground_style,
typography_style,
foreground_style, foreground_style,
background_style, background_style,
border_left_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 let Some(text) = content {
if text.lines.is_empty() { if text.lines.is_empty() {
return Err("Native layout measured text must contain one line".to_owned()); 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> { fn exact_rendered_height(node: &LayoutNode, context: LayoutContext) -> Option<i64> {
match node { match node {
LayoutNode::Text { content, .. } => i64::try_from(content.lines.len()).ok(),
LayoutNode::Box { LayoutNode::Box {
height, height,
min_height, min_height,
@ -5158,6 +5274,10 @@ fn render_node_with_override(
LayoutNode::Box { LayoutNode::Box {
region_id, region_id,
content, content,
content_region_id,
content_typography_style,
content_foreground_style,
content_surface_template_id,
child, child,
content_width_exact, content_width_exact,
content_min_width: _, content_min_width: _,
@ -5178,6 +5298,7 @@ fn render_node_with_override(
margin_bottom, margin_bottom,
border_left, border_left,
border_right, border_right,
typography_style,
foreground_style, foreground_style,
background_style, background_style,
border_left_style, border_left_style,
@ -5439,7 +5560,16 @@ fn render_node_with_override(
} }
let mut formatted = if let Some(text) = content { 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() .into_iter()
.map(|line| line.padded(content_width, *text_align)) .map(|line| line.padded(content_width, *text_align))
.collect::<Vec<_>>() .collect::<Vec<_>>()
@ -5580,6 +5710,7 @@ fn render_node_with_override(
*padding_right, *padding_right,
region_properties(RegionRole::PaddingRight, *region_id, None), region_properties(RegionRole::PaddingRight, *region_id, None),
); );
line.apply_style(*typography_style);
line.apply_style(*foreground_style); line.apply_style(*foreground_style);
line.apply_style(*background_style); line.apply_style(*background_style);
line.prepend_space_with_properties( line.prepend_space_with_properties(
@ -5648,6 +5779,24 @@ fn render_node_with_override(
} }
Ok(rendered) 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 } => { LayoutNode::Row { children } => {
let child_context = LayoutContext { let child_context = LayoutContext {
inline_auto_width_intrinsic: true, inline_auto_width_intrinsic: true,
@ -5801,6 +5950,10 @@ mod tests {
LayoutNode::Box { LayoutNode::Box {
region_id, region_id,
content: Some(Box::new(content)), content: Some(Box::new(content)),
content_region_id: None,
content_typography_style: None,
content_foreground_style: None,
content_surface_template_id: None,
child: None, child: None,
content_width_exact: false, content_width_exact: false,
content_min_width: None, content_min_width: None,
@ -5821,6 +5974,7 @@ mod tests {
margin_bottom: 0, margin_bottom: 0,
border_left: 0, border_left: 0,
border_right: 0, border_right: 0,
typography_style: None,
foreground_style: None, foreground_style: None,
background_style: None, background_style: None,
border_left_style: None, border_left_style: None,
@ -5857,6 +6011,10 @@ mod tests {
LayoutNode::Box { LayoutNode::Box {
region_id, region_id,
content: None, content: None,
content_region_id: None,
content_typography_style: None,
content_foreground_style: None,
content_surface_template_id: None,
child: Some(Box::new(child)), child: Some(Box::new(child)),
content_width_exact: true, content_width_exact: true,
content_min_width: None, content_min_width: None,
@ -5877,6 +6035,7 @@ mod tests {
margin_bottom: 0, margin_bottom: 0,
border_left: 0, border_left: 0,
border_right: 0, border_right: 0,
typography_style: None,
foreground_style: None, foreground_style: None,
background_style: None, background_style: None,
border_left_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 { fn literal_from_full_tape(tape: &[u8]) -> &str {
let literal_length = u64::from_le_bytes(tape[112..120].try_into().unwrap()) as usize; 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; 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[92..100].try_into().unwrap()), 7);
assert_eq!(u64::from_le_bytes(encoded[112..120].try_into().unwrap()), 6); 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; 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 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 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; 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_length > 0);
assert!(fragment_records > 0); assert!(fragment_records > 0);
assert_eq!(encoded.len(), payload_end + fragment_length); assert_eq!(encoded.len(), payload_end + fragment_length);
assert!(payload.starts_with("[#(\"Xe \"")); assert!(payload.starts_with("[#(\"Xdef \""));
assert!(payload.contains("1 2 (ebox-content 9 ebox-native-property-template-ids (4))")); assert!(payload.contains("2 3 (ebox-content 9 ebox-native-property-template-ids (4))"));
assert!(payload.contains("2 3 (display (space :width (7)))")); assert!(payload.contains("4 5 (display (space :width (7)))"));
assert!(payload.contains("(:prepared-p t")); assert!(payload.contains("(:prepared-p t"));
assert!(payload.contains("(9 content) ((5 . 6))")); assert!(payload.contains("(9 content) ((5 . 6))"));
assert!(payload.contains("data (9 (5 . 6))")); assert!(payload.contains("data (9 (5 . 6))"));

View File

@ -286,7 +286,7 @@
(let (cells) (let (cells)
(dotimes (column 4) (dotimes (column 4)
(push (push
(apply #'ebox-create (apply #'ebox-test-box
(append (append
(list :key (list 'cell row column) (list :key (list 'cell row column)
:content (format "row-%d cell-%d\ndetail-%d-%d" :content (format "row-%d cell-%d\ndetail-%d-%d"
@ -299,24 +299,23 @@
'(:id target)) '(:id target))
(when styled '(:class card)))) (when styled '(:class card))))
cells)) cells))
(push (apply #'ebox-row (nreverse cells)) rows))) (push (apply #'ebox-test-row (nreverse cells)) rows)))
(apply #'ebox-column (nreverse rows)))) (apply #'ebox-test-column (nreverse rows))))
(defun ebox-performance-evaluator--resize-fixture () (defun ebox-performance-evaluator--resize-fixture ()
"Return a viewport-dependent wrapped flex fixture." "Return a viewport-dependent wrapped flex fixture."
(let (items) (let (items)
(dotimes (index 12) (dotimes (index 12)
(push (push
(ebox-flex-item (ebox-test-box
(ebox-create
:key (list 'resize-item index) :key (list 'resize-item index)
:content (format "resize-%02d" index) :content (format "resize-%02d" index)
:width '(70) :width '(70)
:padding '(0 1) :padding '(0 1)
:color "#172554" :color "#172554"
:bgcolor "#DBEAFE")) :bgcolor "#DBEAFE")
items)) items))
(apply #'ebox-flex (apply #'ebox-test-flex
:key 'resize-root :key 'resize-root
:width '(viewport) :width '(viewport)
:flex-wrap 'wrap :flex-wrap 'wrap

View File

@ -28,7 +28,7 @@
(defun ebox-visual-check-layout () (defun ebox-visual-check-layout ()
"Return the fixed layout used by visual verification." "Return the fixed layout used by visual verification."
(let* ((title (ebox-create (let* ((title (ebox-test-box
:content "Ebox Visual Check" :content "Ebox Visual Check"
:box-sizing 'border-box :box-sizing 'border-box
:width 74 :width 74
@ -37,7 +37,7 @@
:border-color "#4A90D9" :border-color "#4A90D9"
:color "#EAF6FF" :color "#EAF6FF"
:bgcolor "#203040")) :bgcolor "#203040"))
(hidden (ebox-create (hidden (ebox-test-box
:content "Hidden: visible line\nHIDDEN-CLIPPED-LINE" :content "Hidden: visible line\nHIDDEN-CLIPPED-LINE"
:box-sizing 'border-box :box-sizing 'border-box
:width 24 :width 24
@ -48,7 +48,7 @@
:color "#F0FFF4" :color "#F0FFF4"
:bgcolor "#17351F" :bgcolor "#17351F"
:overflow 'hidden)) :overflow 'hidden))
(scroll (ebox-create (scroll (ebox-test-box
:content "Scroll: first\nSCROLL-SECOND\nSCROLL-THIRD" :content "Scroll: first\nSCROLL-SECOND\nSCROLL-THIRD"
:box-sizing 'border-box :box-sizing 'border-box
:width 24 :width 24
@ -59,7 +59,7 @@
:color "#FFF4E6" :color "#FFF4E6"
:bgcolor "#3A2410" :bgcolor "#3A2410"
:overflow 'scroll)) :overflow 'scroll))
(sized (ebox-create (sized (ebox-test-box
:content "border-box max-height\nline 2\nSIZED-CLIPPED" :content "border-box max-height\nline 2\nSIZED-CLIPPED"
:box-sizing 'border-box :box-sizing 'border-box
:width 26 :width 26
@ -70,7 +70,7 @@
:color "#F8EEFF" :color "#F8EEFF"
:bgcolor "#2F1B3A" :bgcolor "#2F1B3A"
:overflow 'hidden)) :overflow 'hidden))
(footer (ebox-create (footer (ebox-test-box
:content "Checks: text properties + display specs + GUI screenshot" :content "Checks: text properties + display specs + GUI screenshot"
:box-sizing 'border-box :box-sizing 'border-box
:width 74 :width 74
@ -79,7 +79,7 @@
:border-color "#7F8C8D" :border-color "#7F8C8D"
:color "#F8F9F9" :color "#F8F9F9"
:bgcolor "#2C3E50"))) :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) (defun ebox-visual-check--max-line-pixel-width (string)
"Return maximum pixel width among STRING lines." "Return maximum pixel width among STRING lines."

View File

@ -65,7 +65,7 @@
(ert-deftest ebox-child-range-render-signature-snapshots-item-identities () (ert-deftest ebox-child-range-render-signature-snapshots-item-identities ()
"A cached Range signature must not follow later runtime identity mutation." "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))))) (sequence (ebox-child-range--build (list (cons 'range (list item)))))
(published (published
(aref (ebox-child-range--segment-payload (aref (ebox-child-range--segment-payload
@ -270,17 +270,17 @@
(ert-deftest ebox-child-range-integration-is-transparent-and-indexed () (ert-deftest ebox-child-range-integration-is-transparent-and-indexed ()
"Compiled Range segments render as direct children and retain empty refs." "Compiled Range segments render as direct children and retain empty refs."
(let* ((static (ebox-create :key 'a :class "a" :content "A" :width '(40))) (let* ((static (ebox-test-box :key 'a :class "a" :content "A" :width '(40)))
(one (ebox-create :key 'b :class "b" :content "B" :width '(40))) (one (ebox-test-box :key 'b :class "b" :content "B" :width '(40)))
(two (ebox-create :key 'c :class "c" :content "C" :width '(40))) (two (ebox-test-box :key 'c :class "c" :content "C" :width '(40)))
(descriptor (ebox-child-range--descriptor-create 'items (list one two))) (descriptor (ebox-child-range--descriptor-create 'items (list one two)))
(empty (ebox-child-range--descriptor-create 'empty nil)) (empty (ebox-child-range--descriptor-create 'empty nil))
(raw-items (ebox-child-range--descriptor-items descriptor)) (raw-items (ebox-child-range--descriptor-items descriptor))
(ranged (ebox-column static empty descriptor)) (ranged (ebox-test-column static empty descriptor))
(direct (ebox-column (direct (ebox-test-column
(ebox-create :key 'a :class "a" :content "A" :width '(40)) (ebox-test-box :key 'a :class "a" :content "A" :width '(40))
(ebox-create :key 'b :class "b" :content "B" :width '(40)) (ebox-test-box :key 'b :class "b" :content "B" :width '(40))
(ebox-create :key 'c :class "c" :content "C" :width '(40)))) (ebox-test-box :key 'c :class "c" :content "C" :width '(40))))
(left (generate-new-buffer " *ebox-range-direct*")) (left (generate-new-buffer " *ebox-range-direct*"))
(right (generate-new-buffer " *ebox-range-compiled*"))) (right (generate-new-buffer " *ebox-range-compiled*")))
(unwind-protect (unwind-protect
@ -335,32 +335,33 @@
(ert-deftest ebox-child-range-integration-rejects-invalid-placement-atomically () (ert-deftest ebox-child-range-integration-rejects-invalid-placement-atomically ()
"Range refs, keys, nesting, roots, and scalar slots fail before publication." "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*"))) (let ((buffer (generate-new-buffer " *ebox-range-invalid*")))
(unwind-protect (unwind-protect
(progn (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))) (let ((before (with-current-buffer buffer (buffer-string)))
(state (ebox--buffer-render-state buffer))) (state (ebox--buffer-render-state buffer)))
(dolist (dolist
(root (root
(list (list
(ebox-child-range--descriptor-create (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 (list :ebox-type 'box
:ebox-content-node :ebox-content-node
(ebox-child-range--descriptor-create 'scalar nil)) (ebox-child-range--descriptor-create 'scalar nil))
(ebox-column (ebox-test-column
(ebox-child-range--descriptor-create
'outer
(list (ebox-child-range--descriptor-create 'nested nil))))
(ebox-column
(ebox-child-range--descriptor-create 'same nil) (ebox-child-range--descriptor-create 'same nil)
(ebox-column (ebox-test-column
(ebox-child-range--descriptor-create 'same nil))) (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"))))))
(should-error (ebox-commit buffer root)) (should-error (ebox-commit buffer root))
(should (eq (ebox--buffer-render-state buffer) state)) (should (eq (ebox--buffer-render-state buffer) state))
(should (equal (with-current-buffer buffer (buffer-string)) (should (equal (with-current-buffer buffer (buffer-string))
@ -383,24 +384,24 @@
"Ranges add no ECSS subject and preserve child/sibling cascade semantics." "Ranges add no ECSS subject and preserve child/sibling cascade semantics."
(let* ((ebox-style-stylesheet (ecss-stylesheet-create)) (let* ((ebox-style-stylesheet (ecss-stylesheet-create))
(direct-left (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))) :content "L" :width '(40)))
(direct-right (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))) :content "R" :width '(40)))
(range-left (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))) :content "L" :width '(40)))
(range-right (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))) :content "R" :width '(40)))
(left-range (left-range
(ebox-child-range--descriptor-create 'left-range (list range-left))) (ebox-child-range--descriptor-create 'left-range (list range-left)))
(right-range (right-range
(ebox-child-range--descriptor-create 'right-range (list range-right))) (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")) :class "parent"))
(ranged (plist-put (ebox-column left-range right-range) (ranged (plist-put (ebox-test-column left-range right-range)
:class "parent")) :class "parent"))
(direct-buffer (generate-new-buffer " *ebox-range-ecss-direct*")) (direct-buffer (generate-new-buffer " *ebox-range-ecss-direct*"))
(range-buffer (generate-new-buffer " *ebox-range-ecss-range*")) (range-buffer (generate-new-buffer " *ebox-range-ecss-range*"))
@ -450,13 +451,13 @@
(ert-deftest ebox-child-range-path-copy-replaces-compiled-material-child () (ert-deftest ebox-child-range-path-copy-replaces-compiled-material-child ()
"Tree path copying cannot ignore a child stored in a compiled sequence." "Tree path copying cannot ignore a child stored in a compiled sequence."
(let* ((source (let* ((source
(ebox-column (ebox-test-column
(ebox-create :key 'static :content "static") (ebox-test-box :key 'static :content "static")
(ebox-child-range--descriptor-create (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)) (compiled (ebox-tree-copy-node-structure source))
(old-child (cadr (ebox-tree-layout-children compiled))) (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)) (unchanged (ebox-tree-copy-with-direct-child-replacements compiled nil))
(copy (ebox-tree-copy-with-direct-child-replacements (copy (ebox-tree-copy-with-direct-child-replacements
compiled (list (cons old-child new-child))))) compiled (list (cons old-child new-child)))))
@ -467,20 +468,49 @@
'(static new))) '(static new)))
(should (equal (mapcar (lambda (node) (plist-get node :key)) (should (equal (mapcar (lambda (node) (plist-get node :key))
(ebox-tree-layout-children compiled)) (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 () (ert-deftest ebox-child-range-empty-sequence-is-authoritative ()
"An all-empty sequence cannot fall through to legacy alias children." "An all-empty sequence cannot fall through to legacy alias children."
(let* ((sequence (ebox-child-range--build (list (cons 'empty nil)))) (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 (node (list :ebox-type 'stack :ebox-child-sequence sequence
:top legacy :bottom legacy))) :top legacy :bottom legacy)))
(should-not (ebox-tree-layout-children node)))) (should-not (ebox-tree-layout-children node))))
(ert-deftest ebox-child-range-validator-covers-wrapper-and-scalar-slots () (ert-deftest ebox-child-range-validator-covers-wrapper-and-scalar-slots ()
"Flat Range compilation cannot hide wrappers or admit scalar descriptors." "Flat Range compilation cannot hide wrappers or admit scalar descriptors."
(let* ((wrapper (ebox-create :key 'wrapper :host-ref 'wrapper :content "W")) (let* ((wrapper (ebox-test-box :key 'wrapper :host-ref 'wrapper :content "W"))
(item (ebox-create :key 'item :host-ref 'item :content "I")) (item (ebox-test-box :key 'item :host-ref 'item :content "I"))
(descriptor (ebox-child-range--descriptor-create 'items (list item))) (descriptor (ebox-child-range--descriptor-create 'items (list item)))
(source (list :ebox-type 'flex :display '(block flex) (source (list :ebox-type 'flex :display '(block flex)
:box wrapper :children (list descriptor)))) :box wrapper :children (list descriptor))))
@ -498,23 +528,23 @@
(ebox-tree-validate-declarative-root (ebox-tree-validate-declarative-root
(list :ebox-type 'stack :display '(block column) (list :ebox-type 'stack :display '(block column)
:top (ebox-child-range--descriptor-create 'bad nil) :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 (should-error
(ebox-tree-validate-declarative-root (ebox-tree-validate-declarative-root
(list :ebox-type 'flex :display '(block flex) (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 :children
(list (ebox-child-range--descriptor-create (list (ebox-child-range--descriptor-create
'items 'items
(list (ebox-create :key 'item :host-ref 'same (list (ebox-test-box :key 'item :host-ref 'same
:content "I"))))))))) :content "I")))))))))
(ert-deftest ebox-child-range-public-candidate-splices-and-reports () (ert-deftest ebox-child-range-public-candidate-splices-and-reports ()
"Public child Ranges support empty and repeated last-wins candidate splices." "Public child Ranges support empty and repeated last-wins candidate splices."
(let* ((buffer (generate-new-buffer " *ebox-public-range*")) (let* ((buffer (generate-new-buffer " *ebox-public-range*"))
(source (source
(ebox-column (ebox-test-column
(ebox-create :key 'static :content "S") (ebox-test-box :key 'static :content "S")
(ebox-child-range 'items))) (ebox-child-range 'items)))
report) report)
(unwind-protect (unwind-protect
@ -522,10 +552,10 @@
(ebox-render-to-buffer buffer source) (ebox-render-to-buffer buffer source)
(dolist (items (dolist (items
(list (list
(list (ebox-create :key 'one :content "1")) (list (ebox-test-box :key 'one :content "1"))
(list (ebox-create :key 'two :content "2") (list (ebox-test-box :key 'two :content "2")
(ebox-create :key 'three :content "3")) (ebox-test-box :key 'three :content "3"))
(list (ebox-create :key 'final :content "F")) (list (ebox-test-box :key 'final :content "F"))
nil)) nil))
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref candidate 'items items) (ebox-candidate-replace-range-ref candidate 'items items)
@ -545,23 +575,23 @@
((rows (selected) ((rows (selected)
(cl-loop for id from 1 to 12 (cl-loop for id from 1 to 12
collect collect
(ebox-create (ebox-test-box
:key id :content (format "row-%02d" id) :width '(420) :key id :content (format "row-%02d" id) :width '(420)
:color (and (= id selected) "#2F6B43")))) :color (and (= id selected) "#2F6B43"))))
(root () (root ()
(ebox-create (ebox-test-box
:width '(900) :color "#172033" :wrap-mode nil :width '(900) :color "#172033" :wrap-mode nil
:ebox-content-node :ebox-content-node
(ebox-flex (ebox-test-flex
:width '(900) :width '(900)
(ebox-create (ebox-test-box
:key 'list :width 'stretch :min-width 0 :wrap-mode nil :key 'list :width 'stretch :min-width 0 :wrap-mode nil
:min-height 24 :padding '(2 2) :border "#687386" :min-height 24 :padding '(2 2) :border "#687386"
:flex-grow 2 :flex-shrink 1 :flex-basis '(600) :flex-grow 2 :flex-shrink 1 :flex-basis '(600)
:ebox-content-node :ebox-content-node
(ebox-column (ebox-test-column
(apply #'ebox-child-range 'items (rows 1)))) (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 :width 'stretch :min-width 0
:min-height 24 :padding '(2 2) :border "#687386" :min-height 24 :padding '(2 2) :border "#687386"
:flex-grow 1 :flex-shrink 1 :flex-basis '(300) :flex-grow 1 :flex-shrink 1 :flex-basis '(300)
@ -576,7 +606,7 @@
candidate 'items (rows selected)) candidate 'items (rows selected))
(ebox-candidate-replace-host-ref (ebox-candidate-replace-host-ref
candidate 'detail candidate 'detail
(ebox-create :key 'detail :host-ref 'detail (ebox-test-box :key 'detail :host-ref 'detail
:width 'stretch :min-width 0 :width 'stretch :min-width 0
:min-height 24 :padding '(2 2) :min-height 24 :padding '(2 2)
:border "#687386" :border "#687386"
@ -592,7 +622,8 @@
(should (memq (plist-get report :strategy) (should (memq (plist-get report :strategy)
'(span-patch owner-rerender '(span-patch owner-rerender
mixed-owner-reflow))) 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 :created-objects) 0))
(should (= (plist-get report :removed-objects) 0)))))))) (should (= (plist-get report :removed-objects) 0))))))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))) (when (buffer-live-p buffer) (kill-buffer buffer))))))
@ -603,7 +634,7 @@
(cl-labels (cl-labels
((rows (keys) ((rows (keys)
(mapcar (lambda (key) (mapcar (lambda (key)
(ebox-create :key key (ebox-test-box :key key
:content (format "row-%s" key))) :content (format "row-%s" key)))
keys)) keys))
(row-id (key) (row-id (key)
@ -624,7 +655,7 @@
(progn (progn
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-column (ebox-test-column
(apply #'ebox-child-range 'items (rows '(1 2 3 4))))) (apply #'ebox-child-range 'items (rows '(1 2 3 4)))))
(let ((initial-1 (row-id 1)) (let ((initial-1 (row-id 1))
(initial-4 (row-id 4))) (initial-4 (row-id 4)))
@ -643,24 +674,180 @@
(ert-deftest ebox-child-range-sole-child-keeps-public-layout-parent () (ert-deftest ebox-child-range-sole-child-keeps-public-layout-parent ()
"Keep row/column material parents for one empty or populated Range." "Keep row/column material parents for one empty or populated Range."
(dolist (constructor '(ebox-row ebox-column)) (dolist (case '((ebox-test-row row) (ebox-test-column column)))
(dolist (items (list nil (list (ebox-create :key 'one :content "1")))) (dolist (items (list nil (list (ebox-test-box :key 'one :content "1"))))
(let* ((range (apply #'ebox-child-range 'items items)) (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-not (ebox-child-range--descriptor-p parent))
(should (eq (plist-get parent :ebox-type) (should (ebox-box-node-p parent))
(if (eq constructor 'ebox-row) 'concat 'stack))) (should (eq (ebox-layout-config-kind (ebox-box-node-layout parent))
(should (equal (plist-get parent :children) (list range))) (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))))))) (should (stringp (ebox-render parent)))))))
(ert-deftest ebox-child-range-dsl-layout-child-keeps-props-wrapper () (ert-deftest ebox-child-range-typed-box-preserves-transparent-range ()
"Pass a Range through row/column DSL children while retaining properties." "Typed Box should retain Range identity outside its visual child model."
(dolist (tag '(row column)) (let* ((before (ebox-text-create :value "before" :key 'before))
(let* ((range (ebox-child-range 'items)) (first (ebox-text-create :value "A" :key 'first))
(node (ebox-build (list tag :color "red" range)))) (second (ebox-text-create :value "B" :key 'second))
(should (equal "red" (plist-get node :color))) (after (ebox-text-create :value "after" :key 'after))
(should (not (ebox-child-range--descriptor-p node))) (range (ebox-child-range 'items first second))
(should-error (ebox-build range))))) (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 () (ert-deftest ebox-child-range-empty-owner-falls-back-atomically ()
"Publish empty sole Range through full surface fallback and rollback safely." "Publish empty sole Range through full surface fallback and rollback safely."
@ -668,17 +855,17 @@
(unwind-protect (unwind-protect
(progn (progn
(ebox-render-to-buffer buffer (ebox-render-to-buffer buffer
(ebox-column (ebox-child-range 'items))) (ebox-test-column (ebox-child-range 'items)))
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref (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) (cl-letf (((symbol-function 'accept-change-group)
(lambda (&rest _) (error "injected empty accept")))) (lambda (&rest _) (error "injected empty accept"))))
(should-error (ebox-commit buffer candidate) :type 'error))) (should-error (ebox-commit buffer candidate) :type 'error)))
(should (equal "" (with-current-buffer buffer (buffer-string)))) (should (equal "" (with-current-buffer buffer (buffer-string))))
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref (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))) (let ((report (ebox-commit buffer candidate)))
(should (plist-get report :empty-range-owner-fallback)))) (should (plist-get report :empty-range-owner-fallback))))
(should (string-match-p "1" (with-current-buffer buffer (should (string-match-p "1" (with-current-buffer buffer
@ -694,20 +881,20 @@
(let* ((buffer (generate-new-buffer " *ebox-range-no-flatten*")) (let* ((buffer (generate-new-buffer " *ebox-range-no-flatten*"))
(static (static
(cl-loop for index below 100 (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)))) :content (number-to-string index))))
(root (apply #'ebox-column (root (apply #'ebox-test-column
(append static (append static
(list (ebox-child-range (list (ebox-child-range
'items 'items
(ebox-create :key 'old :content "old")))))) (ebox-test-box :key 'old :content "old"))))))
candidate logical trace deltas) candidate logical trace deltas)
(unwind-protect (unwind-protect
(progn (progn
(ebox-render-to-buffer buffer root) (ebox-render-to-buffer buffer root)
(setq candidate (ebox-candidate-begin buffer)) (setq candidate (ebox-candidate-begin buffer))
(ebox-candidate-replace-range-ref (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)) (setq trace (make-hash-table :test 'equal))
(cl-letf (((symbol-function 'ebox-child-range--flatten) (cl-letf (((symbol-function 'ebox-child-range--flatten)
(lambda (&rest _) (error "candidate flattened Range")))) (lambda (&rest _) (error "candidate flattened Range"))))
@ -730,15 +917,15 @@
(progn (progn
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-column (ebox-test-column
(ebox-child-range 'left (ebox-create :key 'move :content "L")) (ebox-child-range 'left (ebox-test-box :key 'move :content "L"))
(ebox-child-range 'right (ebox-create :key 'stay :content "R")))) (ebox-child-range 'right (ebox-test-box :key 'stay :content "R"))))
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref candidate 'left nil) (ebox-candidate-replace-range-ref candidate 'left nil)
(ebox-candidate-replace-range-ref (ebox-candidate-replace-range-ref
candidate 'right candidate 'right
(list (ebox-create :key 'move :content "M") (list (ebox-test-box :key 'move :content "M")
(ebox-create :key 'stay :content "R"))) (ebox-test-box :key 'stay :content "R")))
(ebox-commit buffer candidate)) (ebox-commit buffer candidate))
(should (string-match-p "M" (with-current-buffer buffer (should (string-match-p "M" (with-current-buffer buffer
(buffer-string))))) (buffer-string)))))
@ -749,13 +936,13 @@
(dolist (static-count '(10 100 500)) (dolist (static-count '(10 100 500))
(let* ((buffer (generate-new-buffer " *ebox-range-public-gate*")) (let* ((buffer (generate-new-buffer " *ebox-range-public-gate*"))
(root (root
(apply #'ebox-column (apply #'ebox-test-column
(append (append
(cl-loop for index below static-count (cl-loop for index below static-count
collect (ebox-create :key (list 'static index) collect (ebox-test-box :key (list 'static index)
:content "s")) :content "s"))
(list (ebox-child-range (list (ebox-child-range
'items (ebox-create :key 'old :content "o"))))))) 'items (ebox-test-box :key 'old :content "o")))))))
(unwind-protect (unwind-protect
(progn (progn
(ebox-render-to-buffer buffer root) (ebox-render-to-buffer buffer root)
@ -768,8 +955,8 @@
report) report)
(ebox-candidate-replace-range-ref (ebox-candidate-replace-range-ref
candidate 'items candidate 'items
(list (ebox-create :key 'new-a :content "a") (list (ebox-test-box :key 'new-a :content "a")
(ebox-create :key 'new-b :content "b"))) (ebox-test-box :key 'new-b :content "b")))
(cl-letf (((symbol-function 'ebox--runtime-index) (cl-letf (((symbol-function 'ebox--runtime-index)
(lambda (&rest _) (error "unexpected full index")))) (lambda (&rest _) (error "unexpected full index"))))
(setq report (ebox-commit buffer candidate))) (setq report (ebox-commit buffer candidate)))
@ -814,13 +1001,13 @@
(progn (progn
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(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-create :key 'b :content "b"))) (ebox-test-box :key 'b :content "b")))
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-host-ref (ebox-candidate-replace-host-ref
candidate 'a candidate 'a
(ebox-column (ebox-test-column
(ebox-create :key 'a :host-ref 'a :content "a") (ebox-test-box :key 'a :host-ref 'a :content "a")
(ebox-child-range 'introduced))) (ebox-child-range 'introduced)))
(should-error (should-error
(ebox-candidate-replace-range-ref candidate 'introduced nil)) (ebox-candidate-replace-range-ref candidate 'introduced nil))
@ -828,7 +1015,7 @@
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref (ebox-candidate-replace-range-ref
candidate 'introduced candidate 'introduced
(list (ebox-create :key 'later :content "later"))) (list (ebox-test-box :key 'later :content "later")))
(ebox-commit buffer candidate)) (ebox-commit buffer candidate))
(should (string-match-p "later" (with-current-buffer buffer (should (string-match-p "later" (with-current-buffer buffer
(buffer-string))))) (buffer-string)))))
@ -842,15 +1029,15 @@
(progn (progn
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-column (ebox-test-column
(plist-put (plist-put
(ebox-column (ebox-test-column
(ebox-create :key 'static :content "s") (ebox-test-box :key 'static :content "s")
(ebox-child-range (ebox-child-range
'items (ebox-create :key 'child :host-ref 'child 'items (ebox-test-box :key 'child :host-ref 'child
:content "old"))) :content "old")))
:host-ref 'parent) :host-ref 'parent)
(ebox-create :key 'peer :content "peer"))) (ebox-test-box :key 'peer :content "peer")))
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(dolist (operation (if (eq order 'host-first) (dolist (operation (if (eq order 'host-first)
'(host range) '(range host))) '(host range) '(range host)))
@ -858,11 +1045,11 @@
('host ('host
(ebox-candidate-replace-host-ref (ebox-candidate-replace-host-ref
candidate 'parent candidate 'parent
(ebox-create :key 'parent-new :content "HOST-WINS"))) (ebox-test-box :key 'parent-new :content "HOST-WINS")))
('range ('range
(ebox-candidate-replace-range-ref (ebox-candidate-replace-range-ref
candidate 'items candidate 'items
(list (ebox-create :key 'range-new (list (ebox-test-box :key 'range-new
:content "range-loses")))))) :content "range-loses"))))))
(ebox-commit buffer candidate)) (ebox-commit buffer candidate))
(should (string-match-p "HOST-WINS" (should (string-match-p "HOST-WINS"
@ -877,15 +1064,15 @@
(progn (progn
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-column (ebox-test-column
(ebox-create :key 'static :content "s") (ebox-test-box :key 'static :content "s")
(ebox-child-range (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) (dotimes (turn 2)
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-host-ref (ebox-candidate-replace-host-ref
candidate 'child candidate 'child
(ebox-create :key 'child :host-ref 'child (ebox-test-box :key 'child :host-ref 'child
:content (number-to-string (1+ turn)))) :content (number-to-string (1+ turn))))
(ebox-commit buffer candidate)) (ebox-commit buffer candidate))
(let* ((state (ebox--buffer-render-state buffer)) (let* ((state (ebox--buffer-render-state buffer))
@ -905,31 +1092,31 @@
(progn (progn
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-column (ebox-test-column
(plist-put (plist-put
(ebox-column (ebox-test-column
(ebox-create :key 'static :content "s") (ebox-test-box :key 'static :content "s")
(ebox-child-range (ebox-child-range
'items (ebox-create :key 'child :host-ref 'child 'items (ebox-test-box :key 'child :host-ref 'child
:content "old"))) :content "old")))
:host-ref 'parent) :host-ref 'parent)
(ebox-create :key 'peer :content "peer"))) (ebox-test-box :key 'peer :content "peer")))
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-host-ref (ebox-candidate-replace-host-ref
candidate 'parent candidate 'parent
(plist-put (plist-put
(ebox-column (ebox-test-column
(ebox-create :key 'static :content "s2") (ebox-test-box :key 'static :content "s2")
(ebox-child-range (ebox-child-range
'items (ebox-create :key 'child :host-ref 'child 'items (ebox-test-box :key 'child :host-ref 'child
:content "ancestor"))) :content "ancestor")))
:host-ref 'parent)) :host-ref 'parent))
(ebox-candidate-replace-range-ref (ebox-candidate-replace-range-ref
candidate 'items candidate 'items
(list (ebox-create :key 'range :content "RANGE-LOSES"))) (list (ebox-test-box :key 'range :content "RANGE-LOSES")))
(ebox-candidate-replace-host-ref (ebox-candidate-replace-host-ref
candidate 'child candidate 'child
(ebox-create :key 'child :host-ref 'child (ebox-test-box :key 'child :host-ref 'child
:content "DESCENDANT-WINS")) :content "DESCENDANT-WINS"))
(ebox-commit buffer candidate)) (ebox-commit buffer candidate))
(let ((text (with-current-buffer buffer (buffer-string)))) (let ((text (with-current-buffer buffer (buffer-string))))
@ -941,10 +1128,10 @@
"M=64 remains an affected-payload local delta under 500 static segments." "M=64 remains an affected-payload local delta under 500 static segments."
(let* ((buffer (generate-new-buffer " *ebox-range-large-payload*")) (let* ((buffer (generate-new-buffer " *ebox-range-large-payload*"))
(root (root
(apply #'ebox-column (apply #'ebox-test-column
(append (append
(cl-loop for index below 500 (cl-loop for index below 500
collect (ebox-create :key (list 'static index) collect (ebox-test-box :key (list 'static index)
:content "s")) :content "s"))
(list (ebox-child-range 'items))))) (list (ebox-child-range 'items)))))
report) report)
@ -955,7 +1142,7 @@
(ebox-candidate-replace-range-ref (ebox-candidate-replace-range-ref
candidate 'items candidate 'items
(cl-loop for index below 64 (cl-loop for index below 64
collect (ebox-create :key (list 'new index) collect (ebox-test-box :key (list 'new index)
:content "n"))) :content "n")))
(cl-letf (((symbol-function (cl-letf (((symbol-function
'ebox-incremental--candidate-full-index-preparation) 'ebox-incremental--candidate-full-index-preparation)
@ -977,10 +1164,10 @@
(progn (progn
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-column (ebox-test-column
(ebox-create :key 'static :content "s") (ebox-test-box :key 'static :content "s")
(ebox-child-range (ebox-child-range
'items (ebox-create :key 'child :host-ref 'child 'items (ebox-test-box :key 'child :host-ref 'child
:content "old")))) :content "old"))))
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(dolist (operation (if (eq order 'host-first) (dolist (operation (if (eq order 'host-first)
@ -988,11 +1175,11 @@
(if (eq operation 'host) (if (eq operation 'host)
(ebox-candidate-replace-host-ref (ebox-candidate-replace-host-ref
candidate 'child candidate 'child
(ebox-create :key 'child :host-ref 'child (ebox-test-box :key 'child :host-ref 'child
:content "HOST-LOSES")) :content "HOST-LOSES"))
(ebox-candidate-replace-range-ref (ebox-candidate-replace-range-ref
candidate 'items candidate 'items
(list (ebox-create :key 'range :content "RANGE-WINS"))))) (list (ebox-test-box :key 'range :content "RANGE-WINS")))))
(ebox-commit buffer candidate)) (ebox-commit buffer candidate))
(let ((text (with-current-buffer buffer (buffer-string)))) (let ((text (with-current-buffer buffer (buffer-string))))
(should (string-match-p "RANGE-WINS" text)) (should (string-match-p "RANGE-WINS" text))
@ -1006,29 +1193,29 @@
(cl-labels (cl-labels
((child (static-text range-ref range-key range-text) ((child (static-text range-ref range-key range-text)
(let ((node (let ((node
(ebox-column (ebox-test-column
(ebox-create :key 'child-static :content static-text) (ebox-test-box :key 'child-static :content static-text)
(if range-key (if range-key
(ebox-child-range (ebox-child-range
range-ref range-ref
(ebox-create :key range-key :content range-text)) (ebox-test-box :key range-key :content range-text))
(ebox-child-range range-ref))))) (ebox-child-range range-ref)))))
(plist-put node :key 'child) (plist-put node :key 'child)
(plist-put node :host-ref 'child) (plist-put node :host-ref 'child)
node)) node))
(ancestor (child-node peer-text) (ancestor (child-node peer-text)
(let ((node (let ((node
(ebox-column (ebox-test-column
child-node 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 :key 'ancestor)
(plist-put node :host-ref 'ancestor) (plist-put node :host-ref 'ancestor)
node))) node)))
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-column (ebox-test-column
(ancestor (child "s" 'old-ref nil nil) "p") (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))) (let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-host-ref (ebox-candidate-replace-host-ref
candidate 'ancestor candidate 'ancestor
@ -1054,20 +1241,20 @@
(progn (progn
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-column (ebox-test-column
(ebox-create :key 'host :host-ref 'host :content "old-host") (ebox-test-box :key 'host :host-ref 'host :content "old-host")
(ebox-child-range (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)) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface))
(revision (tp-surface-revision surface)) (revision (tp-surface-revision surface))
(candidate (ebox-candidate-begin buffer)) (candidate (ebox-candidate-begin buffer))
report) report)
(ebox-candidate-replace-host-ref (ebox-candidate-replace-host-ref
candidate 'host 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 (ebox-candidate-replace-range-ref
candidate 'items candidate 'items
(list (ebox-create :key 'new-range :content "new-range"))) (list (ebox-test-box :key 'new-range :content "new-range")))
(setq report (setq report
(ebox-commit buffer candidate (ebox-commit buffer candidate
(lambda (_report) (cl-incf callbacks)))) (lambda (_report) (cl-incf callbacks))))
@ -1088,10 +1275,10 @@
(progn (progn
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-column (ebox-test-column
(ebox-create :key 'static :content "s") (ebox-test-box :key 'static :content "s")
(ebox-child-range (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)) (let* ((old-state (ebox--buffer-render-state buffer))
(old-root (plist-get old-state :root-node)) (old-root (plist-get old-state :root-node))
(old-table (plist-get old-state :range-ref-table)) (old-table (plist-get old-state :range-ref-table))
@ -1100,7 +1287,7 @@
(candidate (ebox-candidate-begin buffer))) (candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref (ebox-candidate-replace-range-ref
candidate 'items 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) (cl-letf (((symbol-function 'accept-change-group)
(lambda (_group) (error "reject final accept")))) (lambda (_group) (error "reject final accept"))))
(should-error (should-error
@ -1119,7 +1306,7 @@
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref (ebox-candidate-replace-range-ref
candidate 'items candidate 'items
(list (ebox-create :key 'new :content "new"))) (list (ebox-test-box :key 'new :content "new")))
(ebox-commit buffer candidate))) (ebox-commit buffer candidate)))
(when (buffer-live-p buffer) (kill-buffer buffer))))) (when (buffer-live-p buffer) (kill-buffer buffer)))))
@ -1130,10 +1317,10 @@
(progn (progn
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-column (ebox-test-column
(ebox-create :key 'static :content "s") (ebox-test-box :key 'static :content "s")
(ebox-child-range (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)) (let* ((old-state (ebox--buffer-render-state buffer))
(old-root (plist-get old-state :root-node)) (old-root (plist-get old-state :root-node))
(old-range-table (plist-get old-state :range-ref-table)) (old-range-table (plist-get old-state :range-ref-table))
@ -1142,7 +1329,7 @@
trace) trace)
(ebox-candidate-replace-range-ref (ebox-candidate-replace-range-ref
candidate 'items candidate 'items
(list (ebox-create :key 'new :content "new"))) (list (ebox-test-box :key 'new :content "new")))
(should-error (should-error
(ebox-commit (ebox-commit
buffer candidate buffer candidate
@ -1157,7 +1344,7 @@
(let ((candidate (ebox-candidate-begin buffer))) (let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref (ebox-candidate-replace-range-ref
candidate 'items candidate 'items
(list (ebox-create :key 'new :content "new"))) (list (ebox-test-box :key 'new :content "new")))
(ebox-commit buffer candidate)) (ebox-commit buffer candidate))
(should (string-match-p "new" (with-current-buffer buffer (should (string-match-p "new" (with-current-buffer buffer
(buffer-string))))) (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-not (ebox-box-node-p node))
(should (eq (ebox-node-source-handle node) 'source-text)) (should (eq (ebox-node-source-handle node) 'source-text))
(should (string= (ebox-text-node-value node) "Hello")) (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 () (ert-deftest ebox-canonical-text-constructor-rejects-non-text-shapes ()
"A canonical TextNode should have exactly one string payload." "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" :content "B") :type 'error)
(should-error (ebox-text-create :value "A" :children nil) :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-text-create :value "A" :color "red") :type 'error)
(should-error (ebox-build '(text :wrap-mode none "A")) :type 'error)
(should-error (should-error
(ebox-text-create :value "A" :value "B") (ebox-text-create :value "A" :value "B")
:type 'error) :type 'error)
@ -164,6 +166,89 @@
(ebox-text-create :value "A" :source-handle 'a :source-handle 'b) (ebox-text-create :value "A" :source-handle 'a :source-handle 'b)
:type 'error)) :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 () (ert-deftest ebox-canonical-normal-box-renders-inline-runs-and-blocks ()
"Normal should retain typed children and honor inline/block boundaries." "Normal should retain typed children and honor inline/block boundaries."
(let* ((text (ebox-text-create :value "A" :source-handle 'source-text)) (let* ((text (ebox-text-create :value "A" :source-handle 'source-text))
@ -442,29 +527,6 @@
:column-gap) :column-gap)
8))))) 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 () (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." "A GridConfig should select the Box algorithm without a Grid runtime node."
(let* ((layout (ebox-grid-layout-create)) (let* ((layout (ebox-grid-layout-create))
@ -573,39 +635,6 @@
(ebox-style-add-rule ".detached-flex-item" (list :grid-row 1)) (ebox-style-add-rule ".detached-flex-item" (list :grid-row 1))
(let ((ebox--render-root-parent-kind 'flex)) (let ((ebox--render-root-parent-kind 'flex))
(should-error (ebox-render detached) :type 'error))) (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. ;; Internal layout and parent participation are orthogonal facts.
(should (should
(stringp (stringp
@ -632,30 +661,6 @@
(ebox-box-create :layout cross-kind-props :children nil) (ebox-box-create :layout cross-kind-props :children nil)
:type 'error))) :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 () (ert-deftest ebox-grid-layout-create-normalizes-the-complete-config-schema ()
"The typed Grid constructor should emit one canonical closed property set." "The typed Grid constructor should emit one canonical closed property set."
(let* ((layout (let* ((layout
@ -837,454 +842,126 @@
(ebox-box-create :layout normal :outer nil :children nil) (ebox-box-create :layout normal :outer nil :children nil)
:type 'error))) :type 'error)))
(ert-deftest ebox-build-normalizes-text-author-forms () (ert-deftest ebox-build-normalizes-the-complete-author-grammar ()
"The Ebox DSL should normalize explicit and bare Text forms." "Every public author form should lower to one canonical Text/Box model."
(let ((text (ebox-build '(text "A"))) (dolist (case
(bare (ebox-build "B"))) '(("plain" text nil)
(should (ebox-text-node-p text)) ((text "styled") text nil)
(should (ebox-text-node-p bare)) ((box) box normal)
(should (string= (ebox-dsl-test--plain text) "A")) ((row "A" "B") box row)
(should (string= (ebox-dsl-test--plain bare) "B")))) ((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 () (ert-deftest ebox-build-projects-source-style-without-polluting-canonical-geometry ()
"The Ebox Text form should reject empty, multiple, and nested payloads." "Source metadata and author paint should use their orthogonal projections."
(should-error (ebox-build '(text)) :type 'error) (let* ((form
(should-error (ebox-build '(text "A" "B")) :type 'error) '(box :key root :id "root" :class "card"
(should-error (ebox-build '(text (text "A"))) :type 'error) :background-color "red" :width '(80)
(should-error (ebox-build '(text :color "red" "A")) :type 'error) (text :color "blue" "A")))
(should-error (ebox-build '(text :wrap-mode garbage "A")) :type 'error)) (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 () (ert-deftest ebox-build-validates-form-owned-config-and-direct-participation ()
"Extracted DSL compiler should preserve existing ebox-build syntax." "Layout config belongs to its form; item roles belong to direct child Boxes."
(let ((node (ebox-build (let* ((flex
'(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-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 (ebox-build
'(column :padding 1 :bgcolor "linen" '(flex :width '(300) :gap '(1 (5))
(box :content "This child text should wrap again when the preview becomes narrow." (box :flex 1 "A")
:wrap-mode word))))) "B")))
buffer) (flex-child (car (ebox-box-node-children flex)))
(unwind-protect (grid
(progn (ebox-build
(setq buffer '(grid :grid-template-columns '((20) (20))
(let ((ebox-viewport-width 96)) (box :grid-column '(1 :span 2) "Header"))))
(ebox-render-to-buffer (grid-child (car (ebox-box-node-children grid))))
(generate-new-buffer-name " *ebox-dsl-test*") (should (= (plist-get flex-child :flex-grow) 1))
node))) (should (equal (plist-get grid-child :grid-column) '(1 :span 2)))
(let ((wide-text (with-current-buffer buffer (should (string-match-p "A.*B"
(buffer-substring-no-properties (substring-no-properties (ebox-render flex))))
(point-min) (point-max)))) (should (string-match-p "Header"
(wide-height (with-current-buffer buffer (substring-no-properties (ebox-render grid)))))
(count-lines (point-min) (point-max))))) (dolist (form
(ebox-rerender-buffer-with-context buffer 36) '((box :flex-direction row)
(let ((narrow-text (with-current-buffer buffer (row :gap 1)
(buffer-substring-no-properties (column :grid-template-columns ((10)))
(point-min) (point-max)))) (flex :grid-template-columns ((10)))
(narrow-height (with-current-buffer buffer (grid :flex-direction row)))
(count-lines (point-min) (point-max))))) (should-error (ebox-build form) :type 'error))
(should-not (string= wide-text narrow-text)) (should-error
(should (> narrow-height wide-height))))) (ebox-render (ebox-build '(box :flex-grow 1 "wrong parent")))
(when (and buffer (buffer-live-p buffer)) :type 'error))
(kill-buffer buffer)))))
(ert-deftest ebox-build-item-is-flex-participation-sugar () (ert-deftest ebox-build-rejects-removed-and-private-author-syntax ()
"DSL item should attach flex participation to its child semantics." "The author boundary should have one grammar and no runtime passthrough."
(let* ((node (ebox-build (dolist (form
'(flex :width (300) '((ebox "A")
(item :flex 1 (box :content "A")) (spacer :width (20))
(box :content "B" :flex 1)))) (item (box "A"))
(rendered (let ((ebox-viewport-width 300)) (grid-item (box "A"))
(ebox-render node)))) (box :content "A")
(should (string-match-p "A" (substring-no-properties rendered))) (box :layout flex "A")
(should (string-match-p "B" (substring-no-properties rendered))))) (box :display (block flex) "A")
(box :ebox-type box "A")
(ert-deftest ebox-build-keeps-pre-render-region-ids-available () (box :ebox-content-node nil)
"The DSL should not break the pre-render region-id workflow." (text)
(let* ((node (ebox-build (text "A" "B")
'(row (text (box "A"))
(box :content "One" :width (80)) (text :width (10) "A")
(box :content "Two" :width (80))))) (row :item-gap -1)
(ids (ebox-region-ids node))) (unknown "A")))
(should (= (length ids) 2)) (should-error (ebox-build form) :type 'error))
(should (cl-every #'identity ids)))) (should-error
(ebox-build (ebox-test-box :content "raw runtime node"))
(ert-deftest ebox-build-wrapper-region-ids-include-child-layout () :type 'error))
"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))
(provide 'ebox-dsl-tests) (provide 'ebox-dsl-tests)
;;; ebox-dsl-tests.el ends here ;;; 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." "Grid should place unpositioned children in row-major order."
(let ((plain (let ((plain
(ebox-grid-test--plain (ebox-grid-test--plain
(ebox-grid (ebox-test-grid
:grid-template-columns '((20) (20)) :grid-template-columns '((20) (20))
:grid-template-rows '(1 1) :grid-template-rows '(1 1)
:gap '(0 (1)) :gap '(0 (1))
(ebox-create :content "A") (ebox-test-box :content "A")
(ebox-create :content "B") (ebox-test-box :content "B")
(ebox-create :content "C") (ebox-test-box :content "C")
(ebox-create :content "D"))))) (ebox-test-box :content "D")))))
(should (string-match-p "A.*B" plain)) (should (string-match-p "A.*B" plain))
(should (string-match-p "C.*D" plain)))) (should (string-match-p "C.*D" plain))))
(ert-deftest ebox-grid-supports-fractional-columns () (ert-deftest ebox-grid-supports-fractional-columns ()
"Fractional tracks should fill a definite grid width." "Fractional tracks should fill a definite grid width."
(let* ((node (ebox-grid (let* ((node (ebox-test-grid
:width '(120) :width '(120)
:grid-template-columns '(1fr 2fr) :grid-template-columns '(1fr 2fr)
(ebox-create :content "A") (ebox-test-box :content "A")
(ebox-create :content "B"))) (ebox-test-box :content "B")))
(lines (ebox-string-lines (ebox-render node)))) (lines (ebox-string-lines (ebox-render node))))
(should (= (length lines) 1)) (should (= (length lines) 1))
(should (= (ebox--string-pixel-width (car lines)) 120)))) (should (= (ebox--string-pixel-width (car lines)) 120))))
(ert-deftest ebox-grid-repeated-render-reuses-construction-time-config () (ert-deftest ebox-grid-repeated-render-reuses-construction-time-config ()
"Grid render and resize paths should not reparse canonical track 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) :grid-template-columns '(1fr 2fr)
(ebox-create :content "A") (ebox-test-box :content "A")
(ebox-create :content "B"))) (ebox-test-box :content "B")))
(calls 0) (calls 0)
(original (symbol-function 'ebox-grid--normalize-config-props))) (original (symbol-function 'ebox-grid--normalize-config-props)))
(cl-letf (((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 () (ert-deftest ebox-grid-minmax-fractional-columns-fill-and-shrink ()
"Zero-minimum fractional maxima should remain responsive to width." "Zero-minimum fractional maxima should remain responsive to width."
(dolist (width '(120 60)) (dolist (width '(120 60))
(let* ((node (ebox-grid (let* ((node (ebox-test-grid
:width (list width) :width (list width)
:grid-template-columns :grid-template-columns
'((minmax (0) (fr 1)) (minmax (0) (fr 2))) '((minmax (0) (fr 1)) (minmax (0) (fr 2)))
(ebox-create :content "A" :width 'stretch) (ebox-test-box :content "A" :width 'stretch)
(ebox-create :content "B" :width 'stretch))) (ebox-test-box :content "B" :width 'stretch)))
(lines (ebox-string-lines (ebox-render node)))) (lines (ebox-string-lines (ebox-render node))))
(should (= (ebox--string-max-pixel-width (should (= (ebox--string-max-pixel-width
(car lines)) width))))) (car lines)) width)))))
(ert-deftest ebox-grid-stretch-children-fill-fractional-tracks () (ert-deftest ebox-grid-stretch-children-fill-fractional-tracks ()
"Stretch-width children must reflow to their assigned 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) (calls 0)
(entry-left (list :node left :column 1 :column-span 1)) (entry-left (list :node left :column 1 :column-span 1))
(rendered (make-hash-table :test #'eq))) (rendered (make-hash-table :test #'eq)))
@ -85,36 +85,36 @@
(ert-deftest ebox-grid-constrains-auto-width-children-to-track-size () (ert-deftest ebox-grid-constrains-auto-width-children-to-track-size ()
"Auto-width grid children should render within their assigned track." "Auto-width grid children should render within their assigned track."
(let* ((ebox-viewport-width 120) (let* ((ebox-viewport-width 120)
(node (ebox-grid (node (ebox-test-grid
:width '(120) :width '(120)
:grid-template-columns '((50) (50)) :grid-template-columns '((50) (50))
:gap '(0 (10)) :gap '(0 (10))
:border "#334155" :border "#334155"
(ebox-create :content "A\nA-long" :padding '(0 (4))) (ebox-test-box :content "A\nA-long" :padding '(0 (4)))
(ebox-create :content "B\nB-long" :padding '(0 (4))))) (ebox-test-box :content "B\nB-long" :padding '(0 (4)))))
(lines (ebox-string-lines (ebox-render node))) (lines (ebox-string-lines (ebox-render node)))
(widths (mapcar #'ebox--string-pixel-width lines))) (widths (mapcar #'ebox--string-pixel-width lines)))
(should (equal widths (make-list (length widths) 120))))) (should (equal widths (make-list (length widths) 120)))))
(ert-deftest ebox-grid-composite-auto-row-respects-track-width () (ert-deftest ebox-grid-composite-auto-row-respects-track-width ()
"A composite auto-width row must not duplicate its Grid track viewport." "A composite auto-width row must not duplicate its Grid track viewport."
(let* ((node (ebox-grid (let* ((node (ebox-test-grid
:width '(120) :width '(120)
:grid-template-columns '((120)) :grid-template-columns '((120))
(ebox-row (ebox-test-row
(ebox-create :content "Left" :border t) (ebox-test-box :content "Left" :border t)
(ebox-create :content "Right" :border t)))) (ebox-test-box :content "Right" :border t))))
(widths (mapcar #'ebox--string-pixel-width (widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines (ebox-render node))))) (ebox-string-lines (ebox-render node)))))
(should (equal widths '(120))))) (should (equal widths '(120)))))
(ert-deftest ebox-grid-sizes-auto-row-after-assigned-width-wrap () (ert-deftest ebox-grid-sizes-auto-row-after-assigned-width-wrap ()
"Auto rows should use the height of width-constrained child content." "Auto rows should use the height of width-constrained child content."
(let* ((node (ebox-grid (let* ((node (ebox-test-grid
:width '(40) :width '(40)
:grid-template-columns '((38)) :grid-template-columns '((38))
:border "#334155" :border "#334155"
(ebox-create (ebox-test-box
:content "WIDE CONTENT WIDE CONTENT WIDE CONTENT WIDE CONTENT" :content "WIDE CONTENT WIDE CONTENT WIDE CONTENT WIDE CONTENT"
:wrap-mode 'word))) :wrap-mode 'word)))
(lines (ebox-string-lines (ebox-render node))) (lines (ebox-string-lines (ebox-render node)))
@ -122,35 +122,57 @@
(should (= (length lines) 2)) (should (= (length lines) 2))
(should (equal widths (make-list (length widths) 40))))) (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 () (ert-deftest ebox-grid-aligns-every-line-before-border-render ()
"Grid wrapper borders should stay on one right edge across child lines." "Grid wrapper borders should stay on one right edge across child lines."
(let* ((node (ebox-grid (let* ((node (ebox-test-grid
:width '(120) :width '(120)
:grid-template-columns '((30) auto 1fr) :grid-template-columns '((30) auto 1fr)
:grid-template-rows '(2) :grid-template-rows '(2)
:gap '(1 (4)) :gap '(1 (4))
:justify-content 'space-between :justify-content 'space-between
:border "#334155" :border "#334155"
(ebox-create :content "FIXED\n30 PX") (ebox-test-box :content "FIXED\n30 PX")
(ebox-create :content "AUTO\nINTRINSIC") (ebox-test-box :content "AUTO\nINTRINSIC")
(ebox-create :content "1FR\nREMAINDER"))) (ebox-test-box :content "1FR\nREMAINDER")))
(lines (ebox-string-lines (ebox-render node))) (lines (ebox-string-lines (ebox-render node)))
(widths (mapcar #'ebox--string-pixel-width lines))) (widths (mapcar #'ebox--string-pixel-width lines)))
(should (equal widths (make-list (length widths) 120))))) (should (equal widths (make-list (length widths) 120)))))
(ert-deftest ebox-grid-keeps-padded-items-inside-column-width () (ert-deftest ebox-grid-keeps-padded-items-inside-column-width ()
"Stack padding should use the widest Grid line instead of its first line." "Stack padding should use the widest Grid line instead of its first line."
(let* ((header (ebox-create :content "Header" :width '(720))) (let* ((header (ebox-test-box :content "Header" :width '(720)))
(grid (ebox-grid (grid (ebox-test-grid
:width '(718) :width '(718)
:grid-template-columns '((220) 1fr) :grid-template-columns '((220) 1fr)
:gap '(1 (12)) :gap '(1 (12))
:border "#C97252" :border "#C97252"
(ebox-create :content "Fixed" :width '(196) (ebox-test-box :content "Fixed" :width '(196)
:padding '(1 (12))) :padding '(1 (12)))
(ebox-create :content "Fractional" :width '(460) (ebox-test-box :content "Fractional" :width '(460)
:padding '(1 (12))))) :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))) (widths (mapcar #'ebox--string-pixel-width lines)))
(should (= (apply #'max widths) 720)))) (should (= (apply #'max widths) 720))))
@ -158,23 +180,23 @@
"Explicit placement should support a cell spanning two columns." "Explicit placement should support a cell spanning two columns."
(let ((plain (let ((plain
(ebox-grid-test--plain (ebox-grid-test--plain
(ebox-grid (ebox-test-grid
:grid-template-columns '((20) (20)) :grid-template-columns '((20) (20))
:grid-template-rows '(1 1) :grid-template-rows '(1 1)
(ebox-create :content "Header" :grid-column '(1 :span 2)) (ebox-test-box :content "Header" :grid-column '(1 :span 2))
(ebox-create :content "Left" :grid-row 2 :grid-column 1) (ebox-test-box :content "Left" :grid-row 2 :grid-column 1)
(ebox-create :content "Right" :grid-row 2 :grid-column 2))))) (ebox-test-box :content "Right" :grid-row 2 :grid-column 2)))))
(should (string-match-p "Header" plain)) (should (string-match-p "Header" plain))
(should (string-match-p "Left.*Right" plain)))) (should (string-match-p "Left.*Right" plain))))
(ert-deftest ebox-grid-uses-implicit-track-templates () (ert-deftest ebox-grid-uses-implicit-track-templates ()
"Implicit columns should use `:grid-auto-columns' when they grow." "Implicit columns should use `:grid-auto-columns' when they grow."
(let* ((node (ebox-grid (let* ((node (ebox-test-grid
:grid-template-columns '((20)) :grid-template-columns '((20))
:grid-auto-columns '((30)) :grid-auto-columns '((30))
:grid-auto-flow 'column :grid-auto-flow 'column
(ebox-create :content "A") (ebox-test-box :content "A")
(ebox-create :content "B"))) (ebox-test-box :content "B")))
(line (car (ebox-string-lines (ebox-render node))))) (line (car (ebox-string-lines (ebox-render node)))))
(should (= (ebox--string-pixel-width line) 50)))) (should (= (ebox--string-pixel-width line) 50))))
@ -182,25 +204,25 @@
"Implicit rows should retain the declared template column count." "Implicit rows should retain the declared template column count."
(let ((plain (let ((plain
(ebox-grid-test--plain (ebox-grid-test--plain
(ebox-grid (ebox-test-grid
:grid-template-columns '((20) (20) (20)) :grid-template-columns '((20) (20) (20))
(ebox-create :content "A") (ebox-test-box :content "A")
(ebox-create :content "B") (ebox-test-box :content "B")
(ebox-create :content "C") (ebox-test-box :content "C")
(ebox-create :content "D") (ebox-test-box :content "D")
(ebox-create :content "E") (ebox-test-box :content "E")
(ebox-create :content "F"))))) (ebox-test-box :content "F")))))
(dolist (letter '("A" "B" "C" "D" "E" "F")) (dolist (letter '("A" "B" "C" "D" "E" "F"))
(should (string-match-p letter plain))))) (should (string-match-p letter plain)))))
(ert-deftest ebox-grid-supports-repeat-minmax-and-content-alignment () (ert-deftest ebox-grid-supports-repeat-minmax-and-content-alignment ()
"Grid should normalize repeat/minmax tracks and align fixed tracks." "Grid should normalize repeat/minmax tracks and align fixed tracks."
(let* ((node (ebox-grid (let* ((node (ebox-test-grid
:width '(100) :width '(100)
:justify-content 'center :justify-content 'center
:grid-template-columns '((minmax (20) (30)) (repeat 1 (20))) :grid-template-columns '((minmax (20) (30)) (repeat 1 (20)))
(ebox-create :content "A") (ebox-test-box :content "A")
(ebox-create :content "B"))) (ebox-test-box :content "B")))
(line (car (ebox-string-lines (ebox-render node))))) (line (car (ebox-string-lines (ebox-render node)))))
(should (= (ebox--string-pixel-width line) 100)) (should (= (ebox--string-pixel-width line) 100))
(should (= (length (seq-filter (lambda (char) (= char ?\s)) line)) (should (= (length (seq-filter (lambda (char) (= char ?\s)) line))
@ -210,15 +232,15 @@
"Invalid flow and non-positive spans should be rejected." "Invalid flow and non-positive spans should be rejected."
(should-error (should-error
(ebox-render (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 (should-error
(ebox-render (ebox-render
(ebox-grid (ebox-test-grid
(ebox-create :content "A" :grid-column-span 0)))) (ebox-test-box :content "A" :grid-column-span 0))))
(should-error (should-error
(ebox-render (ebox-render
(ebox-grid (ebox-test-grid
(ebox-create :content "A" :grid-column 0))))) (ebox-test-box :content "A" :grid-column 0)))))
(ert-deftest ebox-grid-rejects-invalid-ecss-placement-before-layout () (ert-deftest ebox-grid-rejects-invalid-ecss-placement-before-layout ()
"ECSS rules should reject invalid Grid placement before layout runs." "ECSS rules should reject invalid Grid placement before layout runs."
@ -233,10 +255,10 @@
(should (ebox-style-add-rule ".valid" (list :grid-column placement)))))) (should (ebox-style-add-rule ".valid" (list :grid-column placement))))))
(ert-deftest ebox-grid-region-update-reflows-grid-child () (ert-deftest ebox-grid-region-update-reflows-grid-child ()
"Grid children should retain region updates through buffer rendering." "Replacing one typed Grid child should reflow and preserve its sibling."
(let* ((left (ebox-create :content "A" :id "left" :width 20)) (let* ((left (ebox-test-box :content "A" :host-ref 'left :width 20))
(right (ebox-create :content "B" :id "right" :width 20)) (right (ebox-test-box :content "B" :id "right" :width 20))
(node (ebox-grid :grid-template-columns '((20) (20)) left right)) (node (ebox-test-grid :grid-template-columns '((20) (20)) left right))
(buffer (ebox-render-to-buffer (buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-grid-update*") node))) (generate-new-buffer-name " *ebox-grid-update*") node)))
(unwind-protect (unwind-protect
@ -244,8 +266,11 @@
(should (with-current-buffer buffer (should (with-current-buffer buffer
(and (string-match-p "A" (buffer-string)) (and (string-match-p "A" (buffer-string))
(string-match-p "B" (buffer-string))))) (string-match-p "B" (buffer-string)))))
(ebox-region-update (ebox-region-resolve buffer "left") (let ((candidate (ebox-candidate-begin buffer)))
:content "Updated") (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)))) (let ((updated (with-current-buffer buffer (buffer-string))))
(should (string-match-p "Updated" updated)) (should (string-match-p "Updated" updated))
(should (string-match-p "B" updated)))) (should (string-match-p "B" updated))))
@ -257,9 +282,10 @@
(let ((node (let ((node
(ebox-build (ebox-build
'(grid :grid-template-columns ((20) (20)) '(grid :grid-template-columns ((20) (20))
(box :content "A") (box "A")
(box :content "B"))))) (box "B")))))
(should (eq (plist-get node :ebox-type) 'grid)) (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 "A" (ebox-grid-test--plain node)))
(should (string-match-p "B" (ebox-grid-test--plain node))))) (should (string-match-p "B" (ebox-grid-test--plain node)))))
@ -268,9 +294,10 @@
(let ((node (let ((node
(ebox-build (ebox-build
'(grid :grid-template-columns ((20) (20)) '(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 (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))))) '(1 :span 2)))))
(provide 'ebox-grid-tests) (provide 'ebox-grid-tests)

View File

@ -162,15 +162,6 @@
("ebox-call-with-render-burst" . "defun") ("ebox-call-with-render-burst" . "defun")
("ebox-render" . "defun") ("ebox-render" . "defun")
("ebox-region-ids" . "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-grid-layout-create" . "defun")
("ebox-normal-layout-create" . "defun") ("ebox-normal-layout-create" . "defun")
("ebox-row-layout-create" . "defun") ("ebox-row-layout-create" . "defun")
@ -179,7 +170,6 @@
("ebox-text-create" . "defun") ("ebox-text-create" . "defun")
("ebox-box-create" . "defun") ("ebox-box-create" . "defun")
("ebox-build" . "defun") ("ebox-build" . "defun")
("ebox-create" . "defun")
("ebox-scroll-down" . "defun") ("ebox-scroll-down" . "defun")
("ebox-scroll-up" . "defun") ("ebox-scroll-up" . "defun")
("ebox-region-update" . "defun") ("ebox-region-update" . "defun")
@ -212,22 +202,15 @@
ebox-candidate-replace-root ebox-candidate-replace-root
ebox-candidate-replace-host-ref ebox-candidate-replace-host-ref
ebox-clear-cache ebox-clear-cache
ebox-column
ebox-normal-layout-create ebox-normal-layout-create
ebox-row-layout-create ebox-row-layout-create
ebox-column-layout-create ebox-column-layout-create
ebox-text-create ebox-text-create
ebox-box-create ebox-box-create
ebox-commit ebox-commit
ebox-concat
ebox-create
ebox-display-buffer ebox-display-buffer
ebox-display-signature ebox-display-signature
ebox-flex
ebox-flex-layout-create ebox-flex-layout-create
ebox-flex-item
ebox-grid
ebox-grid-fr
ebox-grid-layout-create ebox-grid-layout-create
ebox-host-ref-bounds ebox-host-ref-bounds
ebox-host-ref-position ebox-host-ref-position
@ -241,7 +224,6 @@
ebox-render-burst-end ebox-render-burst-end
ebox-render-to-buffer ebox-render-to-buffer
ebox-rerender-buffer-with-context ebox-rerender-buffer-with-context
ebox-row
ebox-scroll-down ebox-scroll-down
ebox-scroll-page-down ebox-scroll-page-down
ebox-scroll-page-up ebox-scroll-page-up
@ -252,11 +234,17 @@
ebox-selector-query-all ebox-selector-query-all
ebox-selector-query-buffer ebox-selector-query-buffer
ebox-selector-update-buffer ebox-selector-update-buffer
ebox-spacer
ebox-stack
ebox-string-pixel-width)) ebox-string-pixel-width))
(should (memq entry ebox-public-api)))) (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 () (ert-deftest ebox-live-buffer-publication-uses-only-tp-surfaces ()
"The facade should expose no raw live-buffer writer beside TP surfaces." "The facade should expose no raw live-buffer writer beside TP surfaces."
(require 'ebox) (require 'ebox)

View File

@ -21,22 +21,22 @@
(defun ebox-selector-test--width-unit-form (width) (defun ebox-selector-test--width-unit-form (width)
"Return a layout whose root WIDTH constrains nested auto layout." "Return a layout whose root WIDTH constrains nested auto layout."
`(column :id root :width ,width `(column :id root :width ,width
(box :content "Header text wraps when the root is narrow" (box :padding (0 (12)) :border "#111111"
:padding (0 (12)) :border "#111111") "Header text wraps when the root is narrow")
(flex :flex-flow (row wrap) :column-gap (12) (flex :flex-flow (row wrap) :column-gap (12)
:padding (0 (12)) :border "#D97757" :padding (0 (12)) :border "#D97757"
(column (column
(box :content "row" :width (220) :border "#D97757") (box :width (220) :border "#D97757" "row")
(flex :width (220) :height 4 :border "#D97757" (flex :width (220) :height 4 :border "#D97757"
(item (box :content "A" :width (42))) (box :width (42) "A")
(item (box :content "B" :width (42))) (box :width (42) "B")
(item (box :content "C" :width (42))))) (box :width (42) "C")))
(column (column
(box :content "row-reverse" :width (220) :border "#D97757") (box :width (220) :border "#D97757" "row-reverse")
(flex :width (220) :height 4 :border "#D97757" (flex :width (220) :height 4 :border "#D97757"
(item (box :content "A" :width (42))) (box :width (42) "A")
(item (box :content "B" :width (42))) (box :width (42) "B")
(item (box :content "C" :width (42)))))))) (box :width (42) "C"))))))
(defun ebox-selector-test--rendered-widths (rendered) (defun ebox-selector-test--rendered-widths (rendered)
"Return pixel widths for RENDERED lines." "Return pixel widths for RENDERED lines."
@ -69,8 +69,8 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build (let* ((node (ebox-build
'(column :id root :class page '(column :id root :class page
(box :id summary :class (card warning) :content "A") (box :id summary :class (card warning) "A")
(box :class card :content "B")))) (box :class card "B"))))
(matches (ebox-selector-query-all node ".warning"))) (matches (ebox-selector-query-all node ".warning")))
(should (= (length matches) 1)) (should (= (length matches) 1))
(should (equal (plist-get (car matches) :region-id) (should (equal (plist-get (car matches) :region-id)
@ -83,8 +83,8 @@
(let* ((node (ebox-build (let* ((node (ebox-build
'(column :class page '(column :class page
(row :class toolbar (row :class toolbar
(box :class action :content "Run")) (box :class action "Run"))
(box :class action :content "Loose"))))) (box :class action "Loose")))))
(should (= (length (ebox-selector-query-all node ".toolbar > box")) 1)) (should (= (length (ebox-selector-query-all node ".toolbar > box")) 1))
(should (= (length (ebox-selector-query-all node ".page .action")) 2)))) (should (= (length (ebox-selector-query-all node ".page .action")) 2))))
@ -93,9 +93,9 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let ((node (ebox-build (let ((node (ebox-build
'(column '(column
(box :id first :content "A") (box :id first "A")
(box :id second :class later :content "B") (box :id second :class later "B")
(box :id third :class later :content "C"))))) (box :id third :class later "C")))))
(should (equal (should (equal
(mapcar (lambda (match) (mapcar (lambda (match)
(ebox-tree-node-id (plist-get match :node))) (ebox-tree-node-id (plist-get match :node)))
@ -112,8 +112,8 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let ((node (ebox-build (let ((node (ebox-build
'(column '(column
(box :class card :content "A") (box :class card "A")
(box :content "B")))) (box "B"))))
(calls 0) (calls 0)
(matcher (symbol-function 'ecss-selector-match-p))) (matcher (symbol-function 'ecss-selector-match-p)))
(cl-letf (((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 () (ert-deftest ebox-selector-queries-explicit-attributes ()
"Attribute queries should read only explicit selector metadata." "Attribute queries should read only explicit selector metadata."
(let ((node (ebox-build (let ((node (ebox-test-box
'(box :content "Run" :content "Run"
:selector-attributes '((role . button)))))) :selector-attributes '((role . button)))))
(should (= (length (ebox-selector-query-all node "[role=button]")) 1)) (should (= (length (ebox-selector-query-all node "[role=button]")) 1))
(should-not (ebox-selector-query-all node "[content=Run]")))) (should-not (ebox-selector-query-all node "[content=Run]"))))
@ -155,8 +155,8 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build (let* ((node (ebox-build
'(column '(column
(box :id summary :class card :content "Summary") (box :id summary :class card "Summary")
(box :class card :content "Other")))) (box :class card "Other"))))
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn
@ -178,8 +178,8 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build (let* ((node (ebox-build
'(column '(column
(box :id summary :class card :content "Summary") (box :id summary :class card "Summary")
(box :class card :content "Other")))) (box :class card "Other"))))
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn
@ -200,8 +200,8 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build (let* ((node (ebox-build
'(column '(column
(box :id summary :class (card warning) :content "Summary") (box :id summary :class (card warning) "Summary")
(box :class card :content "Other")))) (box :class card "Other"))))
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn
@ -225,8 +225,8 @@
(let* ((node (ebox-build (let* ((node (ebox-build
'(column :class page '(column :class page
(row :class toolbar (row :class toolbar
(box :class action :content "Run")) (box :class action "Run"))
(box :class action :content "Loose")))) (box :class action "Loose"))))
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn
@ -249,8 +249,8 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build (let* ((node (ebox-build
'(column :id shell '(column :id shell
(box :class target :content "A") (box :class target "A")
(box :content "B")))) (box "B"))))
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn
@ -262,9 +262,13 @@
(should-not (should-not
(gethash 'column (plist-get state :selector-type-table))) (gethash 'column (plist-get state :selector-type-table)))
(should (= (gethash (should (= (gethash
'column 'box
(plist-get state :runtime-type-count-table)) (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 (should-not (ebox-selector-query-buffer
buffer "column .target"))) buffer "column .target")))
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))
@ -276,8 +280,8 @@
(let* ((node (ebox-build (let* ((node (ebox-build
'(column '(column
(box :id card (box :id card
:content "Text wraps when narrow" :wrap-mode word
:wrap-mode word)))) "Text wraps when narrow"))))
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn
@ -299,7 +303,7 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build (let* ((node (ebox-build
'(column '(column
(box :id card :content "Card" :border "#111111")))) (box :id card :border "#111111" "Card"))))
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn
@ -321,7 +325,7 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build (let* ((node (ebox-build
'(column '(column
(box :id card :content "Card")))) (box :id card "Card"))))
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn
@ -371,10 +375,10 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let* ((target-node (ebox-build (let* ((target-node (ebox-build
'(column '(column
(box :id target-card :content "Target")))) (box :id target-card "Target"))))
(other-node (ebox-build (other-node (ebox-build
'(column '(column
(box :id other-card :content "Other")))) (box :id other-card "Other"))))
target-buffer other-buffer) target-buffer other-buffer)
(unwind-protect (unwind-protect
(progn (progn
@ -416,7 +420,7 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build (let* ((node (ebox-build
'(column '(column
(box :id card :content "Card")))) (box :id card "Card"))))
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn
@ -449,8 +453,8 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build (let* ((node (ebox-build
'(column '(column
(box :class card :content "One") (box :class card "One")
(box :class card :content "Two")))) (box :class card "Two"))))
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn
@ -485,8 +489,8 @@
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build (let* ((node (ebox-build
'(column '(column
(box :class card :content "One") (box :class card "One")
(box :class card :content "Two")))) (box :class card "Two"))))
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn

File diff suppressed because it is too large Load Diff