feat: add typed Text and Normal Box property boundary

This commit is contained in:
Kinneyzhang 2026-08-26 18:41:59 +08:00
parent 47f9da2ef2
commit b530b0513c
11 changed files with 633 additions and 99 deletions

View File

@ -190,7 +190,7 @@ together.
| Content/geometry | `:content`, `:box-sizing`, `:width`, `:min-width`, `:max-width`, `:height`, `:min-height`, `:max-height` |
| Padding | `:padding`, `:padding-inline`, `:padding-block`; longhands `:padding-block-start`, `:padding-inline-end`, `:padding-block-end`, `:padding-inline-start`; aliases `:padding-top`, `:padding-right`, `:padding-bottom`, `:padding-left` and the pixel/height aliases `:padding-right-pixel`, `:padding-left-pixel`, `:padding-top-height`, `:padding-bottom-height` |
| Margin | `:margin`, `:margin-inline`, `:margin-block`; longhands `:margin-block-start`, `:margin-inline-end`, `:margin-block-end`, `:margin-inline-start`; aliases `:margin-top`, `:margin-right`, `:margin-bottom`, `:margin-left` and the pixel/height aliases `:margin-right-pixel`, `:margin-left-pixel`, `:margin-top-height`, `:margin-bottom-height` |
| Border | `:border`, `:border-top`, `:border-right`, `:border-bottom`, `:border-left`, `:border-width`, `:border-style`, `:border-color`; side longhands `:border-*-width`, `:border-*-style`, `:border-*-color`; compatibility aliases `: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 longhands `:border-*-width`, `:border-*-style`, `:border-*-color`; Ebox boolean shorthands `:border-top-p`, `:border-bottom-p`; compatibility aliases `:border-left-pixel`, `:border-right-pixel` |
| Paint | `:color`, `:background-color`, and alias `:bgcolor` |
| Typography | `:font`, `:font-family`, `:font-height` and alias `:font-size`, `:font-weight`, `:font-slant` |
| Text/layout | `:text-align` (`left`, `center`, `right`), `:vertical-align` (`top`, `center`/`middle`, `bottom`), `:overflow` (`scroll`, `hidden`, `visible`), `:wrap-mode` (`word`, `char`, `kp`, or nil), `:visibility` (`visible` or `hidden`) |
@ -587,7 +587,8 @@ style-rule functions immediately below are public module-level style APIs.
| Function | Use |
| --- | --- |
| `ebox-create`, `ebox-build` | Create a node or compile the `.ebox` list DSL. |
| `ebox-text-create`, `ebox-normal-layout-create`, `ebox-box-create` | Framework-integration port for typed TextNode, Normal LayoutConfig, and BoxNode values. |
| `ebox-create`, `ebox-build` | Create a legacy node or compile the `.ebox` list DSL during migration. |
| `ebox-concat`, `ebox-stack`, `ebox-row`, `ebox-column`, `ebox-spacer` | Compose simple horizontal/vertical layouts and blank boxes. |
| `ebox-flex`, `ebox-flex-item` | Build flex containers and item metadata. |
| `ebox-grid`, `ebox-grid-fr`, `ebox-grid-item` | Build Grid layouts, fractional tracks, and placement metadata. |

View File

@ -173,7 +173,7 @@ property`ebox-region-update` 接受其目标支持的可变 content/style/scr
| Content/geometry | `:content`、`:box-sizing`、`:width`、`:min-width`、`:max-width`、`:height`、`:min-height`、`:max-height` |
| Padding | `:padding`、`:padding-inline`、`:padding-block`longhand `:padding-block-start`、`:padding-inline-end`、`:padding-block-end`、`:padding-inline-start`;别名 `:padding-top`、`:padding-right`、`:padding-bottom`、`:padding-left`,以及 `:padding-right-pixel`、`:padding-left-pixel`、`:padding-top-height`、`:padding-bottom-height` |
| Margin | `:margin`、`:margin-inline`、`:margin-block`longhand `:margin-block-start`、`:margin-inline-end`、`:margin-block-end`、`:margin-inline-start`;别名 `:margin-top`、`:margin-right`、`:margin-bottom`、`:margin-left`,以及 `:margin-right-pixel`、`:margin-left-pixel`、`:margin-top-height`、`:margin-bottom-height` |
| Border | `:border`、`:border-top`、`:border-right`、`:border-bottom`、`:border-left`、`:border-width`、`:border-style`、`:border-color`side longhand `:border-*-width`、`:border-*-style`、`:border-*-color`兼容别名 `: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` |
| 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` |
@ -533,7 +533,8 @@ style API。
| 函数 | 用途 |
| --- | --- |
| `ebox-create`、`ebox-build` | 创建节点或编译 `.ebox` list DSL。 |
| `ebox-text-create`、`ebox-normal-layout-create`、`ebox-box-create` | 框架集成使用的 typed TextNode、Normal LayoutConfig 与 BoxNode 端口。 |
| `ebox-create`、`ebox-build` | 迁移期间创建旧节点或编译 `.ebox` list DSL。 |
| `ebox-concat`、`ebox-stack`、`ebox-row`、`ebox-column`、`ebox-spacer` | 组合简单横向/纵向布局与空白 box。 |
| `ebox-flex`、`ebox-flex-item` | 构建 Flex 容器与 item metadata。 |
| `ebox-grid`、`ebox-grid-fr`、`ebox-grid-item` | 构建 Grid、分数轨道和 placement metadata。 |

213
ebox-canonical.el Normal file
View File

