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` | | 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` | | 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` | | 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` | | 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`) | | 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 | | 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-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-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. | | `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` | | 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` | | 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` | | 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` | | 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` | | 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-concat`、`ebox-stack`、`ebox-row`、`ebox-column`、`ebox-spacer` | 组合简单横向/纵向布局与空白 box。 |
| `ebox-flex`、`ebox-flex-item` | 构建 Flex 容器与 item metadata。 | | `ebox-flex`、`ebox-flex-item` | 构建 Flex 容器与 item metadata。 |
| `ebox-grid`、`ebox-grid-fr`、`ebox-grid-item` | 构建 Grid、分数轨道和 placement 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-tree)
(require 'ebox-flex) (require 'ebox-flex)
(require 'ebox-grid) (require 'ebox-grid)
(require 'ebox-canonical)
(declare-function ebox-create "ebox" (&rest plist)) (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 (when child-node
(list :ebox-content-node 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) (defun ebox--build-wrap-layout (tag props layout)
"Wrap LAYOUT with box PROPS for DSL TAG, or return LAYOUT unchanged." "Wrap LAYOUT with box PROPS for DSL TAG, or return LAYOUT unchanged."
(if (null props) (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." the child layout in a box using the rendered child layout as content."
(cond (cond
((ebox--node-p dsl) dsl) ((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)))) ((not (and (consp dsl) (symbolp (car dsl))))
(error "ebox-build: invalid DSL node %S" dsl)) (error "ebox-build: invalid DSL node %S" dsl))
(t (t
(let ((tag (car dsl)) (let ((tag (car dsl))
(items (ebox--dsl-unquote-properties (cdr dsl)))) (items (ebox--dsl-unquote-properties (cdr dsl))))
(pcase tag (pcase tag
('text (ebox--build-text items))
('box (ebox--build-canonical-box items)) ('box (ebox--build-canonical-box items))
('ebox (ebox--build-box items)) ('ebox (ebox--build-box items))
('row (ebox--build-layout tag #'ebox-row 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 :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size) :validator nonnegative-size)
(:name :border-top-width :id ebox/border-top-width (: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) :group geometry :dirty-kind geometry :signature layout)
(:name :border-right-width :id ebox/border-right-width (:name :border-right-width :id ebox/border-right-width
:aliases (:border-right-pixel) :initial nil :contexts (box) :aliases (:border-right-pixel) :initial nil :contexts (box)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size) :validator nonnegative-size)
(:name :border-bottom-width :id ebox/border-bottom-width (: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) :group geometry :dirty-kind geometry :signature layout)
(:name :border-left-width :id ebox/border-left-width (:name :border-left-width :id ebox/border-left-width
:aliases (:border-left-pixel) :initial nil :contexts (box) :aliases (:border-left-pixel) :initial nil :contexts (box)
@ -231,6 +231,14 @@
(:name :border-right :id ebox/border-right :shorthand border-right) (:name :border-right :id ebox/border-right :shorthand border-right)
(:name :border-bottom :id ebox/border-bottom :shorthand border-bottom) (:name :border-bottom :id ebox/border-bottom :shorthand border-bottom)
(:name :border-left :id ebox/border-left :shorthand border-left) (: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 (:name :border-width :id ebox/border-width :shorthand border-width
:group geometry :dirty-kind geometry :signature layout) :group geometry :dirty-kind geometry :signature layout)
(:name :border-style :id ebox/border-style :shorthand border-style (: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) (make-hash-table :test 'equal)
"Bounded memo table for canonical Ebox declaration compilation.") "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 () (defun ebox-style--ensure-property-table ()
"Return the canonical style property registry table." "Return the canonical style property registry table."
(or ebox-style--property-table (or ebox-style--property-table
(let ((table (make-hash-table :test 'eq))) (let ((table (make-hash-table :test 'eq)))
(dolist (property ebox-style--property-definitions) (dolist (property ebox-style--property-definitions)
(puthash (plist-get property :name) property table) (ebox-style--register-property-name
(puthash (plist-get property :id) property table) 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)) (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)))) (setq ebox-style--property-table table))))
(defun ebox-style-property (name) (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." "Expand left border VALUE to namespaced longhands."
(ebox-style--border-side-declarations 'left value)) (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) (defun ebox-style--border-component-shorthand (component value)
"Expand border COMPONENT VALUE across all physical sides." "Expand border COMPONENT VALUE across all physical sides."
(ebox-style--trbl-declarations (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-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)
('boolean (lambda (value) (memq value '(nil t))))
(_ (lambda (_value) t)))) (_ (lambda (_value) t))))
(defun ebox-style--shorthand (name) (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-right . ebox-style--border-right-shorthand)
(border-bottom . ebox-style--border-bottom-shorthand) (border-bottom . ebox-style--border-bottom-shorthand)
(border-left . ebox-style--border-left-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-width . ebox-style--border-width-shorthand)
(border-style . ebox-style--border-style-shorthand) (border-style . ebox-style--border-style-shorthand)
(border-color . ebox-style--border-color-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." "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))))
(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) (defun ebox-style--declaration-cache-key (plist strict)
"Return a stable style-only cache key for PLIST and STRICT. "Return a stable style-only cache key for PLIST and STRICT.
Non-style node properties such as content, Host references, and callbacks do 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))) (append canonical (list schema value)))
else if (ebox-style--custom-property-p property) else if (ebox-style--custom-property-p property)
do (setq canonical 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))) (list strict canonical)))
(defun ebox-style--static-invalid-declaration-p (validator value) (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) (ebox-style--ensure-properties)
(unless (ebox-style--valid-plist-p plist) (unless (ebox-style--valid-plist-p plist)
(user-error "Ebox style declarations must be an even property list")) (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)) (let* ((cache-key (ebox-style--declaration-cache-key plist strict))
(missing (make-symbol "ebox-style-declaration-cache-missing")) (missing (make-symbol "ebox-style-declaration-cache-missing"))
(cached (gethash cache-key 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.") "Directory containing the active Ebox Lisp sources.")
(defconst ebox--compile-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-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-native-commit.el" "ebox-surface.el" "ebox-viewport.el"
"ebox-dsl.el" "ebox-selector.el" "ebox.el" "ebox-dsl.el" "ebox-selector.el" "ebox.el"
"ebox-native-reflow.el") "ebox-native-reflow.el")
@ -39,6 +41,7 @@
(require 'subr-x) (require 'subr-x)
(require 'ebox-cache) (require 'ebox-cache)
(require 'ebox-style) (require 'ebox-style)
(require 'ebox-node-factory)
(require 'ebox-child-range) (require 'ebox-child-range)
(require 'ebox-tree) (require 'ebox-tree)
(require 'ebox-measure) (require 'ebox-measure)
@ -47,6 +50,7 @@
(require 'ebox-layout) (require 'ebox-layout)
(require 'ebox-flex) (require 'ebox-flex)
(require 'ebox-grid) (require 'ebox-grid)
(require 'ebox-canonical)
(require 'ebox-buffer-backend) (require 'ebox-buffer-backend)
(require 'ebox-incremental) (require 'ebox-incremental)
(require 'ebox-surface) (require 'ebox-surface)
@ -387,51 +391,6 @@ caller, such as the Playground preview, renders against a concrete window.")
"Generate next unique region ID." "Generate next unique region ID."
(cl-incf ebox--region-id-counter)) (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) (defsubst ebox-get (box property)
"Retrieve PROPERTY from BOX." "Retrieve PROPERTY from BOX."
(plist-get box property)) (plist-get box property))
@ -869,21 +828,6 @@ Supports: t (default fg), nil, string, (light . dark) cons."
;;; --- 核心 API --- ;;; --- 核心 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 ;;; 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-candidate-patch-host-paint
ebox-clear-cache ebox-clear-cache
ebox-column ebox-column
ebox-normal-layout-create
ebox-text-create
ebox-box-create
ebox-commit ebox-commit
ebox-concat ebox-concat
ebox-create ebox-create
@ -4724,8 +4671,16 @@ and are documented separately in the public API reference.")
;; All other ebox-- prefixed functions are internal implementation details. ;; All other ebox-- prefixed functions are internal implementation details.
;; ;;
;; ── Creation ────────────────────────────────────────────────── ;; ── 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 ;; 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 ;; :padding :margin :border :border-color :bgcolor
;; :text-align :wrap-mode :box-sizing :surface-properties :host-ref ;; :text-align :wrap-mode :box-sizing :surface-properties :host-ref
;; ebox-build dsl ;; ebox-build dsl

View File

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

View File

@ -52,6 +52,164 @@
(ebox-style-apply-computed node style) (ebox-style-apply-computed node style)
(should (equal (ebox--computed-display node) '(inline flex))))) (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 () (ert-deftest ebox-dsl-module-preserves-public-build-syntax ()
"Extracted DSL compiler should preserve existing ebox-build syntax." "Extracted DSL compiler should preserve existing ebox-build syntax."
(let ((node (ebox-build (let ((node (ebox-build

View File

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

View File

@ -11,10 +11,12 @@
(defconst ebox-test--autoload-source-files (defconst ebox-test--autoload-source-files
'("ebox.el" '("ebox.el"
"ebox-node-factory.el"
"ebox-measure.el" "ebox-measure.el"
"ebox-layout.el" "ebox-layout.el"
"ebox-flex.el" "ebox-flex.el"
"ebox-grid.el" "ebox-grid.el"
"ebox-canonical.el"
"ebox-buffer-backend.el" "ebox-buffer-backend.el"
"ebox-selector.el" "ebox-selector.el"
"ebox-native-reflow.el") "ebox-native-reflow.el")
@ -49,12 +51,34 @@
"The public ebox facade should load all internal foundation modules." "The public ebox facade should load all internal foundation modules."
(require 'ebox) (require 'ebox)
(dolist (feature '(ebox-cache ebox-style ebox-tree ebox-measure (dolist (feature '(ebox-cache ebox-style ebox-tree ebox-measure
ebox-node-factory
ebox-fragment ebox-layout ebox-flex ebox-grid ebox-fragment ebox-layout ebox-flex ebox-grid
ebox-canonical
ebox-buffer-backend ebox-incremental ebox-buffer-backend ebox-incremental
ebox-surface ebox-viewport ebox-dsl ebox-surface ebox-viewport ebox-dsl
ebox-selector)) ebox-selector))
(should (featurep feature)))) (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 () (ert-deftest ebox-facade-exposes-native-workflow-without-loading-it ()
"The two native commands should be available without native load work." "The two native commands should be available without native load work."
(require 'ebox) (require 'ebox)
@ -67,10 +91,12 @@
(should (commandp 'ebox-byte-compile)) (should (commandp 'ebox-byte-compile))
(should (should
(equal ebox--compile-sources (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-tree.el" "ebox-measure.el"
"ebox-fragment.el" "ebox-render-context.el" "ebox-layout.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-native-commit.el" "ebox-surface.el" "ebox-viewport.el"
"ebox-dsl.el" "ebox-selector.el" "ebox.el" "ebox-dsl.el" "ebox-selector.el" "ebox.el"
"ebox-native-reflow.el")))) "ebox-native-reflow.el"))))
@ -143,6 +169,9 @@
("ebox-grid-fr" . "defun") ("ebox-grid-fr" . "defun")
("ebox-grid-item" . "defun") ("ebox-grid-item" . "defun")
("ebox-grid" . "defun") ("ebox-grid" . "defun")
("ebox-normal-layout-create" . "defun")
("ebox-text-create" . "defun")
("ebox-box-create" . "defun")
("ebox-build" . "defun") ("ebox-build" . "defun")
("ebox-create" . "defun") ("ebox-create" . "defun")
("ebox-scroll-down" . "defun") ("ebox-scroll-down" . "defun")
@ -178,6 +207,9 @@
ebox-candidate-replace-host-ref ebox-candidate-replace-host-ref
ebox-clear-cache ebox-clear-cache
ebox-column ebox-column
ebox-normal-layout-create
ebox-text-create
ebox-box-create
ebox-commit ebox-commit
ebox-concat ebox-concat
ebox-create ebox-create