@ -0,0 +1,213 @@
;;; ebox-canonical.el --- Typed canonical Ebox nodes -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns the typed programmatic boundary between normalized author input and
;; Ebox's current runtime representation. Canonical kind, layout, source, and
;; child facts remain explicit even when the runtime reuses an existing box
;; encoding for rendering. This module does not parse the `.ebox' DSL or
;; perform measurement, layout, paint, or buffer publication.
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(require 'ebox-style)
(require 'ebox-node-factory)
(cl-defstruct
(ebox-layout-config
(:constructor ebox-layout-config--create))
"Canonical typed layout selection for one BoxNode."
kind
props)
(defun ebox-canonical--validate-plist (plist context)
"Validate PLIST shape for error CONTEXT and return PLIST."
(unless (proper-list-p plist)
(error "%s properties must be a proper plist: %S" context plist))
(unless (zerop (% (length plist) 2))
(error "%s property is missing a value: %S" context (car (last 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-canonical--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-canonical--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-canonical--required-field (plist field context)
"Return exactly one required FIELD from PLIST for error CONTEXT."
(let ((values (ebox-canonical--field-values plist field)))
(unless (= (length values) 1)
(error "%s requires %S exactly once" context field))
(car values)))
(defun ebox-canonical--optional-field (plist field context)
"Return `(PRESENT . VALUE)' for optional FIELD in PLIST and CONTEXT."
(let ((values (ebox-canonical--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-canonical--compiled-property-definitions (plist context)
"Return canonical property definitions compiled from PLIST for CONTEXT."
(condition-case err
(cl-loop for (property _value)
on (ebox-style-compile-declarations plist t) by #'cddr
collect (or (ebox-style-property property)
(error "Missing canonical Ebox property: %S"
property)))
(error
(error "%s rejected properties: %s" context (error-message-string err)))))
(defun ebox-canonical--text-measurement-property-p (property)
"Return non-nil when PROPERTY belongs to canonical Text measurement."
(eq (plist-get property :group) 'typography))
(defun ebox-canonical--normal-box-property-p (property)
"Return non-nil when PROPERTY belongs to Normal Box geometry/participation."
(let ((contexts (plist-get property :contexts))
(group (plist-get property :group))
(name (plist-get property :name)))
(or (memq 'item contexts)
(and (memq 'box contexts)
(not (memq group '(paint typography)))
(not (eq name :wrap-mode))))))
(defun ebox-canonical--assert-property-role (plist predicate context)
"Require every compiled PLIST property to satisfy PREDICATE for CONTEXT."
(dolist (property
(ebox-canonical--compiled-property-definitions plist context))
(unless (funcall predicate property)
(error "%s does not accept %S"
context (plist-get property :name)))))
;;;###autoload
(defun ebox-normal-layout-create ()
"Return the canonical Normal layout config."
(ebox-layout-config--create :kind 'normal :props nil))
(defun ebox-node-kind (node)
"Return canonical NODE kind, or nil for a legacy runtime node."
(and (listp node) (plist-get node :ebox-kind)))
(defun ebox-text-node-p (node)
"Return non-nil when NODE is a canonical TextNode."
(eq (ebox-node-kind node) 'text))
(defun ebox-box-node-p (node)
"Return non-nil when NODE is a canonical BoxNode."
(eq (ebox-node-kind node) 'box))
(defun ebox-node-source-handle (node)
"Return canonical NODE's opaque source handle."
(and (listp node) (plist-get node :ebox-source-handle)))
(defun ebox-text-node-value (node)
"Return canonical TextNode NODE's string payload."
(unless (ebox-text-node-p node)
(error "Expected canonical TextNode, got %S" node))
(plist-get node :ebox-text-value))
(defun ebox-box-node-layout (node)
"Return canonical BoxNode NODE's typed layout config."
(unless (ebox-box-node-p node)
(error "Expected canonical BoxNode, got %S" node))
(plist-get node :ebox-layout-config))
(defun ebox-box-node-children (node)
"Return canonical BoxNode NODE's retained canonical children."
(unless (ebox-box-node-p node)
(error "Expected canonical BoxNode, got %S" node))
(plist-get node :ebox-canonical-children))
;;;###autoload
(defun ebox-text-create (&rest plist)
"Create a canonical TextNode from evaluated PLIST.
`:value' is required and must be one string. `:source-handle' is optional.
Only text measurement properties are accepted; paint is projected separately."
(ebox-canonical--validate-plist plist "ebox-text-create")
(let* ((value (ebox-canonical--required-field
plist :value "Ebox Text"))
(source-field (ebox-canonical--optional-field
plist :source-handle "Ebox Text"))
(source-handle (cdr source-field))
(props (ebox-canonical--without-keys
plist '(:value :source-handle))))
(unless (stringp value)
(error "Ebox Text :value must be a string: %S" value))
(ebox-canonical--assert-property-role
props #'ebox-canonical--text-measurement-property-p "Ebox Text")
(let ((node (apply #'ebox-create (append props (list :content value)))))
(plist-put node :ebox-kind 'text)
(plist-put node :ebox-text-value value)
(plist-put node :ebox-source-handle source-handle)
node)))
;;;###autoload
(defun ebox-box-create (&rest plist)
"Create a canonical BoxNode from evaluated PLIST.
`:layout' must occur exactly once as an `ebox-layout-config'.
`:children' is a list of canonical nodes. This first vertical slice supports
Normal layout with zero or one child; later slices extend the same typed port
to the other Layout variants."
(ebox-canonical--validate-plist plist "ebox-box-create")
(let* ((layout (ebox-canonical--required-field
plist :layout "Ebox Box"))
(children-field (ebox-canonical--optional-field
plist :children "Ebox Box"))
(outer-field (ebox-canonical--optional-field
plist :outer "Ebox Box"))
(source-field (ebox-canonical--optional-field
plist :source-handle "Ebox Box"))
(children (if children-field (cdr children-field) nil))
(outer (if outer-field (cdr outer-field) 'block))
(source-handle (cdr source-field))
(props (ebox-canonical--without-keys
plist '(:layout :children :outer :source-handle))))
(unless (ebox-layout-config-p layout)
(error "Ebox Box :layout must be a typed layout config: %S"
layout))
(unless (memq outer '(inline block))
(error "Ebox Box :outer must be inline or block: %S" outer))
(unless (proper-list-p children)
(error "Ebox Box :children must be a proper list: %S" children))
(unless (cl-every (lambda (child)
(or (ebox-text-node-p child)
(ebox-box-node-p child)))
children)
(error "Ebox Box children must be canonical Text/Box nodes: %S"
children))
(unless (eq (ebox-layout-config-kind layout) 'normal)
(error "Ebox Box Layout is not implemented in this slice: %S"
(ebox-layout-config-kind layout)))
(when (> (length children) 1)
(error "Ebox Box Normal layout currently accepts at most one child"))
(ebox-canonical--assert-property-role
props #'ebox-canonical--normal-box-property-p "Ebox Box")
(let ((node
(apply #'ebox-create
(append props
(when children
(list :ebox-content-node (car children)))))))
(plist-put node :ebox-kind 'box)
(plist-put node :ebox-layout-config layout)
(plist-put node :ebox-canonical-children children)
(plist-put node :ebox-source-handle source-handle)
(plist-put node :display (list outer 'flow))
node)))
(provide 'ebox-canonical)
;;; ebox-canonical.el ends here

View File

@ -11,6 +11,7 @@
(require 'ebox-tree)
(require 'ebox-flex)
(require 'ebox-grid)
(require 'ebox-canonical)
(declare-function ebox-create "ebox" (&rest plist))
@ -97,6 +98,20 @@ The first value is string content. The second value is a lazy child node."
(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)
(error "ebox-build: text requires exactly one string payload"))
(unless (stringp (car payloads))
(error "ebox-build: text payload must be a string: %S" (car payloads)))
(apply #'ebox-text-create
(append props (list :value (car payloads))))))
(defun ebox--build-wrap-layout (tag props layout)
"Wrap LAYOUT with box PROPS for DSL TAG, or return LAYOUT unchanged."
(if (null props)
@ -220,13 +235,14 @@ and `column' may also receive box properties; those properties wrap
the child layout in a box using the rendered child layout as content."
(cond
((ebox--node-p dsl) dsl)
((stringp dsl) (ebox-create :content dsl))
((stringp dsl) (ebox-text-create :value dsl))
((not (and (consp dsl) (symbolp (car dsl))))
(error "ebox-build: invalid DSL node %S" dsl))
(t
(let ((tag (car dsl))
(items (ebox--dsl-unquote-properties (cdr dsl))))
(pcase tag
('text (ebox--build-text items))
('box (ebox--build-canonical-box items))
('ebox (ebox--build-box items))
('row (ebox--build-layout tag #'ebox-row items))

62
ebox-node-factory.el Normal file
View File

@ -0,0 +1,62 @@
;;; ebox-node-factory.el --- Runtime box node factory -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns the legacy runtime box encoding used by render/layout modules during
;; the typed canonical migration. Higher programmatic constructors may lower
;; through this factory, but author DSL parsing and buffer publication do not
;; belong here.
;;; Code:
(require 'ebox-style)
(defconst ebox--longhand
'(:content ""
:box-sizing border-box
:width nil :min-width 0 :max-width nil
:height nil :min-height 0 :max-height nil
: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-left-color nil
:border-right-pixel 0 :border-right-color nil
:border-top-p nil :border-top-color nil
:border-bottom-p nil :border-bottom-color nil
:color nil :bgcolor nil
:font nil :font-family nil :font-height nil
:font-weight nil :font-slant nil
:text-align left :vertical-align top
:overflow scroll :visibility visible
:wrap-mode word :scroll-offset 0)
"Default longhand fields for the current runtime box encoding.")
(defun ebox--longhand-create (&optional plist)
"Return a runtime box field plist overridden by PLIST."
(let ((box (copy-sequence ebox--longhand)))
(while plist
(let ((key (pop plist))
(value (pop plist)))
(plist-put box key value)))
box))
;;;###autoload
(defun ebox-create (&rest plist)
"Create one legacy runtime box from PLIST.
New framework integrations should use `ebox-text-create' and
`ebox-box-create'. This factory remains public during source-tree migration."
(let ((declarations (ebox-style-compile-declarations plist))
(expanded (ebox-style-expand-ebox-plist plist))
(controlled-scroll-offset-p (plist-member plist :scroll-offset)))
(let ((box (ebox--longhand-create expanded)))
(plist-put box :ebox-type 'box)
(plist-put box :display '(block flow))
(plist-put box :ebox-style-declarations declarations)
(when controlled-scroll-offset-p
(plist-put box :ebox-scroll-offset-controlled-p t))
box)))
(provide 'ebox-node-factory)
;;; ebox-node-factory.el ends here

View File

@ -114,14 +114,14 @@
:group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size)
(:name :border-top-width :id ebox/border-top-width
:aliases (:border-top-p) :initial nil :contexts (box)
:initial nil :contexts (box)
:group geometry :dirty-kind geometry :signature layout)
(:name :border-right-width :id ebox/border-right-width
:aliases (:border-right-pixel) :initial nil :contexts (box)
:group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size)
(:name :border-bottom-width :id ebox/border-bottom-width
:aliases (:border-bottom-p) :initial nil :contexts (box)
:initial nil :contexts (box)
:group geometry :dirty-kind geometry :signature layout)
(:name :border-left-width :id ebox/border-left-width
:aliases (:border-left-pixel) :initial nil :contexts (box)
@ -231,6 +231,14 @@
(:name :border-right :id ebox/border-right :shorthand border-right)
(:name :border-bottom :id ebox/border-bottom :shorthand border-bottom)
(:name :border-left :id ebox/border-left :shorthand border-left)
(:name :border-top-p :id ebox/border-top-p
:shorthand border-top-present :validator boolean
:exclusive-outputs t
:group structure :dirty-kind structure :signature layout)
(:name :border-bottom-p :id ebox/border-bottom-p
:shorthand border-bottom-present :validator boolean
:exclusive-outputs t
:group structure :dirty-kind structure :signature layout)
(:name :border-width :id ebox/border-width :shorthand border-width
:group geometry :dirty-kind geometry :signature layout)
(:name :border-style :id ebox/border-style :shorthand border-style
@ -263,15 +271,25 @@ every `ebox-create' needlessly copies the entire property metadata domain.")
(make-hash-table :test 'equal)
"Bounded memo table for canonical Ebox declaration compilation.")
(defun ebox-style--register-property-name (table name property role)
"Register NAME for PROPERTY ROLE in TABLE, rejecting collisions."
(when-let* ((existing (gethash name table)))
(unless (eq existing property)
(error "Ebox property %S %s collides with canonical property %S"
name role (plist-get existing :name))))
(puthash name property table))
(defun ebox-style--ensure-property-table ()
"Return the canonical style property registry table."
(or ebox-style--property-table
(let ((table (make-hash-table :test 'eq)))
(dolist (property ebox-style--property-definitions)
(puthash (plist-get property :name) property table)
(puthash (plist-get property :id) property table)
(ebox-style--register-property-name
table (plist-get property :name) property "name")
(ebox-style--register-property-name
table (plist-get property :id) property "ID")
(dolist (alias (plist-get property :aliases))
(puthash alias property table)))
(ebox-style--register-property-name table alias property "alias")))
(setq ebox-style--property-table table))))
(defun ebox-style-property (name)
@ -457,6 +475,22 @@ every `ebox-create' needlessly copies the entire property metadata domain.")
"Expand left border VALUE to namespaced longhands."
(ebox-style--border-side-declarations 'left value))
(defun ebox-style--border-present-declarations (side value)
"Return canonical boolean border declarations for SIDE and VALUE."
(unless (memq value '(nil t))
(error "Ebox border-%s-p must be boolean: %S" side value))
(list (intern (format "ebox/border-%s-width" side)) (if value 1 0)
(intern (format "ebox/border-%s-style" side))
(if value 'solid 'none)))
(defun ebox-style--border-top-present-shorthand (value)
"Expand boolean top-border VALUE to canonical width and style."
(ebox-style--border-present-declarations 'top value))
(defun ebox-style--border-bottom-present-shorthand (value)
"Expand boolean bottom-border VALUE to canonical width and style."
(ebox-style--border-present-declarations 'bottom value))
(defun ebox-style--border-component-shorthand (component value)
"Expand border COMPONENT VALUE across all physical sides."
(ebox-style--trbl-declarations
@ -593,6 +627,7 @@ every `ebox-create' needlessly copies the entire property metadata domain.")
('font-family #'ebox-style--font-family-p)
('font-height #'ebox-style--font-height-p)
('font-attribute #'ebox-style--font-attribute-p)
('boolean (lambda (value) (memq value '(nil t))))
(_ (lambda (_value) t))))
(defun ebox-style--shorthand (name)
@ -610,6 +645,8 @@ every `ebox-create' needlessly copies the entire property metadata domain.")
(border-right . ebox-style--border-right-shorthand)
(border-bottom . ebox-style--border-bottom-shorthand)
(border-left . ebox-style--border-left-shorthand)
(border-top-present . ebox-style--border-top-present-shorthand)
(border-bottom-present . ebox-style--border-bottom-present-shorthand)
(border-width . ebox-style--border-width-shorthand)
(border-style . ebox-style--border-style-shorthand)
(border-color . ebox-style--border-color-shorthand)
@ -694,6 +731,58 @@ cascade pass during its initial static projection."
"Return non-nil when PLIST is a proper even-length list."
(and (proper-list-p plist) (zerop (% (length plist) 2))))
(defun ebox-style--validate-no-duplicate-properties (plist)
"Reject duplicate canonical properties in author PLIST."
(let ((seen (make-hash-table :test 'eq)))
(cl-loop for (name _value) on plist by #'cddr
for property = (ebox-style-property name)
for id = (and property (plist-get property :id))
when id
do (when-let* ((previous (gethash id seen)))
(error
"Duplicate Ebox property %S via %S and %S"
(plist-get property :name) previous name))
and do (puthash id name seen)))
plist)
(defun ebox-style--author-property-output-ids (name value)
"Return canonical output IDs produced by author NAME and VALUE."
(when-let* ((property (ebox-style-property name)))
(if-let* ((shorthand-name (plist-get property :shorthand))
(expander (ebox-style--shorthand shorthand-name)))
(cl-loop for (id _output) on (funcall expander value) by #'cddr
collect id)
(list (plist-get property :id)))))
(defun ebox-style--validate-exclusive-properties (plist)
"Reject author properties whose canonical outputs overlap in PLIST."
(let (entries)
(cl-loop for (name value) on plist by #'cddr
for property = (ebox-style-property name)
when property
do (push (list :name name :value value :property property)
entries))
(when (cl-some (lambda (entry)
(plist-get (plist-get entry :property)
:exclusive-outputs))
entries)
(dolist (entry entries)
(when (plist-get (plist-get entry :property) :exclusive-outputs)
(let ((outputs
(ebox-style--author-property-output-ids
(plist-get entry :name) (plist-get entry :value))))
(dolist (other entries)
(unless (eq entry other)
(when (cl-intersection
outputs
(ebox-style--author-property-output-ids
(plist-get other :name) (plist-get other :value))
:test #'eq)
(error "Ebox property %S cannot be combined with %S"
(plist-get entry :name)
(plist-get other :name))))))))))
plist)
(defun ebox-style--declaration-cache-key (plist strict)
"Return a stable style-only cache key for PLIST and STRICT.
Non-style node properties such as content, Host references, and callbacks do
@ -706,7 +795,12 @@ not affect declaration compilation and are intentionally excluded."
(append canonical (list schema value)))
else if (ebox-style--custom-property-p property)
do (setq canonical
(append canonical (list property value))))
(append canonical (list property value)))
else if strict
do (setq canonical
(append canonical
(list 'ebox-style/unknown-property
property value))))
(list strict canonical)))
(defun ebox-style--static-invalid-declaration-p (validator value)
@ -738,6 +832,8 @@ When STRICT is non-nil, reject properties outside the Ebox style domain."
(ebox-style--ensure-properties)
(unless (ebox-style--valid-plist-p plist)
(user-error "Ebox style declarations must be an even property list"))
(ebox-style--validate-no-duplicate-properties plist)
(ebox-style--validate-exclusive-properties plist)
(let* ((cache-key (ebox-style--declaration-cache-key plist strict))
(missing (make-symbol "ebox-style-declaration-cache-missing"))
(cached (gethash cache-key ebox-style--declaration-cache missing)))

81
ebox.el
View File

@ -23,9 +23,11 @@
"Directory containing the active Ebox Lisp sources.")
(defconst ebox--compile-sources
'("ebox-cache.el" "ebox-style.el" "ebox-child-range.el" "ebox-tree.el" "ebox-measure.el"
'("ebox-cache.el" "ebox-style.el" "ebox-node-factory.el"
"ebox-child-range.el" "ebox-tree.el" "ebox-measure.el"
"ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el"
"ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-incremental.el"
"ebox-flex.el" "ebox-grid.el" "ebox-canonical.el"
"ebox-buffer-backend.el" "ebox-incremental.el"
"ebox-native-commit.el" "ebox-surface.el" "ebox-viewport.el"
"ebox-dsl.el" "ebox-selector.el" "ebox.el"
"ebox-native-reflow.el")
@ -39,6 +41,7 @@
(require 'subr-x)
(require 'ebox-cache)
(require 'ebox-style)
(require 'ebox-node-factory)
(require 'ebox-child-range)
(require 'ebox-tree)
(require 'ebox-measure)
@ -47,6 +50,7 @@
(require 'ebox-layout)
(require 'ebox-flex)
(require 'ebox-grid)
(require 'ebox-canonical)
(require 'ebox-buffer-backend)
(require 'ebox-incremental)
(require 'ebox-surface)
@ -387,51 +391,6 @@ caller, such as the Playground preview, renders against a concrete window.")
"Generate next unique region ID."
(cl-incf ebox--region-id-counter))
(defconst ebox--longhand
'( :content ""
:box-sizing border-box ;; content-box or border-box
;; 所有宽度的单位是像素,所有高度的单位是行数
:width nil :min-width 0 :max-width nil
:height nil :min-height 0 :max-height nil
: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-left-color nil
:border-right-pixel 0 :border-right-color nil
;; 上下边框由于 Emacs 限制,用 overline/underline 实现,不支持设置宽度
:border-top-p nil :border-top-color nil
:border-bottom-p nil :border-bottom-color nil
:color nil
:bgcolor nil
:font nil :font-family nil :font-height nil
:font-weight nil :font-slant nil
:text-align left :vertical-align top
;; supports: 'visible / 'hidden / 'scroll
:overflow scroll
;; supports: 'visible / 'hidden — hidden suppresses ink while the
;; layout footprint, buffer text, and metadata stay identical
:visibility visible
;; supports: 'word / 'char / 'kp
:wrap-mode word
:scroll-offset 0 ;; 当前滚动偏移(行数)
)
"Default plist for a box structure (longhand properties only).")
(defun ebox--longhand-create (&optional plist)
"Create a shorthand box structure.
PLIST is a property list that overrides the defaults."
(let ((box (copy-sequence ebox--longhand)))
(while plist
(let ((key (pop plist))
(value (pop plist)))
(plist-put box key value)))
box))
(defsubst ebox-get (box property)
"Retrieve PROPERTY from BOX."
(plist-get box property))
@ -869,21 +828,6 @@ Supports: t (default fg), nil, string, (light . dark) cons."
;;; --- 核心 API ---
;;;###autoload
(defun ebox-create (&rest plist)
"Create a box from PLIST. Supports all shorthand and longhand properties.
Properties are expanded recursively according to `ebox-property-rules'."
(let ((declarations (ebox-style-compile-declarations plist))
(expanded (ebox--expand-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 :display '(block flow))
(plist-put box :ebox-style-declarations declarations)
(when controlled-scroll-offset-p
(plist-put box :ebox-scroll-offset-controlled-p t))
box)))
;;; ============================================================
;;; Buffer Runtime State & Scroll State
;;; ============================================================
@ -4643,6 +4587,9 @@ through dirty-set and patch-set execution before falling back to root rerender."
ebox-candidate-patch-host-paint
ebox-clear-cache
ebox-column
ebox-normal-layout-create
ebox-text-create
ebox-box-create
ebox-commit
ebox-concat
ebox-create
@ -4724,8 +4671,16 @@ and are documented separately in the public API reference.")
;; All other ebox-- prefixed functions are internal implementation details.
;;
;; ── Creation ──────────────────────────────────────────────────
;; ebox-text-create :value string &rest canonical-measurement-props
;; Create one typed canonical TextNode.
;; ebox-normal-layout-create
;; Create the typed Normal LayoutConfig.
;; ebox-box-create :layout config :children nodes &rest geometry-props
;; Create one typed canonical BoxNode. The current vertical slice
;; accepts Normal layout with zero or one canonical child.
;; ebox-create &rest plist
;; Create a box. Key options: :content :width :height
;; 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

View File

@ -5254,13 +5254,13 @@
:padding (0 (8)) :border "#D97757")
(box :content "Body" :width (120)
:padding (0 (8)) :border "#D97757"
:border-top-p nil))
:border-top none))
(column
(box :id right :content "Right" :width (120)
:padding (0 (8)) :border "#5E7F6A")
(box :content "Body" :width (120)
:padding (0 (8)) :border "#5E7F6A"
:border-top-p nil)))))
:border-top none)))))
(left-id (plist-get
(car (ebox-selector-query-all layout "#left"))
:region-id)))
@ -5291,17 +5291,17 @@
(column
(box :id left :content "Left" :width (120)
:padding (0 (8)) :border "#D97757"
:border-bottom-p nil)
:border-bottom none)
(box :content "Body" :width (120)
:padding (0 (8)) :border "#D97757"
:border-top-p nil))
:border-top none))
(column
(box :content "Right" :width (120)
:padding (0 (8)) :border "#5E7F6A"
:border-bottom-p nil)
:border-bottom none)
(box :content "Body" :width (120)
:padding (0 (8)) :border "#5E7F6A"
:border-top-p nil)))))
:border-top none)))))
(left-id (plist-get
(car (ebox-selector-query-all layout "#left"))
:region-id)))
@ -5335,17 +5335,17 @@
(column
(box :id left :content "Left" :width (120)
:padding (0 (8)) :border "#D97757"
:border-bottom-p nil)
:border-bottom none)
(box :content "Body" :width (120)
:padding (0 (8)) :border "#D97757"
:border-top-p nil))
:border-top none))
(column
(box :content "Right" :width (120)
:padding (0 (8)) :border "#5E7F6A"
:border-bottom-p nil)
:border-bottom none)
(box :content "Body" :width (120)
:padding (0 (8)) :border "#5E7F6A"
:border-top-p nil)))))
:border-top none)))))
(left-id (plist-get
(car (ebox-selector-query-all layout "#left"))
:region-id)))
@ -8504,7 +8504,7 @@
`(flex :width (240) :flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8)) :border "#D97757"
:border-top-p nil :bgcolor "#FFF8F2"
:border-top none :bgcolor "#FFF8F2"
,@(cl-loop for idx below 6
collect
`(box :id ,(format "prefix-item-%02d" idx)
@ -8610,7 +8610,7 @@
:flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8)) :border "#D97757"
:border-top-p nil :bgcolor "#FFF8F2"
:border-top none :bgcolor "#FFF8F2"
,@(cl-loop for idx below 8
collect
`(box :content ,(format "wrapper %02d" idx)
@ -8659,7 +8659,7 @@
:flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8)) :border "#D97757"
:border-top-p nil :bgcolor "#FFF8F2"
:border-top none :bgcolor "#FFF8F2"
,@(cl-loop for idx below 4
collect
`(box :content ,(format "complete %02d" idx)
@ -8788,7 +8788,7 @@
:flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8)) :border "#D97757"
:border-top-p nil :bgcolor "#FFF8F2"
:border-top none :bgcolor "#FFF8F2"
,@(cl-loop for idx below 8
collect
`(box :content ,(format "extend %02d" idx)
@ -8821,7 +8821,7 @@
`(flex :width (240) :flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8)) :border "#D97757"
:border-top-p nil :bgcolor "#FFF8F2"
:border-top none :bgcolor "#FFF8F2"
,@(cl-loop for idx below 16
collect
`(box :content ,(format "piece %02d" idx)
@ -8852,7 +8852,7 @@
:padding (0 (8) 2 (8))
:margin-bottom 2
:border "#D97757"
:border-top-p nil
:border-top none
:bgcolor "#FFF8F2"
,@(cl-loop for idx below 2
collect
@ -8897,7 +8897,7 @@
:row-gap 1 :column-gap 0
:padding (0 (8))
:border "#D97757"
:border-top-p nil
:border-top none
:bgcolor "#FFF8F2"
,@(cl-loop for idx below 4
collect
@ -8934,7 +8934,7 @@
:row-gap 1 :column-gap 0
:padding (0 (8))
:border "#D97757"
:border-top-p nil
:border-top none
,@(cl-loop for idx below 4
collect
`(box :content ,(format "atomic %02d" idx)
@ -11066,7 +11066,7 @@
:flex-flow (row wrap)
:row-gap 1 :column-gap (8)
:padding (0 (12)) :border "#D97757"
:border-top-p nil
:border-top none
,@(cl-loop for idx below 24
collect
`(box :content ,(format "signature %02d" idx)

View File

@ -52,6 +52,164 @@
(ebox-style-apply-computed node style)
(should (equal (ebox--computed-display node) '(inline flex)))))
(ert-deftest ebox-style-strict-cache-does-not-hide-unknown-properties ()
"Strict declaration validation should remain exact after an empty cache hit."
(ebox-style-compile-declarations nil t)
(should-error
(ebox-style-compile-declarations '(:not-an-ebox-property 1) t)
:type 'error))
(ert-deftest ebox-style-aliases-share-one-canonical-property-id ()
"A concise alias should disappear before canonical declarations."
(should
(equal (ebox-style-compile-declarations '(:bgcolor "red") t)
(ebox-style-compile-declarations '(:background-color "red") t))))
(ert-deftest ebox-style-rejects-canonical-and-alias-duplicates ()
"One declaration source should not spell one property twice."
(should-error
(ebox-style-compile-declarations
'(:bgcolor "red" :background-color "blue") t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:background-color "red" :bgcolor "blue") t)
:type 'error))
(ert-deftest ebox-style-property-registry-rejects-alias-collisions ()
"An alias should never shadow another canonical property."
(let ((ebox-style--property-table nil)
(ebox-style--property-definitions
'((:name :first :id ebox/first :aliases (:second))
(:name :second :id ebox/second))))
(should-error (ebox-style--ensure-property-table) :type 'error)))
(ert-deftest ebox-style-border-present-is-a-typed-shorthand-not-an-alias ()
"The concise border boolean should expand to canonical width and style."
(should
(equal (ebox-style-compile-declarations '(:border-top-p t) t)
'(ebox/border-top-width 1 ebox/border-top-style solid)))
(should
(equal (ebox-style-compile-declarations '(:border-top-p nil) t)
'(ebox/border-top-width 0 ebox/border-top-style none)))
(should (eq (ebox-style-canonical-name :border-top-p) :border-top-p)))
(ert-deftest ebox-style-exclusive-check-does-not-reexpand-ordinary-shorthands ()
"Conflict validation should leave the declaration cache effective."
(let ((calls 0)
(original (symbol-function 'ebox-style--padding-shorthand)))
(clrhash ebox-style--declaration-cache)
(cl-letf (((symbol-function 'ebox-style--padding-shorthand)
(lambda (value)
(cl-incf calls)
(funcall original value))))
(ebox-style-compile-declarations '(:padding 1) t)
(ebox-style-compile-declarations '(:padding 1) t))
(should (= calls 1))))
(ert-deftest ebox-style-border-present-rejects-owned-longhand-conflicts ()
"One source should not mix the boolean border shorthand with its outputs."
(should-error
(ebox-style-compile-declarations
'(:border-top-p t :border-top-width 2) t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:border-top-style dashed :border-top-p t) t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:border-top-p t :border-top (2 dashed "red")) t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:border-top-p t :border-width 2) t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:border-style dashed :border-top-p t) t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:border-bottom-p t :border (1 solid "red")) t)
:type 'error))
(ert-deftest ebox-canonical-text-constructor-is-renderable-and-typed ()
"A canonical TextNode should retain its kind, source, and string value."
(let ((node (ebox-text-create :value "Hello" :source-handle 'source-text)))
(should (ebox-text-node-p node))
(should-not (ebox-box-node-p node))
(should (eq (ebox-node-source-handle node) 'source-text))
(should (string= (ebox-text-node-value node) "Hello"))
(should (string= (ebox-dsl-test--plain node) "Hello"))))
(ert-deftest ebox-canonical-text-constructor-rejects-non-text-shapes ()
"A canonical TextNode should have exactly one string payload."
(should-error (ebox-text-create :value 7) :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" :color "red") :type 'error)
(should-error
(ebox-text-create :value "A" :value "B")
:type 'error)
(should-error
(ebox-text-create :value "A" :source-handle 'a :source-handle 'b)
:type 'error))
(ert-deftest ebox-canonical-normal-box-renders-one-typed-text-child ()
"A canonical BoxNode should retain typed Layout and child source facts."
(let* ((text (ebox-text-create :value "A" :source-handle 'source-text))
(layout (ebox-normal-layout-create))
(box (ebox-box-create :layout layout
:children (list text)
:source-handle 'source-box)))
(should (ebox-box-node-p box))
(should-not (ebox-text-node-p box))
(should (eq (ebox-node-source-handle box) 'source-box))
(should (eq (ebox-layout-config-kind
(ebox-box-node-layout box))
'normal))
(should (equal (ebox-box-node-children box) (list text)))
(should (string= (ebox-dsl-test--plain box) "A"))))
(ert-deftest ebox-canonical-box-rejects-paint-only-properties ()
"Canonical Box geometry should not duplicate paint-only state."
(should-error
(ebox-box-create :layout (ebox-normal-layout-create)
:bgcolor "red")
:type 'error))
(ert-deftest ebox-canonical-box-requires-unique-reserved-fields ()
"Typed Box reserved fields should never be missing or duplicated."
(let ((normal (ebox-normal-layout-create)))
(should-error (ebox-box-create :children nil) :type 'error)
(should-error
(ebox-box-create :layout normal :layout normal :children nil)
:type 'error)
(should-error
(ebox-box-create :layout normal :children nil :children nil)
:type 'error)
(should-error
(ebox-box-create :layout normal :outer nil :children nil)
:type 'error)))
(ert-deftest ebox-build-normalizes-text-author-forms ()
"The Ebox DSL should normalize explicit and bare Text forms."
(let ((text (ebox-build '(text "A")))
(bare (ebox-build "B")))
(should (ebox-text-node-p text))
(should (ebox-text-node-p bare))
(should (string= (ebox-dsl-test--plain text) "A"))
(should (string= (ebox-dsl-test--plain bare) "B"))))
(ert-deftest ebox-build-text-requires-one-string-payload ()
"The Ebox Text form should reject empty, multiple, and nested payloads."
(should-error (ebox-build '(text)) :type 'error)
(should-error (ebox-build '(text "A" "B")) :type 'error)
(should-error (ebox-build '(text (text "A"))) :type 'error)
(should-error (ebox-build '(text :color "red" "A")) :type 'error)
(should-error (ebox-build '(text :wrap-mode garbage "A")) :type 'error))
(ert-deftest ebox-dsl-module-preserves-public-build-syntax ()
"Extracted DSL compiler should preserve existing ebox-build syntax."
(let ((node (ebox-build

View File

@ -416,7 +416,7 @@
:padding (0 (10)) :border "#5E7F6A"
:text-align center)
(flex :id "body" :max-width (293) :padding (0 (8))
:border "#5E7F6A" :border-top-p nil
:border "#5E7F6A" :border-top none
:flex-wrap wrap :column-gap (8)
(item :flex-shrink 0
(box :content "A 118" :width (118)))
@ -439,14 +439,14 @@
(layout
(ebox-build
'(flex :flex-flow (row wrap) :row-gap 1 :column-gap (12)
:padding (0 (12)) :border "#6F6A95" :border-top-p nil
:padding (0 (12)) :border "#6F6A95" :border-top none
:bgcolor "#FBF9FF"
(column
(box :content ":flex-flow (row wrap)" :max-width (440)
:padding (0 (10)) :border "#6F6A95"
:text-align center)
(flex :max-width (440) :padding (0 (8))
:border "#6F6A95" :border-top-p nil
:border "#6F6A95" :border-top none
:flex-flow (row wrap) :gap (1 (12))
(item (box :content "A" :width (170)))
(item (box :content "B" :width (170)))
@ -456,7 +456,7 @@
:padding (0 (10)) :border "#6F6A95"
:text-align center)
(flex :max-width (440) :padding (0 (8))
:border "#6F6A95" :border-top-p nil
:border "#6F6A95" :border-top none
:flex-flow (column wrap) :gap (1 (12))
(item (box :content "A" :width (140)))
(item (box :content "B" :width (140)))

View File

@ -11,10 +11,12 @@
(defconst ebox-test--autoload-source-files
'("ebox.el"
"ebox-node-factory.el"
"ebox-measure.el"
"ebox-layout.el"
"ebox-flex.el"
"ebox-grid.el"
"ebox-canonical.el"
"ebox-buffer-backend.el"
"ebox-selector.el"
"ebox-native-reflow.el")
@ -49,12 +51,34 @@
"The public ebox facade should load all internal foundation modules."
(require 'ebox)
(dolist (feature '(ebox-cache ebox-style ebox-tree ebox-measure
ebox-node-factory
ebox-fragment ebox-layout ebox-flex ebox-grid
ebox-canonical
ebox-buffer-backend ebox-incremental
ebox-surface ebox-viewport ebox-dsl
ebox-selector))
(should (featurep feature))))
(ert-deftest ebox-canonical-module-loads-typed-port-without-facade ()
"The canonical autoload boundary should not depend on loading `ebox.el'."
(let* ((emacs (expand-file-name invocation-name invocation-directory))
(parent (file-name-directory (directory-file-name ebox-test--root)))
(ecss (expand-file-name "ecss" parent))
(tp (expand-file-name "tp" parent)))
(with-temp-buffer
(should
(zerop
(process-file
emacs nil (current-buffer) nil
"-Q" "--batch"
"-L" ebox-test--root "-L" ecss "-L" tp
"--eval" "(setq load-prefer-newer t)"
"--eval"
(concat
"(progn (require 'ebox-canonical) "
"(unless (ebox-text-node-p (ebox-text-create :value \"A\")) "
" (error \"Typed Text autoload failed\")))")))))))
(ert-deftest ebox-facade-exposes-native-workflow-without-loading-it ()
"The two native commands should be available without native load work."
(require 'ebox)
@ -67,10 +91,12 @@
(should (commandp 'ebox-byte-compile))
(should
(equal ebox--compile-sources
'("ebox-cache.el" "ebox-style.el" "ebox-child-range.el"
'("ebox-cache.el" "ebox-style.el" "ebox-node-factory.el"
"ebox-child-range.el"
"ebox-tree.el" "ebox-measure.el"
"ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el"
"ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-incremental.el"
"ebox-flex.el" "ebox-grid.el" "ebox-canonical.el"
"ebox-buffer-backend.el" "ebox-incremental.el"
"ebox-native-commit.el" "ebox-surface.el" "ebox-viewport.el"
"ebox-dsl.el" "ebox-selector.el" "ebox.el"
"ebox-native-reflow.el"))))
@ -143,6 +169,9 @@
("ebox-grid-fr" . "defun")
("ebox-grid-item" . "defun")
("ebox-grid" . "defun")
("ebox-normal-layout-create" . "defun")
("ebox-text-create" . "defun")
("ebox-box-create" . "defun")
("ebox-build" . "defun")
("ebox-create" . "defun")
("ebox-scroll-down" . "defun")
@ -178,6 +207,9 @@
ebox-candidate-replace-host-ref
ebox-clear-cache
ebox-column
ebox-normal-layout-create
ebox-text-create
ebox-box-create
ebox-commit
ebox-concat
ebox-create