From 722791a01effaf62aac3e3f31c0c04dc428d5cf1 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Fri, 28 Aug 2026 22:06:30 +0800 Subject: [PATCH] refactor: unify canonical style and font projection --- DESIGN.md | 2 +- DESIGN.zh.md | 2 +- Makefile | 7 +- README.md | 2 +- README.zh-CN.md | 2 +- ...box-current-implementation-reference.en.md | 2 +- ...box-current-implementation-reference.zh.md | 2 +- docs/user/ebox-api-reference.en.md | 4 +- docs/user/ebox-api-reference.zh.md | 5 +- docs/user/ebox-user-guide.en.md | 6 +- docs/user/ebox-user-guide.zh.md | 6 +- ebox-buffer-backend.el | 92 +- ebox-canonical.el | 23 +- ebox-dsl.el | 61 +- ebox-flex.el | 19 +- ebox-font.el | 222 +++ ebox-fragment.el | 11 +- ebox-grid.el | 115 +- ebox-incremental.el | 14 +- ebox-layout-config.el | 3 +- ebox-layout.el | 44 +- ebox-measure.el | 38 +- ebox-native-reflow.el | 71 +- ebox-node-factory.el | 33 +- ebox-source.el | 26 + ebox-style.el | 1279 ++++++++++++----- ebox-surface.el | 231 ++- ebox-tree.el | 23 +- ebox.el | 176 +-- tests/ebox-child-range-tests.el | 16 +- tests/ebox-commit-tests.el | 125 +- tests/ebox-core-render-tests.el | 496 ++++--- tests/ebox-dsl-tests.el | 220 ++- tests/ebox-flex-tests.el | 26 +- tests/ebox-font-tests.el | 152 ++ tests/ebox-grid-tests.el | 39 +- tests/ebox-package-tests.el | 2 +- tests/ebox-surface-tests.el | 65 +- 38 files changed, 2491 insertions(+), 1171 deletions(-) create mode 100644 ebox-font.el create mode 100644 tests/ebox-font-tests.el diff --git a/DESIGN.md b/DESIGN.md index df0f948..d0519fc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -30,7 +30,7 @@ Use the functions and properties in `docs/user/ebox-api-reference.en.md`; the fa ## Layout scope -Ebox supports character and pixel dimensions, padding, margins, borders, colors, Emacs face/font properties, overflow, wrapping, row/column/flex formatting, and Grid tracks including fixed, fractional, implicit, gap, placement, span, and alignment behavior. The supported typography subset is `:font`, `:font-family`, `:font-height`/`:font-size`, `:font-weight`, and `:font-slant`. CSS compatibility is intentionally partial: percentages, absolute positioning, z-index, shadows, border radius, full browser typography, and browser-level bidi are outside this package. +Ebox supports column- and pixel-based horizontal dimensions, line-based vertical dimensions, padding, margins, pixel borders, colors, overflow, wrapping, row/column/flex formatting, and Grid tracks including fixed, fractional, implicit, gap, placement, span, and alignment behavior. The public typography subset is `:font-family`, CSS-reference-pixel `:font-size`, `:font-weight`, and `:font-style`; `:font-slant` is only an exact parse-time alias of `:font-style`. Emacs faces and text properties belong to the final adapter and are not Ebox author properties. CSS compatibility is intentionally partial: percentages, absolute positioning, z-index, shadows, border radius, full browser typography, and browser-level bidi are outside this package. ## Verification rule diff --git a/DESIGN.zh.md b/DESIGN.zh.md index d959a4e..e0012e4 100644 --- a/DESIGN.zh.md +++ b/DESIGN.zh.md @@ -30,7 +30,7 @@ Ebox 是底层空间渲染引擎。它负责把声明式节点树转换为经过 ## 布局范围 -Ebox 支持字符和像素尺寸、padding、margin、border、颜色、Emacs face/font property、overflow、换行、row/column/flex formatting,以及固定、分数、隐式、gap、放置、span 和对齐等 Grid 能力。支持的 typography 子集是 `:font`、`:font-family`、`:font-height`/`:font-size`、`:font-weight` 和 `:font-slant`。CSS 兼容性有意是部分实现:百分比、绝对定位、z-index、阴影、border radius、完整浏览器 typography 和浏览器级 bidi 不属于本包。 +Ebox 支持以列或像素表示的横向尺寸、以行表示的纵向尺寸、padding、margin、像素 border、颜色、overflow、换行、row/column/flex formatting,以及固定、分数、隐式、gap、放置、span 和对齐等 Grid 能力。公共 typography 子集是 `:font-family`、以 CSS reference pixel 表示的 `:font-size`、`:font-weight` 和 `:font-style`;`:font-slant` 只是 `:font-style` 的精确 parse-time alias。Emacs face 与文本属性只属于最终 adapter,不是 Ebox 作者属性。CSS 兼容性有意是部分实现:百分比、绝对定位、z-index、阴影、border radius、完整浏览器 typography 和浏览器级 bidi 不属于本包。 ## 验证规则 diff --git a/Makefile b/Makefile index 3210e45..43370e3 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ NATIVE_MANIFEST = native/Cargo.toml NATIVE_TARGET ?= $(shell $(EMACS_BATCH) -l ebox-native-reflow.el --eval '(princ (ebox-native-reflow--rust-target))') NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release -.PHONY: all check ci load compile test checkdoc source-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install +.PHONY: all check ci load compile test checkdoc source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install all: check @@ -25,11 +25,14 @@ compile: rm -f *.elc tests/*.elc scripts/*.elc $(EMACS_BATCH) --eval '(setq byte-compile-error-on-warn t byte-compile-warnings (quote (not obsolete)))' -l ebox.el --eval '(ebox-byte-compile)' -test: source-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests +test: source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests source-tests: $(EMACS_BATCH) -l tests/ebox-source-tests.el -f ert-run-tests-batch-and-exit +font-tests: + $(EMACS_BATCH) -l tests/ebox-font-tests.el -f ert-run-tests-batch-and-exit + core-tests: $(EMACS_TEST) -l tests/ebox-core-render-tests.el -f ert-run-tests-batch-and-exit diff --git a/README.md b/README.md index 1e23902..9b788dd 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ field-based child syntax. "*Ebox Example*" (ebox-build '(column :padding (1 2) - :border ((1) solid "#8A93A6") + :border (1 solid "#8A93A6") (text :color "#263244" "Hello Ebox") (row :item-gap 1 (box "Left") diff --git a/README.zh-CN.md b/README.zh-CN.md index 3986fb2..6dbf3d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -30,7 +30,7 @@ Ebox 需要 Emacs 29.1 或更高版本,并依赖 ECSS 与 TP。包管理器应 "*Ebox Example*" (ebox-build '(column :padding (1 2) - :border ((1) solid "#8A93A6") + :border (1 solid "#8A93A6") (text :color "#263244" "Hello Ebox") (row :item-gap 1 (box "Left") diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md index c76cedd..b68caf6 100644 --- a/docs/maintainer/ebox-current-implementation-reference.en.md +++ b/docs/maintainer/ebox-current-implementation-reference.en.md @@ -90,7 +90,7 @@ Caller-owned Source Tree ## Grid contract -Grid currently covers fixed and pixel tracks, `auto`, fractional tracks, `minmax`, `repeat`, implicit rows and columns, row/column gap, auto-flow, one-based placement, positive spans, item/content alignment, and ordinary buffer rendering and updates. Ebox supports a finite typography subset (`:font`, `:font-family`, `:font-height`/`:font-size`, `:font-weight`, and `:font-slant`); full browser typography is outside the contract. Percentages, absolute positioning, z-index, border radius, shadows, and browser-level bidi are also outside the contract. +Grid currently covers fixed and pixel tracks, `auto`, fractional tracks, `minmax`, `repeat`, implicit rows and columns, row/column gap, auto-flow, one-based placement, positive spans, item/content alignment, and ordinary buffer rendering and updates. Ebox supports canonical font inputs (`:font-family`, `:font-size`, `:font-weight`, and `:font-style`) plus text-decoration paint; full browser typography is outside the contract. Percentages, absolute positioning, z-index, border radius, shadows, and browser-level bidi are also outside the contract. ## Active examples and tests diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md index 98dc0c6..c17f17b 100644 --- a/docs/maintainer/ebox-current-implementation-reference.zh.md +++ b/docs/maintainer/ebox-current-implementation-reference.zh.md @@ -90,7 +90,7 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor ## Grid 合同 -当前 Grid 支持固定和像素轨道、`auto`、分数轨道、`minmax`、`repeat`、隐式行列、行列 gap、auto-flow、从 1 开始的 placement、正整数 span、item/content 对齐,以及普通 buffer 渲染和更新。Ebox 支持有限 typography 子集(`:font`、`:font-family`、`:font-height`/`:font-size`、`:font-weight` 和 `:font-slant`);完整浏览器 typography 不在合同内。百分比、绝对定位、z-index、圆角、阴影和浏览器级 bidi 也不在合同内。 +当前 Grid 支持固定和像素轨道、`auto`、分数轨道、`minmax`、`repeat`、隐式行列、行列 gap、auto-flow、从 1 开始的 placement、正整数 span、item/content 对齐,以及普通 buffer 渲染和更新。Ebox 支持规范字体输入(`:font-family`、`:font-size`、`:font-weight`、`:font-style`)与 text-decoration paint;完整浏览器 typography 不在合同内。百分比、绝对定位、z-index、圆角、阴影和浏览器级 bidi 也不在合同内。 ## Active 示例与测试 diff --git a/docs/user/ebox-api-reference.en.md b/docs/user/ebox-api-reference.en.md index f3c1f49..5309cd2 100644 --- a/docs/user/ebox-api-reference.en.md +++ b/docs/user/ebox-api-reference.en.md @@ -72,7 +72,9 @@ another public node or wrapper. | Overflow | `:overflow` (`visible`, `hidden`, `scroll`) | Ordinary horizontal numbers are character columns; a one-element horizontal -list is pixels. Vertical numbers are lines. `(viewport)` and +list is pixels. Vertical numbers are lines. Border widths are the exception: +they are always nonnegative integer pixels, so use `(1 solid "#687386")`, not +`((1) solid "#687386")`. `(viewport)` and `(viewport-height)` use the current render context. Property validation belongs to Ebox and invalid context/value combinations signal an error. diff --git a/docs/user/ebox-api-reference.zh.md b/docs/user/ebox-api-reference.zh.md index cf5799d..480d8a4 100644 --- a/docs/user/ebox-api-reference.zh.md +++ b/docs/user/ebox-api-reference.zh.md @@ -67,8 +67,9 @@ participation property 就是普通子 Box property,不会创建另一种公 | 文本布局 | `:text-align`、`:wrap-mode`(`word`、`char`、`kp`、`none`) | | Overflow | `:overflow`(`visible`、`hidden`、`scroll`) | -普通横向数字表示字符列,单元素横向 list 表示像素;纵向数字表示行数。 -`(viewport)` 与 `(viewport-height)` 使用当前 render context。property 验证属于 +普通横向数字表示字符列,单元素横向 list 表示像素;纵向数字表示行数。Border +width 是例外,始终使用非负整数像素,因此写 `(1 solid "#687386")`,不写 +`((1) solid "#687386")`。`(viewport)` 与 `(viewport-height)` 使用当前 render context。property 验证属于 Ebox;context/value 组合无效时会直接报错。 ## 2. Evaluated typed construction diff --git a/docs/user/ebox-user-guide.en.md b/docs/user/ebox-user-guide.en.md index 1d36d1e..ad32856 100644 --- a/docs/user/ebox-user-guide.en.md +++ b/docs/user/ebox-user-guide.en.md @@ -38,7 +38,7 @@ child-layout algorithm, not a different kind of visual object. (defvar ebox-guide-input (ebox-build '(column :padding (1 2) - :border ((1) solid "#8A93A6") + :border (1 solid "#8A93A6") (text :color "#263244" "Research notes") (row :item-gap 1 (box :id "status" :background-color "#F4F6FB" "Inbox") @@ -94,7 +94,7 @@ child `box`. ```elisp (ebox-build '(grid :width (640) - :grid-template-columns ((200) 1fr 1fr) + :grid-template-columns ((200) (fr 1) (fr 1)) :grid-template-rows (1 1) :gap (1 (12)) (box :grid-column (1 :span 3) "Header") @@ -117,7 +117,7 @@ Text Box geometry. '(box :width (420) :padding (1 2) :margin (0 1) - :border ((1) solid "#8A93A6") + :border (1 solid "#8A93A6") :color "#263244" :background-color "#FFFFFF" "A readable panel")) diff --git a/docs/user/ebox-user-guide.zh.md b/docs/user/ebox-user-guide.zh.md index 47f9919..9fcc936 100644 --- a/docs/user/ebox-user-guide.zh.md +++ b/docs/user/ebox-user-guide.zh.md @@ -34,7 +34,7 @@ Ebox 文档由 Text 和 Box 节点组成。author 语法只有七个入口: (defvar ebox-guide-input (ebox-build '(column :padding (1 2) - :border ((1) solid "#8A93A6") + :border (1 solid "#8A93A6") (text :color "#263244" "Research notes") (row :item-gap 1 (box :id "status" :background-color "#F4F6FB" "Inbox") @@ -86,7 +86,7 @@ placement property 同样直接属于子 `box`。 ```elisp (ebox-build '(grid :width (640) - :grid-template-columns ((200) 1fr 1fr) + :grid-template-columns ((200) (fr 1) (fr 1)) :grid-template-rows (1 1) :gap (1 (12)) (box :grid-column (1 :span 3) "Header") @@ -107,7 +107,7 @@ placement property 同样直接属于子 `box`。 '(box :width (420) :padding (1 2) :margin (0 1) - :border ((1) solid "#8A93A6") + :border (1 solid "#8A93A6") :color "#263244" :background-color "#FFFFFF" "A readable panel")) diff --git a/ebox-buffer-backend.el b/ebox-buffer-backend.el index b77d1fa..4db1818 100644 --- a/ebox-buffer-backend.el +++ b/ebox-buffer-backend.el @@ -6,9 +6,24 @@ ;;; Code: +(define-error 'ebox-surface-capability-error + "Ebox surface capability violation") + +(defun ebox-buffer-validate-border-capability (style) + "Return STYLE when its border can be represented exactly by Emacs text." + (dolist (side '(top bottom) style) + (let ((width (or (plist-get + style (intern (format ":border-%s-pixel" side))) + 0))) + (when (> width 1) + (signal 'ebox-surface-capability-error + (list :border side :declared-pixels width + :supported-pixels '(0 1))))))) + (require 'cl-lib) (require 'subr-x) (require 'ebox-measure) +(require 'ebox-font) (require 'tp-style) (defvar ebox--propertize-private-content-line-p nil @@ -320,29 +335,50 @@ Return FUNCTION's value." ;;; Paint Mapping -(defun ebox-buffer--font-attributes (style) - "Return anonymous face attributes for STYLE typography longhands." - (let (face) - (dolist (entry '((:font-family . :family) - (:font-height . :height) - (:font-weight . :weight) - (:font-slant . :slant))) - (when-let* ((value (plist-get style (car entry)))) - (setq face (plist-put face (cdr entry) value)))) - face)) +(defun ebox-buffer--font-fact (style) + "Return STYLE's shared surface-resolved font fact." + (and (listp style) (plist-get style :ebox-font-fact))) (defun ebox-buffer--font-face (style) - "Return STYLE's complete Emacs face value for typography." - (let* ((base (plist-get style :font)) - (base (if (stringp base) (list :family base) base)) - (attributes (ebox-buffer--font-attributes style))) - (cond - ((and attributes base) - (if (and (listp base) (not (keywordp (car-safe base)))) - (cons attributes base) - (list attributes base))) - (attributes attributes) - (base base)))) + "Return a detached Emacs face from STYLE's shared font fact." + (when-let* ((fact (ebox-buffer--font-fact style))) + (ebox-font-paint-face fact))) + +(defconst ebox-buffer--decoration-style-map + '((solid . line) (double . double-line) (dotted . dots) + (dashed . dashes) (wavy . wave)) + "Canonical CSS decoration styles mapped to Emacs underline styles.") + +(defun ebox-buffer--text-decoration-face (style) + "Return STYLE's final Emacs text-decoration face, or nil." + (let* ((line (plist-get style :text-decoration-line)) + (lines (if (listp line) line (list line))) + (color (plist-get style :text-decoration-color)) + (decoration-style + (or (plist-get style :text-decoration-style) 'solid)) + (resolved-color + (unless (or (memq color '(nil currentColor)) + (tp-paint-slot-p color)) + color)) + face) + (unless (or (null line) (equal lines '(none))) + (when (memq 'underline lines) + (setq face + (plist-put + face :underline + (append + (when resolved-color (list :color resolved-color)) + (let ((emacs-style + (alist-get decoration-style + ebox-buffer--decoration-style-map))) + (unless (eq emacs-style 'line) + (list :style emacs-style))))))) + (when (memq 'overline lines) + (setq face (plist-put face :overline (or resolved-color t)))) + (when (memq 'line-through lines) + (setq face (plist-put face :strike-through + (or resolved-color t))))) + face)) (defun ebox-buffer--paint-color (style role) "Return the foreground paint color for STYLE and ROLE." @@ -377,6 +413,8 @@ foreground color." This backend mapper intentionally accepts computed style facts and emits only buffer-facing paint properties. Layout-only properties never pass through." (let ((face (ebox-buffer--font-face style))) + (when-let* ((decoration (ebox-buffer--text-decoration-face style))) + (setq face (append face decoration))) (when-let* ((foreground (ebox-buffer--paint-color style role))) (setq face (cond @@ -397,7 +435,17 @@ buffer-facing paint properties. Layout-only properties never pass through." (defun ebox--propertize-typography (string style) "Apply STYLE's typography to one copy of STRING." - (if-let* ((face (ebox-buffer--font-face style))) + (if-let* ((fact (ebox-buffer--font-fact style))) + (let ((copy (copy-sequence string)) + (face (ebox-font-measurement-face fact))) + (when face + (ebox--add-render-face! copy 0 (length copy) face t)) + (ebox--register-render-owned-face-values string copy)) + string)) + +(defun ebox--propertize-text-decoration (string style) + "Apply STYLE's text decoration to one copy of STRING." + (if-let* ((face (ebox-buffer--text-decoration-face style))) (let ((copy (copy-sequence string))) (ebox--add-render-face! copy 0 (length copy) face t) (ebox--register-render-owned-face-values string copy)) diff --git a/ebox-canonical.el b/ebox-canonical.el index 664e01b..dd66e14 100644 --- a/ebox-canonical.el +++ b/ebox-canonical.el @@ -242,9 +242,11 @@ canonical inputs, not additional source authority." (plist-put node :ebox-source-handle handle) node) -(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--text-engine-property-p (_property) + "Return non-nil for a Text fact materializable without a Surface. +Canonical Text style currently consists of paint facts and host-resolved font +facts. Neither is a constructor-owned measurement field." + nil) (defun ebox-canonical--box-frame-property-p (property) "Return non-nil when PROPERTY belongs to Box geometry or participation." @@ -284,20 +286,18 @@ and author TAG." declarations (lambda (property) (or (funcall predicate property) - (memq (plist-get property :group) - '(paint typography))))) + (eq (plist-get property :group) 'paint)))) (ebox-style-declarations-require-projection-p declarations (lambda (property) (or (funcall predicate property) - (memq (plist-get property :group) - '(paint typography)) + (eq (plist-get property :group) 'paint) (and (memq tag '(flex grid)) (memq tag (plist-get property :contexts))))))))) (setf (nth 1 facts) - (ebox-style-expand-ebox-plist (nth 1 facts))) + (ebox-style--expand-engine-plist (nth 1 facts))) (puthash tag facts by-tag) facts)))) (let ((declarations @@ -316,13 +316,12 @@ and author TAG." do (error "%s does not accept %S" context (plist-get property :name))) (list declarations - (ebox-style-expand-ebox-plist + (ebox-style--expand-engine-plist (ebox-style-declaration-properties declarations (lambda (property) (or (funcall predicate property) - (memq (plist-get property :group) - '(paint typography)))))) + (eq (plist-get property :group) 'paint))))) nil)))) (defun ebox-canonical-facts-from-declarations (tag declarations) @@ -331,7 +330,7 @@ Source declarations stay behind the source handle; the returned value contains only the current canonical-node projection needed before G4c separates paint." (let ((predicate (if (eq tag 'text) - #'ebox-canonical--text-measurement-property-p + #'ebox-canonical--text-engine-property-p #'ebox-canonical--box-frame-property-p))) (pcase-let ((`(,canonical ,engine ,style-required-p) (ebox-canonical--node-properties diff --git a/ebox-dsl.el b/ebox-dsl.el index 22def1c..bbfb3c9 100644 --- a/ebox-dsl.el +++ b/ebox-dsl.el @@ -73,31 +73,49 @@ (when-let* ((entry (ebox-dsl--optional-field properties field context))) (cdr entry))) +(defun ebox-dsl--validate-source-fields (properties context) + "Validate shared source metadata in PROPERTIES for CONTEXT." + (dolist (entry `((:key . ,#'ebox-source-stable-key-p) + (:class . ,#'ebox-source-class-list-p) + (:id . ,#'ebox-source-semantic-id-p))) + (when-let* ((field (car entry)) + (present (ebox-dsl--optional-field properties field context))) + (unless (funcall (cdr entry) (cdr present)) + (error "%s rejected %S value: %S" context field (cdr present))))) + properties) + (defun ebox-dsl--compile-style (tag properties context) "Compile and validate TAG author PROPERTIES for CONTEXT." (condition-case err - (if properties - (ebox-style-compile-form tag properties) - nil) + (let ((declarations + (and properties (ebox-style-compile-form tag properties)))) + (cl-loop + for (property value) + on (ebox-style-declaration-properties + declarations + (lambda (definition) + (eq (plist-get definition :normalizer) 'color))) + by #'cddr + unless (stringp value) + do (error "Standalone Ebox %S requires a CSS color string: %S" + property value)) + declarations) (error (error "%s rejected properties: %s" context (error-message-string err))))) -(defun ebox-dsl--layout-config - (tag declarations extensions context) - "Construct TAG LayoutConfig from DECLARATIONS and EXTENSIONS for CONTEXT." +(defun ebox-dsl--layout-config (tag declarations context) + "Construct TAG LayoutConfig from canonical DECLARATIONS for CONTEXT." (if (eq tag 'text) nil (condition-case err - (ebox-layout-config-for-form - tag - (if (memq tag '(row column)) - extensions - (let ((names (ebox-layout-config-property-names tag))) - (ebox-style-declaration-properties - declarations - (lambda (property) - (memq (plist-get property :name) names)))))) + (let ((names (ebox-layout-config-property-names tag))) + (ebox-layout-config-for-form + tag + (ebox-style-declaration-properties + declarations + (lambda (property) + (memq (plist-get property :name) names))))) (error (error "%s rejected layout config: %s" context (error-message-string err)))))) @@ -114,6 +132,7 @@ function." (let* ((context (format "Ebox %S" tag)) (properties (ebox-dsl--validate-plist properties context))) + (ebox-dsl--validate-source-fields properties context) (dolist (field '(:content :layout :display :ebox-type :ebox-content-node)) (when (plist-member properties field) (error "%s does not accept private author property %S" @@ -124,17 +143,9 @@ function." properties :outer context))) (outer (and (not (eq tag 'text)) (if outer-field (cdr outer-field) 'block))) - (extension-keys (and (memq tag '(row column)) - '(:item-gap :cross-align))) - (extensions - (ebox-dsl--keep-keys - properties extension-keys)) (style-properties (ebox-dsl--without-keys - properties - (append (unless (eq tag 'text) '(:outer)) - extension-keys - ebox-dsl--source-fields))) + properties ebox-dsl--source-fields)) (declarations (ebox-dsl--compile-style tag style-properties context))) @@ -152,7 +163,7 @@ function." :outer outer :layout (ebox-dsl--layout-config - tag declarations extensions context) + tag declarations context) :declarations declarations :owned-facts (ebox-canonical-facts-from-declarations tag declarations))))) diff --git a/ebox-flex.el b/ebox-flex.el index 0e5d012..fa6d9ee 100644 --- a/ebox-flex.el +++ b/ebox-flex.el @@ -102,18 +102,17 @@ "Supported canonical FlexConfig wrapping values.") (defconst ebox--flex-justify-content-values - '(flex-start flex-end center space-between space-around space-evenly - start end left right normal) + '(normal start end flex-start flex-end center stretch + space-between space-around space-evenly) "Supported canonical FlexConfig main-axis alignment values.") (defconst ebox--flex-align-items-values - '(stretch flex-start flex-end center baseline normal - start end self-start self-end) + '(normal start end stretch flex-start flex-end center baseline) "Supported canonical FlexConfig item alignment values.") (defconst ebox--flex-align-content-values - '(stretch flex-start flex-end center space-between space-around space-evenly - baseline normal start end) + '(normal start end stretch flex-start flex-end center + space-between space-around space-evenly) "Supported canonical FlexConfig line alignment values.") (defconst ebox--flex-container-neutral-box-prop-keys @@ -1843,7 +1842,7 @@ a prefix of the eventual full wrapper render." (unless complete (plist-put copy :padding-bottom-height 0) (plist-put copy :margin-bottom-height 0) - (plist-put copy :border-bottom-p nil)) + (plist-put copy :border-bottom-pixel 0)) copy)) (defun ebox--flex-window-complete-prefix-supported-p (box) @@ -1858,7 +1857,7 @@ a prefix of the eventual full wrapper render." line (let ((region-id (ebox--ensure-region-id box))) (puthash region-id box ebox--region-box-table) - (if (ebox-get box :border-bottom-p) + (if (> (ebox-get box :border-bottom-pixel) 0) (propertize (ebox--propertize-underline line (ebox-get box :border-bottom-color)) @@ -1918,7 +1917,7 @@ the chunk begins at the wrapper top and should include top chrome." (border-right-pixel (ebox-get box :border-right-pixel)) (border-right-color (ebox-get box :border-right-color)) (border-top-p (and include-top - (ebox-get box :border-top-p))) + (> (ebox-get box :border-top-pixel) 0))) (border-top-color (ebox-get box :border-top-color)) (color (and (ebox-style--text-paint-owner-p box) (ebox-get box :color))) @@ -2242,7 +2241,7 @@ the chunk begins at the wrapper top and should include top chrome." "Return how many cached prefix tail lines completion rewrites for NODE." (let ((box (ebox--flex-window-box node))) (if (and box - (ebox-get box :border-bottom-p) + (> (ebox-get box :border-bottom-pixel) 0) (= (floor (ebox-get box :padding-bottom-height)) 0)) 1 0))) diff --git a/ebox-font.el b/ebox-font.el new file mode 100644 index 0000000..c3bf99c --- /dev/null +++ b/ebox-font.el @@ -0,0 +1,222 @@ +;;; ebox-font.el --- Surface-resolved font facts -*- lexical-binding: t; -*- + +;;; Commentary: +;; Resolves canonical font inputs against one Emacs display capability and +;; returns a shared immutable fact for measurement and final paint. It does +;; not own author parsing, cascade, layout, TP priority, or buffer publication. + +;;; Code: + +(require 'cl-lib) + +(cl-defstruct (ebox-font-fact + (:constructor ebox-font--make-fact) + (:conc-name ebox-font-fact--)) + "One surface-resolved font projection shared by all downstream consumers." + (requested-decipoints nil :read-only t) + (resolved-font-identity nil :read-only t) + (measurement-metrics nil :read-only t) + (capability-signature nil :read-only t)) + +(defun ebox-font-fact-requested-decipoints (fact) + "Return FACT's requested decipoints scalar." + (ebox-font-fact--requested-decipoints fact)) + +(defun ebox-font-fact-resolved-font-identity (fact) + "Return a detached resolved identity from FACT." + (copy-tree (ebox-font-fact--resolved-font-identity fact) t)) + +(defun ebox-font-fact-measurement-metrics (fact) + "Return detached measurement metrics from FACT." + (copy-tree (ebox-font-fact--measurement-metrics fact) t)) + +(defun ebox-font-fact-capability-signature (fact) + "Return a detached display capability signature from FACT." + (copy-tree (ebox-font-fact--capability-signature fact) t)) + +(defconst ebox-font--cache-max-entries 512 + "Maximum resolved font facts retained across display capabilities.") + +(defvar ebox-font--cache (make-hash-table :test #'equal) + "Cache keyed by canonical font inputs and complete capability signature.") + +(defun ebox-font--requested-decipoints (font-size-px) + "Convert positive FONT-SIZE-PX reference pixels to integer decipoints." + (when font-size-px + (unless (and (numberp font-size-px) (> font-size-px 0)) + (error "Ebox font size must be positive CSS reference pixels: %S" + font-size-px)) + ;; CSS reference px is 1/96 inch. Emacs integer face height is 1/10 point. + ;; px * 72 / 96 * 10 = px * 7.5; positive values round half up. + (floor (+ (* font-size-px 7.5) 0.5)))) + +(defun ebox-font--face + (family decipoints weight style weight-active-p style-active-p) + "Return Emacs face projection for canonical font inputs." + (let (face) + (when family + (setq face + (plist-put face :family + (if (symbolp family) (symbol-name family) family)))) + (when decipoints (setq face (plist-put face :height decipoints))) + (when weight-active-p (setq face (plist-put face :weight weight))) + (when style-active-p (setq face (plist-put face :slant style))) + face)) + +(defun ebox-font--parent-css-weight (fact) + "Return FACT's resolved CSS numeric weight, defaulting to normal." + (or (and (ebox-font-fact-p fact) + (plist-get (ebox-font-fact--resolved-font-identity fact) + :css-weight)) + 400)) + +(defun ebox-font--absolute-css-weight (weight parent-fact) + "Resolve CSS WEIGHT against PARENT-FACT to one absolute numeric weight." + (pcase weight + ((or 'nil 'normal) 400) + ('bold 700) + ('bolder + (let ((parent (ebox-font--parent-css-weight parent-fact))) + (cond ((<= parent 300) 400) + ((<= parent 500) 700) + (t 900)))) + ('lighter + (let ((parent (ebox-font--parent-css-weight parent-fact))) + (cond ((<= parent 500) 100) + ((<= parent 700) 400) + (t 700)))) + ((pred numberp) weight) + (_ (error "Invalid canonical CSS font weight: %S" weight)))) + +(defun ebox-font--emacs-weight (css-weight) + "Map absolute numeric CSS-WEIGHT to an Emacs face weight." + (alist-get css-weight + '((100 . ultra-light) (200 . extra-light) (300 . light) + (400 . normal) (500 . normal) (600 . semi-bold) + (700 . bold) (800 . extra-bold) (900 . ultra-bold)))) + +(defun ebox-font--default-resolver (face window) + "Resolve FACE in WINDOW and return identity plus actual measurement metrics." + (let* ((window (and (window-live-p window) window)) + (probe (propertize "M " 'face face)) + (font (ignore-errors (font-at 0 window probe))) + (name + (and font + (or (ignore-errors (font-get font :name)) + (and (fboundp 'font-xlfd-name) + (ignore-errors (font-xlfd-name font)))))) + (info + (and name + (ignore-errors + (font-info name (and window (window-frame window))))))) + (list + :identity + (if font + (list :type (font-get font :type) + :name name + :family (font-get font :family) + :weight (font-get font :weight) + :slant (font-get font :slant) + :width (font-get font :width) + :size (font-get font :size) + :file (and info (> (length info) 12) (aref info 12))) + (list :unresolved-face (copy-tree face))) + :metrics + (if info + (list :pixel-size (aref info 2) + :height (aref info 3) + :baseline-offset (aref info 4) + :max-width (aref info 7) + :ascent (aref info 8) + :descent (aref info 9) + :space-width (aref info 10) + :average-width (aref info 11)) + (list :probe-width (string-pixel-width probe) + :space-width + (string-pixel-width (propertize " " 'face face))))))) + +(defvar ebox-font--resolver #'ebox-font--default-resolver + "Function resolving an Emacs face into identity and measurement metrics.") + +(cl-defun ebox-font--project + (&key family size weight style weight-active-p style-active-p + capability-signature window parent-fact) + "Return one cached font fact for canonical inputs and display capability." + (let* ((css-weight (ebox-font--absolute-css-weight weight parent-fact)) + (emacs-weight (ebox-font--emacs-weight css-weight)) + (decipoints (ebox-font--requested-decipoints size)) + (face (ebox-font--face + family decipoints emacs-weight style + weight-active-p style-active-p)) + (key (list family size weight css-weight style + weight-active-p style-active-p capability-signature)) + (missing (make-symbol "ebox-font-fact-missing")) + (cached (gethash key ebox-font--cache missing))) + (if (not (eq cached missing)) + cached + (let* ((resolved (funcall ebox-font--resolver face window)) + (fact + (ebox-font--make-fact + :requested-decipoints decipoints + :resolved-font-identity + (list :css-weight css-weight + :face (copy-tree face) + :resource (copy-tree (plist-get resolved :identity))) + :measurement-metrics + (copy-tree (plist-get resolved :metrics)) + :capability-signature (copy-tree capability-signature)))) + (when (>= (hash-table-count ebox-font--cache) + ebox-font--cache-max-entries) + (clrhash ebox-font--cache)) + (puthash (copy-tree key) fact ebox-font--cache) + fact)))) + +(defun ebox-font-project-values + (values specified capability-signature &optional window parent-fact) + "Project canonical font VALUES for one Emacs display capability. +SPECIFIED is the canonical property-id set with declaration winners." + (let* ((values values) + (family (plist-get values 'ebox/font-family)) + (size (plist-get values 'ebox/font-size)) + (weight (plist-get values 'ebox/font-weight)) + (font-style (plist-get values 'ebox/font-style)) + (weight-active-p + (or (not (eq weight 'normal)) + (memq 'ebox/font-weight specified))) + (style-active-p + (or (not (eq font-style 'normal)) + (memq 'ebox/font-style specified)))) + (when (or family size weight-active-p style-active-p) + (ebox-font--project + :family family :size size :weight weight :style font-style + :weight-active-p weight-active-p :style-active-p style-active-p + :capability-signature capability-signature :window window + :parent-fact parent-fact)))) + +(defun ebox-font-clear-cache () + "Clear all surface-resolved font facts." + (clrhash ebox-font--cache)) + +(defun ebox-font-measurement-face (fact) + "Return a detached measurement face from font FACT." + (unless (ebox-font-fact-p fact) + (signal 'wrong-type-argument (list 'ebox-font-fact-p fact))) + (copy-tree + (plist-get (ebox-font-fact--resolved-font-identity fact) :face))) + +(defun ebox-font-paint-face (fact) + "Return a detached final-paint face from the same font FACT." + (unless (ebox-font-fact-p fact) + (signal 'wrong-type-argument (list 'ebox-font-fact-p fact))) + (copy-tree + (plist-get (ebox-font-fact--resolved-font-identity fact) :face))) + +(defun ebox-font-fact-neutral-p (fact) + "Return non-nil when FACT adds no non-default font capability." + (and (ebox-font-fact-p fact) + (null (ebox-font-fact--requested-decipoints fact)) + (null (plist-get + (ebox-font-fact--resolved-font-identity fact) :face)))) + +(provide 'ebox-font) +;;; ebox-font.el ends here diff --git a/ebox-fragment.el b/ebox-fragment.el index ab354b5..9b6266e 100644 --- a/ebox-fragment.el +++ b/ebox-fragment.el @@ -125,14 +125,14 @@ Backend marker and buffer span data are intentionally excluded." signature)) (defconst ebox--paint-style-signature-keys - '(:color :bgcolor - :border-top-p :border-bottom-p + '(:color :bgcolor :visibility + :text-decoration-line :text-decoration-color :text-decoration-style :border-left-color :border-right-color :border-top-color :border-bottom-color) "Box properties that affect paint without changing layout geometry.") (defconst ebox--typography-style-signature-keys - '(:font :font-family :font-height :font-weight :font-slant) + '(:ebox-font-fact) "Typography properties whose resolved metrics may change geometry.") (defconst ebox--geometry-style-signature-keys @@ -145,7 +145,10 @@ Backend marker and buffer span data are intentionally excluded." :padding-top-height :padding-bottom-height :margin-left-pixel :margin-right-pixel :margin-top-height :margin-bottom-height - :border-left-pixel :border-right-pixel + :border-left-pixel :border-left-style + :border-right-pixel :border-right-style + :border-top-pixel :border-top-style + :border-bottom-pixel :border-bottom-style :text-align :vertical-align :overflow :wrap-mode :flex-props :flex-participation)) diff --git a/ebox-grid.el b/ebox-grid.el index 5ee42d6..9c071f5 100644 --- a/ebox-grid.el +++ b/ebox-grid.el @@ -18,6 +18,7 @@ (declare-function ebox--ensure-node-id "ebox" (node)) (declare-function ebox--ensure-region-id "ebox" (box)) +(declare-function ebox-box-node-p "ebox-canonical" (node)) (declare-function ebox-get "ebox" (box property)) (declare-function ebox-node-factory--create-from-properties "ebox-node-factory" (&rest plist)) @@ -26,6 +27,10 @@ (declare-function ebox-string-lines "ebox" (string)) (declare-function ebox-string-height "ebox" (string)) (declare-function ebox-lines-join "ebox" (lines)) +(declare-function ebox--side-pixel "ebox-layout" (box &optional side)) +(declare-function ebox--content-min-pixel "ebox-layout" (box)) +(declare-function ebox--content-max-pixel "ebox-layout" (box)) +(declare-function ebox--line-min-content-pixel "ebox-layout" (line)) (declare-function ebox--lines-align-vertical "ebox-layout" (string height align)) (declare-function ebox--flex-spacing "ebox-flex" (mode leftover count base-gap)) (declare-function ebox--render-with-cache "ebox-incremental" (node &optional force cache-probe)) @@ -34,7 +39,7 @@ (defconst ebox--grid-layout-prop-keys '(:grid-template-columns :grid-template-rows :grid-auto-columns :grid-auto-rows :grid-auto-flow - :column-gap :row-gap :grid-column-gap :grid-row-gap :gap + :column-gap :row-gap :gap :justify-items :align-items :justify-content :align-content) "Properties owned by the grid formatting context.") @@ -46,20 +51,20 @@ "Canonical property order stored in a typed GridConfig.") (defconst ebox--grid-justify-items-values - '(stretch normal start end left right center) + '(stretch start end center) "Supported canonical GridConfig inline item alignment values.") (defconst ebox--grid-align-items-values - '(stretch normal start end top bottom center) + '(normal start end stretch flex-start flex-end center baseline) "Supported canonical GridConfig block item alignment values.") (defconst ebox--grid-content-alignment-values - '(stretch normal start end left right center - flex-start flex-end space-between space-around space-evenly) + '(normal start end stretch flex-start flex-end center + space-between space-around space-evenly) "Supported canonical GridConfig content alignment values.") (defconst ebox--grid-item-prop-keys - '(:grid-column :grid-row :grid-column-span :grid-row-span) + '(:grid-column :grid-row :justify-self) "Properties that describe a child's position in a grid.") (defun ebox-grid--split-attrs (items) @@ -99,7 +104,17 @@ (unless (and (null (cdddr track)) (integerp count) (> count 0) value) (error "ebox-grid: invalid repeat track %S" track)) - (make-list count value)) + (let ((body + (cond + ((or (ebox-grid--track-form-p value) + (numberp value) (symbolp value) + (and (consp value) (numberp (car value)) + (null (cdr value)))) + (list value)) + ((proper-list-p value) value) + (t (error "ebox-grid: invalid repeat body %S" value))))) + (cl-loop repeat count + append (ebox-grid--expand-repeat body)))) (list track))) tracks)) @@ -110,14 +125,8 @@ value)) (cond ((eq value 'auto) '(:kind auto :factor 0)) - ((and (symbolp value) - (string-match "\\`\\([0-9]+\\(?:\\.[0-9]+\\)?\\)fr\\'" - (symbol-name value))) - (let ((factor - (string-to-number (match-string 1 (symbol-name value))))) - (unless (> factor 0) - (error "ebox-grid: fractional track must be positive: %S" value)) - (list :kind 'fr :factor factor))) + ((memq value '(min-content max-content)) + (list :kind value)) ((and (consp value) (eq (car value) 'fr)) (unless (and (numberp (cadr value)) (> (cadr value) 0)) (error "ebox-grid: invalid fractional track %S" value)) @@ -129,9 +138,11 @@ (error "ebox-grid: invalid minmax track %S" value)) (let ((minimum (ebox-grid--normalize-track (cadr value) axis)) (maximum (ebox-grid--normalize-track (caddr value) axis))) - (unless (memq (plist-get minimum :kind) '(fixed auto)) + (unless (memq (plist-get minimum :kind) + '(fixed auto min-content max-content)) (error "ebox-grid: minmax minimum must be fixed or auto: %S" value)) - (unless (memq (plist-get maximum :kind) '(fixed auto fr)) + (unless (memq (plist-get maximum :kind) + '(fixed auto min-content max-content fr)) (error "ebox-grid: minmax maximum cannot be nested: %S" value)) (when (and (eq (plist-get maximum :kind) 'fixed) (eq (plist-get minimum :kind) 'fixed) @@ -164,14 +175,10 @@ (let* ((pair (ebox--flex-gap-pair (plist-get props :gap))) (row (cond ((plist-member props :row-gap) (plist-get props :row-gap)) - ((plist-member props :grid-row-gap) - (plist-get props :grid-row-gap)) (t (car pair)))) (column (cond ((plist-member props :column-gap) (plist-get props :column-gap)) - ((plist-member props :grid-column-gap) - (plist-get props :grid-column-gap)) (t (cdr pair))))) (cons (or (ebox--flex-line-value row 0) 0) (or (ebox--nonnegative-horizontal-size-pixels column 0) 0)))) @@ -192,6 +199,13 @@ (defun ebox-grid--normalize-config-props (props) "Normalize GridConfig PROPS once to canonical layout-owned facts." + (dolist (property '(:grid-template-columns :grid-template-rows)) + (when-let* ((value (plist-get props property))) + (unless (and (proper-list-p value) + value + (not (ebox-grid--track-form-p value))) + (error "Ebox GridConfig %S requires a nonempty track list: %S" + property value)))) (let ((gaps (ebox-grid--gap-pair props))) (list :grid-template-columns @@ -221,11 +235,11 @@ ebox--grid-align-items-values :align-items) :justify-content (ebox-grid--enum-value - (plist-get props :justify-content) 'start + (plist-get props :justify-content) 'flex-start ebox--grid-content-alignment-values :justify-content) :align-content (ebox-grid--enum-value - (plist-get props :align-content) 'start + (plist-get props :align-content) 'stretch ebox--grid-content-alignment-values :align-content)))) (defconst ebox--grid-default-config-props @@ -238,6 +252,8 @@ (memq (plist-get track :kind) allowed-kinds) (pcase (plist-get track :kind) ('auto (equal track '(:kind auto :factor 0))) + ('min-content (equal track '(:kind min-content))) + ('max-content (equal track '(:kind max-content))) ('fixed (let ((size (plist-get track :size))) (and (numberp size) (>= size 0) @@ -250,13 +266,15 @@ (defun ebox-grid--canonical-track-p (track) "Return non-nil when TRACK is one canonical top-level track descriptor." - (or (ebox-grid--canonical-atomic-track-p track '(fixed auto fr)) + (or (ebox-grid--canonical-atomic-track-p + track '(fixed auto min-content max-content fr)) (and (proper-list-p track) (eq (plist-get track :kind) 'minmax) (ebox-grid--canonical-atomic-track-p - (plist-get track :min) '(fixed auto)) + (plist-get track :min) '(fixed auto min-content max-content)) (ebox-grid--canonical-atomic-track-p - (plist-get track :max) '(fixed auto fr)) + (plist-get track :max) + '(fixed auto min-content max-content fr)) (equal track (list :kind 'minmax :min (plist-get track :min) @@ -313,8 +331,8 @@ ;;;###autoload (defun ebox-grid-layout-create (&rest plist) "Return a typed GridConfig from canonical evaluated PLIST. -Author `:gap' sugar and legacy grid-gap aliases are normalized before this -programmatic port and are therefore rejected here." +Author `:gap' sugar is expanded before this programmatic port. Removed +grid-gap aliases are rejected at the author schema boundary." (ebox-grid--validate-config-input plist) (let ((props (ebox-grid--normalize-config-props plist))) (unless (ebox-grid-layout-config-props-p props) @@ -345,6 +363,11 @@ programmatic port and are therefore rejected here." (if (> value 0) (list value 1) (error "ebox-grid: placement starts at one: %S" value))) + ((and (proper-list-p value) + (eq (car value) :span) + (integerp (cadr value)) + (null (cddr value))) + (list default (cadr value))) ((and (consp value) (numberp (car value))) (let ((start (car value)) (tail (cdr value))) @@ -364,8 +387,8 @@ programmatic port and are therefore rejected here." (let* ((row (ebox-grid--placement-part (plist-get child :grid-row) 0)) (column (ebox-grid--placement-part (plist-get child :grid-column) 0)) - (row-span (or (plist-get child :grid-row-span) (cadr row))) - (column-span (or (plist-get child :grid-column-span) (cadr column)))) + (row-span (cadr row)) + (column-span (cadr column))) (dolist (part (list row column)) (when (or (not (integerp (car part))) (< (car part) 0) (not (integerp (cadr part))) (< (cadr part) 1)) @@ -491,6 +514,29 @@ programmatic port and are therefore rejected here." (list (nreverse entries) matrix (length matrix) (length (aref matrix 0))))) +(defun ebox-grid--string-min-content-pixel (string) + "Return STRING's widest unbreakable run in pixels." + (let ((best 0)) + (dolist (line (ebox-string-lines string) best) + (setq best (max best (ebox--line-min-content-pixel line)))))) + +(defun ebox-grid--entry-intrinsic-size (entry rendered axis mode) + "Return ENTRY's intrinsic size from RENDERED for AXIS and MODE." + (let* ((node (plist-get entry :node)) + (string (gethash node rendered))) + (if (eq axis 'rows) + (ebox-string-height string) + (pcase mode + ('min-content + (if (ebox-box-node-p node) + (+ (ebox--side-pixel node) (ebox--content-min-pixel node)) + (ebox-grid--string-min-content-pixel string))) + ('max-content + (if (ebox-box-node-p node) + (+ (ebox--side-pixel node) (ebox--content-max-pixel node)) + (ebox--string-max-pixel-width string))) + (_ (ebox--string-pixel-width string)))))) + (defun ebox-grid--track-intrinsic (track axis entries rendered key slot-index) "Return intrinsic size for TRACK from RENDERED grid ENTRIES." (pcase (plist-get track :kind) @@ -500,6 +546,7 @@ programmatic port and are therefore rejected here." (ebox-grid--track-intrinsic (plist-get track :min) axis entries rendered key slot-index)) (_ (let ((position (1+ slot-index)) + (mode (plist-get track :kind)) (best 0)) (dolist (entry entries best) (when (and (<= (plist-get entry key) position) @@ -509,11 +556,9 @@ programmatic port and are therefore rejected here." (if (eq key :column) :column-span :row-span))))) - (let* ((node (plist-get entry :node)) - (string (gethash node rendered)) - (value (if (eq axis 'columns) - (ebox--string-pixel-width string) - (ebox-string-height string)))) + (let ((value + (ebox-grid--entry-intrinsic-size + entry rendered axis mode))) (setq best (max best value))))))))) (defun ebox-grid--sum (values) diff --git a/ebox-incremental.el b/ebox-incremental.el index 5d98e5f..4632f05 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -6598,12 +6598,10 @@ recognized from its first untouched sibling without walking the whole tree." (defun ebox-incremental--declaration-changed-keys (old new) "Return engine longhands changed between canonical OLD and NEW declarations." - (let ((all-properties (lambda (_property) t))) - (ebox-incremental--plist-changed-keys - (ebox-style-expand-ebox-plist - (ebox-style-declaration-properties old all-properties)) - (ebox-style-expand-ebox-plist - (ebox-style-declaration-properties new all-properties))))) + (delete-dups + (cl-mapcan + #'ebox-style--engine-projection-targets + (ebox-incremental--plist-changed-keys old new)))) (defun ebox-incremental--candidate-source-record (source-index node) "Return NODE's borrowed immutable record from candidate SOURCE-INDEX. @@ -9507,8 +9505,8 @@ role, and outside-complement compatibility before publication." (or (eq key :content) (eq key :surface-properties) (memq key ebox--paint-style-signature-keys) - (memq key '(:font :font-family :font-height - :font-weight :font-slant)))) + (memq key '(:font-family :font-size + :font-weight :font-style)))) (plist-get entry :changed-keys)))) dirty-set) allocation-stable-p diff --git a/ebox-layout-config.el b/ebox-layout-config.el index 2a12fce..c6d80c6 100644 --- a/ebox-layout-config.el +++ b/ebox-layout-config.el @@ -124,7 +124,8 @@ PROPS must already use the variant's normalized representation." :grid-auto-columns :grid-auto-rows :grid-auto-flow :row-gap :column-gap :justify-items :align-items :justify-content :align-content)) - ((or 'box 'row 'column) nil) + ('box nil) + ((or 'row 'column) '(:item-gap :cross-align)) (_ (error "Unknown Ebox Box author form: %S" tag)))) (defun ebox-layout-config-for-form (tag properties) diff --git a/ebox-layout.el b/ebox-layout.el index c5b78fe..71b27d5 100644 --- a/ebox-layout.el +++ b/ebox-layout.el @@ -58,8 +58,12 @@ "ebox-buffer-backend" (string property region-id)) (declare-function ebox--propertize-typography "ebox-buffer-backend" (string style)) +(declare-function ebox--propertize-text-decoration + "ebox-buffer-backend" (string style)) (declare-function ebox--propertize-underline "ebox-buffer-backend" (string &optional color)) +(declare-function ebox-buffer-validate-border-capability + "ebox-buffer-backend" (style)) (defvar ebox--render-runtime-revision nil "Current buffer runtime revision for render-local cache validation.") @@ -230,8 +234,8 @@ the generic box pipeline." (equal (ebox-get box :margin-bottom-height) 0) (equal (ebox-get box :border-left-pixel) 0) (equal (ebox-get box :border-right-pixel) 0) - (not (ebox-get box :border-top-p)) - (not (ebox-get box :border-bottom-p)) + (= (ebox-get box :border-top-pixel) 0) + (= (ebox-get box :border-bottom-pixel) 0) (null (and (ebox-style--text-paint-owner-p box) (ebox-get box :color))) (null (ebox-get box :bgcolor))) @@ -296,8 +300,8 @@ and its formatted content exactly fills the used height." (= (floor (ebox-get box :padding-bottom-height)) 0) (= (ebox-get box :border-left-pixel) 0) (= (ebox-get box :border-right-pixel) 0) - (not (ebox-get box :border-top-p)) - (not (ebox-get box :border-bottom-p))))) + (= (ebox-get box :border-top-pixel) 0) + (= (ebox-get box :border-bottom-pixel) 0)))) (when (and lines-fit-p (or (= text-height content-height) underfilled-simple-scroll-p)) @@ -1203,7 +1207,8 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." ;; typography contribution around already-rendered descendants. (if (eq (plist-get box :ebox-kind) 'box) content - (ebox--propertize-typography content box)))) + (ebox--propertize-text-decoration + (ebox--propertize-typography content box) box)))) (defun ebox--intrinsic-box-content (box) "Return BOX content materialized independently of its used width. @@ -1556,9 +1561,9 @@ Internal implementation of `ebox-render' for box nodes." (border-left-color (ebox-get box :border-left-color)) (border-right-pixel (ebox-get box :border-right-pixel)) (border-right-color (ebox-get box :border-right-color)) - (border-top-p (ebox-get box :border-top-p)) + (border-top-p (> (ebox-get box :border-top-pixel) 0)) (border-top-color (ebox-get box :border-top-color)) - (border-bottom-p (ebox-get box :border-bottom-p)) + (border-bottom-p (> (ebox-get box :border-bottom-pixel) 0)) (border-bottom-color (ebox-get box :border-bottom-color)) ;; Style and Layout @@ -1887,7 +1892,9 @@ Internal implementation of `ebox-render' for box nodes." (let* ((region-id (or ebox--render-region-id (ebox--ensure-region-id node))) (value (copy-sequence (plist-get node :ebox-text-value))) - (styled (ebox--propertize-typography value node)) + (styled + (ebox--propertize-text-decoration + (ebox--propertize-typography value node) node)) (painted (if (or (ebox-get node :color) (ebox-get node :bgcolor)) (ebox--propertize-colors @@ -1921,7 +1928,8 @@ This function owns spatial layout only; it never creates a live TP surface." (or ebox--box-content-render-cache (make-hash-table :test 'eq)))) (when (listp node) - (ebox--ensure-node-id node)) + (ebox--ensure-node-id node) + (ebox-buffer-validate-border-capability node)) (let ((type (plist-get node :ebox-type))) (cond ((eq (plist-get node :ebox-kind) 'text) @@ -2815,15 +2823,12 @@ last cached leaf instead of walking the stack from the beginning." :margin-left-pixel :margin-right-pixel :margin-top-height :margin-bottom-height :border-left-pixel :border-right-pixel)) - (not (ebox-get node :border-top-p)) - (not (ebox-get node :border-bottom-p)) + (= (ebox-get node :border-top-pixel) 0) + (= (ebox-get node :border-bottom-pixel) 0) (null (ebox-get node :color)) (null (ebox-get node :bgcolor)) - (null (ebox-get node :font)) - (null (ebox-get node :font-family)) - (null (ebox-get node :font-height)) - (null (ebox-get node :font-weight)) - (null (ebox-get node :font-slant)) + (or (null (plist-get node :ebox-font-fact)) + (ebox-font-fact-neutral-p (plist-get node :ebox-font-fact))) (null (ebox-get node :surface-properties)) (eq (ebox-get node :visibility) 'visible) (eq (ebox-get node :text-align) 'left) @@ -2951,10 +2956,11 @@ no included vertical margin." 0)) (border-left-pixel (ebox-get box :border-left-pixel)) (border-right-pixel (ebox-get box :border-right-pixel)) - (border-top-p (and include-top (ebox-get box :border-top-p))) + (border-top-p + (and include-top (> (ebox-get box :border-top-pixel) 0))) (border-top-color (ebox-get box :border-top-color)) (border-bottom-p - (and include-bottom (ebox-get box :border-bottom-p))) + (and include-bottom (> (ebox-get box :border-bottom-pixel) 0))) (border-bottom-color (ebox-get box :border-bottom-color)) (color (and (ebox-style--text-paint-owner-p box) (ebox-get box :color))) @@ -3128,7 +3134,7 @@ only used for incomplete lazy prefixes." (border-right-pixel (ebox-get box :border-right-pixel)) (border-right-color (ebox-get box :border-right-color)) (border-top-p (and include-top - (ebox-get box :border-top-p))) + (> (ebox-get box :border-top-pixel) 0))) (border-top-color (ebox-get box :border-top-color)) (color (and (ebox-style--text-paint-owner-p box) (ebox-get box :color))) diff --git a/ebox-measure.el b/ebox-measure.el index 0451b6d..675a94a 100644 --- a/ebox-measure.el +++ b/ebox-measure.el @@ -8,6 +8,7 @@ (require 'cl-lib) (require 'ebox-cache) +(require 'ebox-font) (declare-function ebox--register-render-owned-text-value "ebox-render-context" (property value)) @@ -251,21 +252,29 @@ switching those layers must not invalidate Ebox measurement caches." This is the public, text-scale-aware measurement used by Ebox layout code." (ebox--string-pixel-width string)) -(defun ebox--display-signature () - "Return the current display signature for pixel measurement caches." - (list (frame-parameter nil 'font) - (frame-parameter nil 'font-backend) - (frame-parameter nil 'line-spacing) - (frame-parameter nil 'internal-border-width) - (face-attribute 'default :family nil t) - (face-attribute 'default :height nil t) - (face-attribute 'default :weight nil t) - (face-attribute 'default :slant nil t) - (face-attribute 'default :width nil t) - (face-attribute 'default :foreground nil t) - (face-attribute 'default :background nil t) +(defun ebox--display-signature-for-frame (frame) + "Return the pixel-measurement capability signature for FRAME." + (list frame + (frame-parameter frame 'font) + (frame-parameter frame 'font-backend) + (frame-parameter frame 'line-spacing) + (face-attribute 'default :family frame t) + (face-attribute 'default :height frame t) + (face-attribute 'default :weight frame t) + (face-attribute 'default :slant frame t) + (face-attribute 'default :width frame t) (ebox--measurement-face-remapping) - (and (boundp 'text-scale-mode-amount) text-scale-mode-amount))) + (and (boundp 'text-scale-mode-amount) text-scale-mode-amount) + (and (boundp 'text-scale-mode-step) text-scale-mode-step))) + +(defun ebox--display-signature-for-window (window) + "Return the pixel-measurement capability signature for live WINDOW." + (ebox--display-signature-for-frame + (if (window-live-p window) (window-frame window) (selected-frame)))) + +(defun ebox--display-signature () + "Return the selected frame's pixel-measurement capability signature." + (ebox--display-signature-for-frame (selected-frame))) (defsubst ebox--current-display-signature () "Return the render snapshot or sample the current display signature." @@ -336,6 +345,7 @@ Nested uses reuse the outer validation." "Clear pixel width caches after font or named face metrics change." (clrhash ebox--char-width-cache) (ebox--clear-string-pixel-width-cache) + (ebox-font-clear-cache) (setq ebox--space-pixel-cache nil) (setq ebox--face-height-width-cache nil) (setq ebox--display-signature-cache (ebox--display-signature))) diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 34a0e9a..4cda61e 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -53,6 +53,8 @@ (declare-function ebox-layout-config-props "ebox-layout-config" (config)) (declare-function ebox-tree-node-children "ebox-tree" (node)) (declare-function ebox-buffer--font-face "ebox-buffer-backend" (style)) +(declare-function ebox--propertize-typography + "ebox-buffer-backend" (string style)) (declare-function ebox--ensure-node-id "ebox" (node)) (declare-function ebox--buffer-render-state "ebox-incremental" (buffer)) (declare-function ebox--ensure-region-id "ebox" (box)) @@ -77,7 +79,11 @@ (declare-function ebox-pixel-space "ebox-measure" (pixel-width)) (declare-function ebox-string-lines "ebox" (string)) (declare-function ebox-string-pixel-width "ebox-measure" (string)) -(declare-function ebox-display-signature "ebox-measure" ()) +(declare-function ebox--current-display-signature "ebox-measure" ()) +(declare-function ebox--display-signature-for-window + "ebox-measure" (window)) +(declare-function ebox-surface--buffer-display-window + "ebox-surface" (buffer)) (declare-function tp-property-value-copy "tp-core" (value)) (declare-function tp-paint-slot-p "tp-style" (value)) (declare-function tp-paint-slot-face "tp-style" (slot)) @@ -1640,8 +1646,8 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame." :margin-left-pixel :margin-right-pixel :margin-top-height :margin-bottom-height :border-left-pixel :border-right-pixel)) - (not (ebox-get box :border-top-p)) - (not (ebox-get box :border-bottom-p)) + (= (ebox-get box :border-top-pixel) 0) + (= (ebox-get box :border-bottom-pixel) 0) (null (ebox-get box :color)) (null (ebox-get box :bgcolor)))) @@ -1743,9 +1749,16 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes." (defun ebox-native-reflow--compile-text-node (node) "Compile canonical Text NODE to the lightweight native Text IR." (let* ((region-id (ebox--ensure-region-id node)) + (content + (ebox--propertize-typography + (or (plist-get node :ebox-text-value) + (ebox-get node :content) + "") + node)) (typography-style (ebox-native-reflow--compile-add-face-style - (ebox-buffer--font-face node))) + (append (ebox-buffer--font-face node) + (ebox-buffer--text-decoration-face node)))) (foreground-style (ebox-native-reflow--compile-add-face-style (ebox-native-reflow--compile-foreground-face @@ -1757,10 +1770,7 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes." (list :type "text" :region-id region-id :content - (ebox-native-reflow--compile-text - (or (plist-get node :ebox-text-value) - (ebox-get node :content) - "")) + (ebox-native-reflow--compile-text content) :typography-style (or typography-style :null) :foreground-style (or foreground-style :null) :surface-template-id (or surface-template-id :null) @@ -1800,6 +1810,14 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes." (margin-bottom (floor (or (ebox-get box :margin-bottom-height) 0))) (border-left (floor (or (ebox-get box :border-left-pixel) 0))) (border-right (floor (or (ebox-get box :border-right-pixel) 0))) + (border-top (floor (or (ebox-get box :border-top-pixel) 0))) + (border-bottom (floor (or (ebox-get box :border-bottom-pixel) 0))) + (_border-capability + (when (or (> border-top 1) (> border-bottom 1)) + (signal 'ebox-surface-capability-error + (list :border '(top bottom) + :declared-pixels (max border-top border-bottom) + :supported-pixels '(0 1))))) (foreground-style (ebox-native-reflow--compile-add-face-style (ebox-native-reflow--compile-foreground-face @@ -1808,7 +1826,8 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes." (typography-style (and (ebox-style--text-paint-owner-p box) (ebox-native-reflow--compile-add-face-style - (ebox-buffer--font-face box)))) + (append (ebox-buffer--font-face box) + (ebox-buffer--text-decoration-face box))))) (background-style (ebox-native-reflow--compile-add-face-style (let ((background (ebox-get box :bgcolor))) @@ -1823,14 +1842,14 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes." (ebox-native-reflow--compile-border-face-style border-right (ebox-get box :border-right-color))) (border-top-style - (and (ebox-get box :border-top-p) + (and (> border-top 0) (ebox-native-reflow--compile-add-face-style (let ((color (ebox-get box :border-top-color))) (if (tp-paint-slot-p color) (tp-paint-slot-face color) (list :overline (or color t))))))) (border-bottom-style - (and (ebox-get box :border-bottom-p) + (and (> border-bottom 0) (ebox-native-reflow--compile-add-face-style (let ((color (ebox-get box :border-bottom-color))) (if (tp-paint-slot-p color) @@ -2220,6 +2239,12 @@ PROPERTY-TEMPLATES are Emacs-owned plists addressed by opaque native ids." :styles (vconcat styles) :property-templates (vconcat property-templates))) +(defun ebox-native-reflow--buffer-display-signature (buffer) + "Return BUFFER's live canonical Surface display capability." + (with-current-buffer buffer + (ebox--display-signature-for-window + (ebox-surface--buffer-display-window buffer)))) + (defun ebox-native-reflow-capture-layout-scene (buffer) "Capture BUFFER's render-owned flat native scene without walking its tree." (unless (buffer-live-p buffer) @@ -2238,7 +2263,7 @@ PROPERTY-TEMPLATES are Emacs-owned plists addressed by opaque native ids." :node-postorder postorder :flex-content-min-widths (plist-get state :flex-content-min-widths) - :display-signature (ebox-display-signature) + :display-signature (plist-get state :display-signature) :runtime-revision (or (plist-get state :runtime-revision) 0))))) (defun ebox-native-reflow-layout-scene-new-builder (scene) @@ -2264,10 +2289,8 @@ PROPERTY-TEMPLATES are Emacs-owned plists addressed by opaque native ids." (and (buffer-live-p buffer) (eq (ebox--buffer-render-state buffer) (ebox-native-reflow-layout-scene-render-state scene)) - (with-current-buffer buffer - (equal (ebox-display-signature) - (ebox-native-reflow-layout-scene-display-signature - scene)))))) + (equal (ebox-native-reflow--buffer-display-signature buffer) + (ebox-native-reflow-layout-scene-display-signature scene))))) (defun ebox-native-reflow-layout-builder-step (builder budget-seconds) "Advance flat layout BUILDER for at most BUDGET-SECONDS. @@ -2359,7 +2382,7 @@ per call, and no call recursively visits the captured Ebox tree." (ebox-native-reflow--compile-property-template-ids (make-hash-table :test 'equal)) (ebox-native-reflow--compile-display-signature - (ebox-display-signature)) + (ebox--current-display-signature)) (ebox-native-reflow--compile-root-node node)) (cl-loop for template in ebox-native-reflow--compile-property-templates @@ -2457,7 +2480,7 @@ per call, and no call recursively visits the captured Ebox tree." (ebox-native-reflow--compile-property-template-ids (make-hash-table :test 'equal)) (ebox-native-reflow--compile-display-signature - (ebox-display-signature)) + (ebox--current-display-signature)) (ebox-native-reflow--compile-root-node node) (ebox--render-string-pixel-width-cache (make-hash-table :test 'equal)) @@ -4353,8 +4376,7 @@ creating and releasing a session for this preparation." (or layout-package (ebox-native-reflow--compile-layout-package root))) (source-revision (or (plist-get state :runtime-revision) 0)) - (display-signature - (with-current-buffer buffer (ebox-display-signature))) + (display-signature (plist-get state :display-signature)) (context-hash (sxhash-equal (list display-signature kind region-id @@ -4505,11 +4527,10 @@ When FULL-P is non-nil, query the complete restoration job." (equal (plist-get state :runtime-revision) (ebox-native-reflow-preparation-expected-revision preparation)) - (with-current-buffer buffer - (equal - (ebox-display-signature) - (ebox-native-reflow-preparation-display-signature - preparation)))) + (equal + (ebox-native-reflow--buffer-display-signature buffer) + (ebox-native-reflow-preparation-display-signature + preparation))) (error "Preview runtime changed during native reflow preparation")) state)) diff --git a/ebox-node-factory.el b/ebox-node-factory.el index b78116b..1dac85b 100644 --- a/ebox-node-factory.el +++ b/ebox-node-factory.el @@ -18,18 +18,32 @@ :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 + :border-left-pixel 0 :border-left-style none :border-left-color nil + :border-right-pixel 0 :border-right-style none :border-right-color nil + :border-top-pixel 0 :border-top-style none :border-top-color nil + :border-bottom-pixel 0 :border-bottom-style none :border-bottom-color nil :color nil :bgcolor nil - :font nil :font-family nil :font-height nil - :font-weight nil :font-slant nil + :text-decoration-line none :text-decoration-color currentColor + :text-decoration-style solid :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.") +(defconst ebox-node-factory--style-projection-keys + (let ((fields ebox--longhand) + keys) + (while fields + (let ((key (pop fields))) + (pop fields) + (unless (memq key '(:content :scroll-offset)) + (push key keys)))) + (nreverse keys)) + "Private runtime fields materialized from canonical style facts. +These fields are projections, never declarative source authority. Content and +scroll offset remain outside this set because their lifecycle is not owned by +style projection.") + (defconst ebox-node-factory--text-longhand '(:content "" :overflow visible @@ -44,7 +58,10 @@ :padding-top-height 0 :padding-bottom-height 0 :margin-left-pixel 0 :margin-right-pixel 0 :margin-top-height 0 :margin-bottom-height 0 - :border-left-pixel 0 :border-right-pixel 0 + :border-left-pixel 0 :border-left-style none + :border-right-pixel 0 :border-right-style none + :border-top-pixel 0 :border-top-style none + :border-bottom-pixel 0 :border-bottom-style none :text-align left :vertical-align top :overflow scroll :visibility visible :wrap-mode word :scroll-offset 0) @@ -87,7 +104,7 @@ leaving them absent would make the canonical node violate the Box contract.") (defun ebox-node-factory--create-from-properties (&rest plist) "Create one private runtime box from evaluated engine PLIST." (ebox-node-factory--create-expanded - (ebox-style-expand-ebox-plist plist))) + (ebox-style--expand-engine-plist plist))) (provide 'ebox-node-factory) diff --git a/ebox-source.el b/ebox-source.el index 78e5964..4ca8c3a 100644 --- a/ebox-source.el +++ b/ebox-source.el @@ -216,6 +216,23 @@ Functions and records are opaque values and retain identity." copy)) (t value))) +(defun ebox-source-stable-key-p (value) + "Return non-nil when VALUE is one public stable source key." + (and value (or (symbolp value) (stringp value) (integerp value)))) + +(defun ebox-source-semantic-id-p (value) + "Return non-nil when VALUE is one public semantic id." + (and value (or (symbolp value) (stringp value) (integerp value)))) + +(defun ebox-source-class-list-p (value) + "Return non-nil when VALUE is a public class token or proper token list." + (or (symbolp value) + (stringp value) + (and (proper-list-p value) + (cl-every (lambda (token) + (or (symbolp token) (stringp token))) + value)))) + (defun ebox-source--metadata-string (value) "Return VALUE normalized to a selector metadata string." (cond @@ -299,6 +316,15 @@ Functions and records are opaque values and retain identity." "Bind normalized source facts in BUILDER and return an opaque handle. The returned handle contains identity only; all facts are owned by BUILDER until `ebox-source-builder-finish' seals them into a source index." + (when (and key (not (ebox-source-stable-key-p key))) + (error "Ebox source key must be a non-nil symbol, string, or integer: %S" + key)) + (when (and id (not (ebox-source-semantic-id-p id))) + (error "Ebox source id must be a non-nil symbol, string, or integer: %S" + id)) + (unless (ebox-source-class-list-p class) + (error "Ebox source class must be a token or proper token list: %S" + class)) (let ((handle (ebox-source--make-handle :identity (ebox-source--copy-data diff --git a/ebox-style.el b/ebox-style.el index 6a11c6b..2c32f41 100644 --- a/ebox-style.el +++ b/ebox-style.el @@ -36,7 +36,7 @@ (ecss-stylesheet-create)) (defconst ebox-style--border-styles - '(none hidden dotted dashed solid double groove ridge inset outset) + '(none solid) "Valid CSS border-style keywords accepted by Ebox style parsing.") (defconst ebox-style--default-foreground 'ebox/default-foreground @@ -44,227 +44,317 @@ (defconst ebox-style--property-definitions '((:name :color :id ebox/color :initial nil :inherits t - :contexts (box) :group paint :dirty-kind paint :signature paint - :normalizer color) + :contexts (text box) :group paint :dirty-kind paint :signature paint + :normalizer color :validator color) (:name :background-color :id ebox/background-color :aliases (:bgcolor) - :initial nil :contexts (box) :group paint :dirty-kind paint - :signature paint :normalizer color) - (:name :font :id ebox/font :initial nil :inherits t :contexts (box) - :group typography :dirty-kind geometry :signature layout - :validator font) + :initial nil :contexts (text box) :group paint :dirty-kind paint + :signature paint :normalizer color :validator color) (:name :font-family :id ebox/font-family :initial nil :inherits t - :contexts (box) :group typography :dirty-kind geometry :signature layout + :contexts (text box) :group typography :dirty-kind geometry :signature layout :validator font-family) - (:name :font-height :id ebox/font-height :aliases (:font-size) - :initial nil :inherits t :contexts (box) :group typography - :dirty-kind geometry :signature layout :validator font-height) - (:name :font-weight :id ebox/font-weight :initial nil :inherits t - :contexts (box) :group typography :dirty-kind geometry :signature layout - :validator font-attribute) - (:name :font-slant :id ebox/font-slant :initial nil :inherits t - :contexts (box) :group typography :dirty-kind geometry :signature layout - :validator font-attribute) + (:name :font-size :id ebox/font-size + :initial nil :inherits t :contexts (text box) :group typography + :dirty-kind geometry :signature layout :validator font-size) + (:name :font-weight :id ebox/font-weight :initial normal :inherits t + :contexts (text box) :group typography :dirty-kind geometry :signature layout + :validator font-weight) + (:name :font-style :id ebox/font-style :aliases (:font-slant) + :initial normal :inherits t + :contexts (text box) :group typography :dirty-kind geometry :signature layout + :validator font-style) + (:name :text-decoration-line :id ebox/text-decoration-line :initial none + :contexts (text box) :group paint :dirty-kind paint :signature paint + :validator text-decoration-line) + (:name :text-decoration-color :id ebox/text-decoration-color + :initial currentColor :contexts (text box) :group paint :dirty-kind paint + :signature paint :normalizer color :validator color) + (:name :text-decoration-style :id ebox/text-decoration-style :initial solid + :contexts (text box) :group paint :dirty-kind paint :signature paint + :validator text-decoration-style) (:name :box-sizing :id ebox/box-sizing :initial border-box - :contexts (box) :group geometry :dirty-kind geometry :signature layout) - (:name :width :id ebox/width :initial nil :contexts (box flex grid) + :contexts (box row column flex grid) :group geometry + :dirty-kind geometry :signature layout) + (:name :width :id ebox/width :initial auto + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout :validator horizontal-size) - (:name :min-width :id ebox/min-width :initial 0 :contexts (box) + (:name :min-width :id ebox/min-width :initial 0 + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout - :validator horizontal-size) - (:name :max-width :id ebox/max-width :initial nil :contexts (box) + :validator min-horizontal-size) + (:name :max-width :id ebox/max-width :initial none + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout - :validator horizontal-size) - (:name :height :id ebox/height :initial nil :contexts (box flex grid) - :group geometry :dirty-kind geometry :signature layout) - (:name :min-height :id ebox/min-height :initial 0 :contexts (box) - :group geometry :dirty-kind geometry :signature layout) - (:name :max-height :id ebox/max-height :initial nil :contexts (box) - :group geometry :dirty-kind geometry :signature layout) - (:name :padding-block-start :id ebox/padding-block-start - :aliases (:padding-top :padding-top-height) :initial 0 :contexts (box) + :validator max-horizontal-size) + (:name :height :id ebox/height :initial auto + :contexts (box row column flex grid) + :group geometry :dirty-kind geometry :signature layout + :validator vertical-size) + (:name :min-height :id ebox/min-height :initial 0 + :contexts (box row column flex grid) + :group geometry :dirty-kind geometry :signature layout + :validator min-vertical-size) + (:name :max-height :id ebox/max-height :initial none + :contexts (box row column flex grid) + :group geometry :dirty-kind geometry :signature layout + :validator max-vertical-size) + (:name :padding-top :id ebox/padding-top :initial 0 + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout :validator nonnegative-size) - (:name :padding-inline-end :id ebox/padding-inline-end - :aliases (:padding-right :padding-right-pixel) :initial 0 :contexts (box) + (:name :padding-right :id ebox/padding-right :initial 0 + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout :validator nonnegative-size) - (:name :padding-block-end :id ebox/padding-block-end - :aliases (:padding-bottom :padding-bottom-height) :initial 0 :contexts (box) + (:name :padding-bottom :id ebox/padding-bottom :initial 0 + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout :validator nonnegative-size) - (:name :padding-inline-start :id ebox/padding-inline-start - :aliases (:padding-left :padding-left-pixel) :initial 0 :contexts (box) + (:name :padding-left :id ebox/padding-left :initial 0 + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout :validator nonnegative-size) - (:name :margin-block-start :id ebox/margin-block-start - :aliases (:margin-top :margin-top-height) :initial 0 :contexts (box) + (:name :padding-block-start :id ebox/padding-block-start :initial 0 + :contexts (box row column flex grid) :group geometry + :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :padding-inline-end :id ebox/padding-inline-end :initial 0 + :contexts (box row column flex grid) :group geometry + :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :padding-block-end :id ebox/padding-block-end :initial 0 + :contexts (box row column flex grid) :group geometry + :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :padding-inline-start :id ebox/padding-inline-start :initial 0 + :contexts (box row column flex grid) :group geometry + :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :margin-top :id ebox/margin-top :initial 0 + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout :validator nonnegative-size) - (:name :margin-inline-end :id ebox/margin-inline-end - :aliases (:margin-right :margin-right-pixel) :initial 0 :contexts (box) + (:name :margin-right :id ebox/margin-right :initial 0 + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout :validator nonnegative-size) - (:name :margin-block-end :id ebox/margin-block-end - :aliases (:margin-bottom :margin-bottom-height) :initial 0 :contexts (box) + (:name :margin-bottom :id ebox/margin-bottom :initial 0 + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout :validator nonnegative-size) - (:name :margin-inline-start :id ebox/margin-inline-start - :aliases (:margin-left :margin-left-pixel) :initial 0 :contexts (box) + (:name :margin-left :id ebox/margin-left :initial 0 + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout :validator nonnegative-size) + (:name :margin-block-start :id ebox/margin-block-start :initial 0 + :contexts (box row column flex grid) :group geometry + :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :margin-inline-end :id ebox/margin-inline-end :initial 0 + :contexts (box row column flex grid) :group geometry + :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :margin-block-end :id ebox/margin-block-end :initial 0 + :contexts (box row column flex grid) :group geometry + :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :margin-inline-start :id ebox/margin-inline-start :initial 0 + :contexts (box row column flex grid) :group geometry + :dirty-kind geometry :signature layout + :validator nonnegative-size) (:name :border-top-width :id ebox/border-top-width - :initial nil :contexts (box) - :group geometry :dirty-kind geometry :signature layout) + :initial 0 :contexts (box row column flex grid) + :group geometry :dirty-kind geometry :signature layout + :validator border-width) (:name :border-right-width :id ebox/border-right-width - :aliases (:border-right-pixel) :initial nil :contexts (box) + :initial 0 :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-size) + :validator border-width) (:name :border-bottom-width :id ebox/border-bottom-width - :initial nil :contexts (box) - :group geometry :dirty-kind geometry :signature layout) + :initial 0 :contexts (box row column flex grid) + :group geometry :dirty-kind geometry :signature layout + :validator border-width) (:name :border-left-width :id ebox/border-left-width - :aliases (:border-left-pixel) :initial nil :contexts (box) + :initial 0 :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-size) - (:name :border-top-style :id ebox/border-top-style :initial nil - :contexts (box) :group structure :dirty-kind structure :signature layout) - (:name :border-right-style :id ebox/border-right-style :initial nil - :contexts (box) :group structure :dirty-kind structure :signature layout) - (:name :border-bottom-style :id ebox/border-bottom-style :initial nil - :contexts (box) :group structure :dirty-kind structure :signature layout) - (:name :border-left-style :id ebox/border-left-style :initial nil - :contexts (box) :group structure :dirty-kind structure :signature layout) - (:name :border-top-color :id ebox/border-top-color :initial nil - :contexts (box) :group paint :dirty-kind paint :signature paint - :normalizer color) - (:name :border-right-color :id ebox/border-right-color :initial nil - :contexts (box) :group paint :dirty-kind paint :signature paint - :normalizer color) - (:name :border-bottom-color :id ebox/border-bottom-color :initial nil - :contexts (box) :group paint :dirty-kind paint :signature paint - :normalizer color) - (:name :border-left-color :id ebox/border-left-color :initial nil - :contexts (box) :group paint :dirty-kind paint :signature paint - :normalizer color) - (:name :text-align :id ebox/text-align :initial left :contexts (box) - :group geometry :dirty-kind geometry :signature layout) - (:name :vertical-align :id ebox/vertical-align :initial top :contexts (box) - :group geometry :dirty-kind geometry :signature layout - :normalizer vertical-align) - (:name :overflow :id ebox/overflow :initial scroll :contexts (box) - :group structure :dirty-kind geometry :signature layout) - (:name :wrap-mode :id ebox/wrap-mode :initial word :inherits t + :validator border-width) + (:name :border-top-style :id ebox/border-top-style :initial none + :contexts (box row column flex grid) :group structure + :dirty-kind structure :signature layout :validator border-style) + (:name :border-right-style :id ebox/border-right-style :initial none + :contexts (box row column flex grid) :group structure + :dirty-kind structure :signature layout :validator border-style) + (:name :border-bottom-style :id ebox/border-bottom-style :initial none + :contexts (box row column flex grid) :group structure + :dirty-kind structure :signature layout :validator border-style) + (:name :border-left-style :id ebox/border-left-style :initial none + :contexts (box row column flex grid) :group structure + :dirty-kind structure :signature layout :validator border-style) + (:name :border-top-color :id ebox/border-top-color :initial currentColor + :contexts (box row column flex grid) :group paint + :dirty-kind paint :signature paint + :normalizer color :validator color) + (:name :border-right-color :id ebox/border-right-color :initial currentColor + :contexts (box row column flex grid) :group paint + :dirty-kind paint :signature paint + :normalizer color :validator color) + (:name :border-bottom-color :id ebox/border-bottom-color + :initial currentColor :contexts (box row column flex grid) + :group paint :dirty-kind paint :signature paint + :normalizer color :validator color) + (:name :border-left-color :id ebox/border-left-color :initial currentColor + :contexts (box row column flex grid) :group paint + :dirty-kind paint :signature paint + :normalizer color :validator color) + (:name :text-align :id ebox/text-align :initial left :inherits t :contexts (box) + :author-contexts (box) + :group geometry :dirty-kind geometry :signature layout) + (:name :overflow :id ebox/overflow :initial scroll + :contexts (box row column flex grid) :group structure + :dirty-kind geometry :signature layout + :impacts (geometry paint) :projections (layout paint)) + (:name :wrap-mode :id ebox/wrap-mode :initial word :inherits t + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout :validator wrap-mode) - (:name :visibility :id ebox/visibility :initial visible :contexts (box) + (:name :visibility :id ebox/visibility :initial visible :inherits t + :contexts (box row column flex grid) :group paint :dirty-kind paint :signature paint) - (:name :display :id ebox/display :initial nil :contexts (node) + (:name :outer :id ebox/outer :initial block + :contexts (box row column flex grid) :group structure :dirty-kind structure :signature structure - :validator display) - (:name :outer :id ebox/outer :initial block :contexts (node) - :group structure :dirty-kind structure :signature structure - :validator outer) - (:name :layout :id ebox/layout :initial normal :contexts (node) - :group structure :dirty-kind structure :signature structure - :validator layout) - (:name :flex-direction :id ebox/flex-direction :initial nil - :contexts (flex) :group geometry :dirty-kind geometry :signature layout) - (:name :flex-wrap :id ebox/flex-wrap :initial nil :contexts (flex) - :group geometry :dirty-kind geometry :signature layout) - (:name :justify-content :id ebox/justify-content :initial nil - :contexts (flex grid) :group geometry :dirty-kind geometry :signature layout) - (:name :align-items :id ebox/align-items :initial nil - :contexts (flex grid) :group geometry :dirty-kind geometry :signature layout) - (:name :align-content :id ebox/align-content :initial nil - :contexts (flex grid) :group geometry :dirty-kind geometry :signature layout) - (:name :justify-items :id ebox/justify-items :initial nil - :contexts (grid) :group geometry :dirty-kind geometry :signature layout) - (:name :row-gap :id ebox/row-gap :aliases (:grid-row-gap) :initial nil + :validator outer :impacts (structure geometry) + :projections (structure layout)) + (:name :item-gap :id ebox/item-gap :initial 0 :contexts (row column) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-integer) + (:name :cross-align :id ebox/cross-align :initial stretch + :contexts (row column) :group geometry :dirty-kind geometry + :signature layout :validator cross-align) + (:name :flex-direction :id ebox/flex-direction :initial row + :contexts (flex) :group geometry :dirty-kind geometry :signature layout + :validator flex-direction) + (:name :flex-wrap :id ebox/flex-wrap :initial nowrap :contexts (flex) + :group geometry :dirty-kind geometry :signature layout + :validator flex-wrap) + (:name :justify-content :id ebox/justify-content :initial flex-start + :contexts (flex grid) :group geometry :dirty-kind geometry + :signature layout :validator justify-content) + (:name :align-items :id ebox/align-items :initial stretch + :contexts (flex grid) :group geometry :dirty-kind geometry + :signature layout :validator align-items) + (:name :align-content :id ebox/align-content :initial stretch + :contexts (flex grid) :group geometry :dirty-kind geometry + :signature layout :validator align-content) + (:name :justify-items :id ebox/justify-items :initial stretch + :contexts (grid) :group geometry :dirty-kind geometry :signature layout + :validator justify-items) + (:name :row-gap :id ebox/row-gap :initial 0 :contexts (flex grid) :group geometry :dirty-kind geometry :signature layout :validator nonnegative-size) - (:name :column-gap :id ebox/column-gap :aliases (:grid-column-gap) - :initial nil :contexts (flex grid) :group geometry :dirty-kind geometry + (:name :column-gap :id ebox/column-gap + :initial 0 :contexts (flex grid) :group geometry :dirty-kind geometry :signature layout :validator nonnegative-size) - (:name :order :id ebox/order :initial nil :contexts (item) - :group geometry :dirty-kind geometry :signature layout) - (:name :flex-grow :id ebox/flex-grow :initial nil :contexts (item) + (:name :order :id ebox/order :initial 0 :contexts (item) + :group geometry :dirty-kind geometry :signature layout :validator order) + (:name :flex-grow :id ebox/flex-grow :initial 0 :contexts (item) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-number) - (:name :flex-shrink :id ebox/flex-shrink :initial nil :contexts (item) + :validator flex-factor) + (:name :flex-shrink :id ebox/flex-shrink :initial 1 :contexts (item) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-number) - (:name :flex-basis :id ebox/flex-basis :initial nil :contexts (item) + :validator flex-factor) + (:name :flex-basis :id ebox/flex-basis :initial auto :contexts (item) :group geometry :dirty-kind geometry :signature layout :validator horizontal-size) - (:name :align-self :id ebox/align-self :initial nil :contexts (item) - :group geometry :dirty-kind geometry :signature layout) + (:name :align-self :id ebox/align-self :initial auto :contexts (item) + :group geometry :dirty-kind geometry :signature layout + :validator align-self) + (:name :justify-self :id ebox/justify-self :initial auto :contexts (item) + :group geometry :dirty-kind geometry :signature layout + :validator justify-self) (:name :grid-template-columns :id ebox/grid-template-columns :initial nil - :contexts (grid) :group geometry :dirty-kind geometry :signature layout) + :contexts (grid) :group geometry :dirty-kind geometry :signature layout + :validator grid-track-list) (:name :grid-template-rows :id ebox/grid-template-rows :initial nil - :contexts (grid) :group geometry :dirty-kind geometry :signature layout) - (:name :grid-auto-columns :id ebox/grid-auto-columns :initial nil - :contexts (grid) :group geometry :dirty-kind geometry :signature layout) - (:name :grid-auto-rows :id ebox/grid-auto-rows :initial nil - :contexts (grid) :group geometry :dirty-kind geometry :signature layout) - (:name :grid-auto-flow :id ebox/grid-auto-flow :initial nil - :contexts (grid) :group structure :dirty-kind structure :signature layout) - (:name :grid-column :id ebox/grid-column :initial nil :contexts (item) + :contexts (grid) :group geometry :dirty-kind geometry :signature layout + :validator grid-track-list) + (:name :grid-auto-columns :id ebox/grid-auto-columns :initial auto + :contexts (grid) :group geometry :dirty-kind geometry :signature layout + :validator grid-track) + (:name :grid-auto-rows :id ebox/grid-auto-rows :initial auto + :contexts (grid) :group geometry :dirty-kind geometry :signature layout + :validator grid-track) + (:name :grid-auto-flow :id ebox/grid-auto-flow :initial row + :contexts (grid) :group structure :dirty-kind structure :signature layout + :validator grid-auto-flow) + (:name :grid-column :id ebox/grid-column :initial auto :contexts (item) :group geometry :dirty-kind geometry :signature layout :validator grid-placement) - (:name :grid-row :id ebox/grid-row :initial nil :contexts (item) + (:name :grid-row :id ebox/grid-row :initial auto :contexts (item) :group geometry :dirty-kind geometry :signature layout :validator grid-placement) - (:name :grid-column-span :id ebox/grid-column-span :initial nil - :contexts (item) :group geometry :dirty-kind geometry :signature layout - :validator positive-integer) - (:name :grid-row-span :id ebox/grid-row-span :initial nil - :contexts (item) :group geometry :dirty-kind geometry :signature layout - :validator positive-integer) + (:name :grid-column-span :id ebox/grid-column-span + :shorthand grid-column-span :validator positive-integer + :contexts (item) :group geometry :dirty-kind geometry :signature layout) + (:name :grid-row-span :id ebox/grid-row-span + :shorthand grid-row-span :validator positive-integer + :contexts (item) :group geometry :dirty-kind geometry :signature layout) (:name :padding :id ebox/padding :shorthand padding - :impacts (geometry) :projections (layout)) + :contexts (box row column flex grid) + :impacts (geometry paint) :projections (layout paint)) (:name :padding-inline :id ebox/padding-inline :shorthand padding-inline - :impacts (geometry) :projections (layout)) + :contexts (box row column flex grid) + :impacts (geometry paint) :projections (layout paint)) (:name :padding-block :id ebox/padding-block :shorthand padding-block - :impacts (geometry) :projections (layout)) + :contexts (box row column flex grid) + :impacts (geometry paint) :projections (layout paint)) (:name :margin :id ebox/margin :shorthand margin + :contexts (box row column flex grid) :impacts (geometry) :projections (layout)) (:name :margin-inline :id ebox/margin-inline :shorthand margin-inline + :contexts (box row column flex grid) :impacts (geometry) :projections (layout)) (:name :margin-block :id ebox/margin-block :shorthand margin-block + :contexts (box row column flex grid) :impacts (geometry) :projections (layout)) (:name :border :id ebox/border :shorthand border - :impacts (geometry structure paint) :projections (layout paint)) + :contexts (box row column flex grid) + :impacts (geometry paint) :projections (layout paint)) (:name :border-top :id ebox/border-top :shorthand border-top - :impacts (geometry structure paint) :projections (layout paint)) + :contexts (box row column flex grid) + :impacts (geometry paint) :projections (layout paint)) (:name :border-right :id ebox/border-right :shorthand border-right - :impacts (geometry structure paint) :projections (layout paint)) + :contexts (box row column flex grid) + :impacts (geometry paint) :projections (layout paint)) (:name :border-bottom :id ebox/border-bottom :shorthand border-bottom - :impacts (geometry structure paint) :projections (layout paint)) + :contexts (box row column flex grid) + :impacts (geometry paint) :projections (layout paint)) (:name :border-left :id ebox/border-left :shorthand border-left - :impacts (geometry structure paint) :projections (layout paint)) + :contexts (box row column flex grid) + :impacts (geometry paint) :projections (layout paint)) (:name :border-top-p :id ebox/border-top-p :shorthand border-top-present :validator boolean - :exclusive-outputs t - :impacts (geometry structure) :projections (layout) - :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 - :impacts (geometry structure) :projections (layout) + :contexts (box row column flex grid) + :impacts (geometry paint) :projections (layout paint) :group structure :dirty-kind structure :signature layout) (:name :border-width :id ebox/border-width :shorthand border-width + :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout) (:name :border-style :id ebox/border-style :shorthand border-style + :contexts (box row column flex grid) :group structure :dirty-kind structure :signature layout) (:name :border-color :id ebox/border-color :shorthand border-color + :contexts (box row column flex grid) :group paint :dirty-kind paint :signature paint) (:name :gap :id ebox/gap :shorthand gap + :contexts (flex grid) :group geometry :dirty-kind geometry :signature layout) (:name :flex-flow :id ebox/flex-flow :shorthand flex-flow + :contexts (flex) :group geometry :dirty-kind geometry :signature layout) (:name :flex :id ebox/flex :shorthand flex + :contexts (item) :group geometry :dirty-kind geometry :signature layout)) "Canonical Ebox property schemas and public aliases.") @@ -335,37 +425,37 @@ "Classify border shorthand ITEM as width, style, or color." (cond ((numberp item) (cons 'width item)) - ((and (listp item) (numberp (car item)) (null (cdr item))) - (cons 'width item)) ((memq item ebox-style--border-styles) (cons 'style item)) - (t (cons 'color item)))) + ((or (stringp item) (tp-paint-slot-p item)) (cons 'color item)) + (t (error "Invalid Ebox border component: %S" item)))) (defun ebox-style--split-wsc (value) "Split VALUE into border width, style, and color." (cond ((null value) (list nil nil nil)) - ((eq value t) - (list '(1) 'solid (frame-parameter nil 'foreground-color))) ((atom value) (let ((classified (ebox-style--wsc-classify value))) (pcase (car classified) ('width (list (cdr classified) nil nil)) ('style (list nil (cdr classified) nil)) - ('color (list '(1) 'solid (cdr classified)))))) - ((keywordp (car value)) - (list (plist-get value :width) - (plist-get value :style) - (plist-get value :color))) - ((listp value) - (let (width style color) + ('color (list 1 'solid (cdr classified)))))) + ((proper-list-p value) + (let (width style color width-p style-p color-p) (dolist (item value) (let ((classified (ebox-style--wsc-classify item))) (pcase (car classified) - ('width (setq width (cdr classified))) - ('style (setq style (cdr classified))) - ('color (setq color (cdr classified)))))) - (list (or width '(1)) (or style 'solid) color))) - (t (list '(1) 'solid value)))) + ('width + (when width-p (error "Duplicate Ebox border width in %S" value)) + (setq width-p t width (cdr classified))) + ('style + (when style-p (error "Duplicate Ebox border style in %S" value)) + (setq style-p t style (cdr classified))) + ('color + (when color-p (error "Duplicate Ebox border color in %S" value)) + (setq color-p t color (cdr classified)))))) + (list (or width 1) (or style 'solid) color))) + (t (error "Ebox border must be a color or proper component list: %S" + value)))) (defun ebox-style--atom-consp (list) "Return non-nil when LIST is a strict dotted pair of atoms." @@ -375,22 +465,8 @@ (atom (cdr list)))) (defun ebox-style--parse-color (color) - "Resolve COLOR, returning nil for an unspecified Emacs color." - (let ((resolved - (cond - ((eq t color) (face-attribute 'default :foreground nil t)) - ((null color) nil) - ((stringp color) color) - ((ebox-style--atom-consp color) - (pcase (frame-parameter nil 'background-mode) - ('light (car color)) - ('dark (cdr color)) - (_ (car color)))) - (t color)))) - (unless (member resolved '(nil unspecified unspecified-fg unspecified-bg - "unspecified" "unspecified-fg" - "unspecified-bg")) - resolved))) + "Return a detached canonical COLOR value without consulting a frame." + (if (stringp color) (copy-sequence color) color)) (defun ebox-style--trbl-declarations (properties value) "Expand VALUE across four namespaced PROPERTIES." @@ -482,10 +558,6 @@ "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 @@ -510,9 +582,24 @@ "Expand gap VALUE to row and column gaps." (ebox-style--pair-declarations '(ebox/row-gap ebox/column-gap) value)) +(defun ebox-style--grid-column-span-shorthand (value) + "Expand Grid column span VALUE into canonical auto placement." + (unless (and (integerp value) (> value 0)) + (error "Ebox grid-column-span must be a positive integer: %S" value)) + (list 'ebox/grid-column (list :span value))) + +(defun ebox-style--grid-row-span-shorthand (value) + "Expand Grid row span VALUE into canonical auto placement." + (unless (and (integerp value) (> value 0)) + (error "Ebox grid-row-span must be a positive integer: %S" value)) + (list 'ebox/grid-row (list :span value))) + (defun ebox-style--flex-flow-shorthand (value) "Expand flex-flow VALUE to direction and wrapping longhands." - (let ((values (if (listp value) value (list value))) direction wrap) + (unless (and (proper-list-p value) (= (length value) 2)) + (error "Ebox flex-flow requires one direction and one wrap value: %S" + value)) + (let ((values value) direction wrap) (setq direction (cl-find-if (lambda (item) (memq item '(row row-reverse column column-reverse))) @@ -520,27 +607,31 @@ wrap (cl-find-if (lambda (item) (memq item '(nowrap wrap wrap-reverse))) values)) + (unless (and direction wrap) + (error "Ebox flex-flow requires one direction and one wrap value: %S" + value)) (list 'ebox/flex-direction direction 'ebox/flex-wrap wrap))) (defun ebox-style--flex-shorthand (value) "Expand flex item VALUE to grow, shrink, and basis longhands." (pcase value - ((pred numberp) (list 'ebox/flex-grow value 'ebox/flex-shrink 1 - 'ebox/flex-basis 0)) + ((and (pred numberp) (guard (>= value 0))) + (list 'ebox/flex-grow value 'ebox/flex-shrink 1 + 'ebox/flex-basis 0)) ('none '(ebox/flex-grow 0 ebox/flex-shrink 0 ebox/flex-basis auto)) ('auto '(ebox/flex-grow 1 ebox/flex-shrink 1 ebox/flex-basis auto)) ('initial '(ebox/flex-grow 0 ebox/flex-shrink 1 ebox/flex-basis auto)) - ((pred listp) - (list 'ebox/flex-grow (or (nth 0 value) 0) - 'ebox/flex-shrink (or (nth 1 value) 1) - 'ebox/flex-basis (if (> (length value) 2) (nth 2 value) 'auto))) - (_ '(ebox/flex-grow 0 ebox/flex-shrink 1 ebox/flex-basis auto)))) + ((and (pred proper-list-p) (guard (= (length value) 3))) + (list 'ebox/flex-grow (nth 0 value) + 'ebox/flex-shrink (nth 1 value) + 'ebox/flex-basis (nth 2 value))) + (_ (error "Ebox flex requires none, auto, initial, a factor, or (grow shrink basis): %S" + value)))) (defun ebox-style--normalizer (name) "Return schema normalizer named NAME." (pcase name ('color #'ebox-style--parse-color) - ('vertical-align (lambda (value) (if (eq value 'middle) 'center value))) (_ #'identity))) (defun ebox-style--negative-numeric-size-p (value) @@ -550,9 +641,55 @@ (numberp (car value)) (< (car value) 0)))) +(defun ebox-style--nonnegative-real-p (value) + "Return non-nil when VALUE is a nonnegative real number." + (and (numberp value) (>= value 0))) + +(defun ebox-style--pixel-size-p (value) + "Return non-nil when VALUE is a one-element nonnegative pixel size." + (and (proper-list-p value) (= (length value) 1) + (ebox-style--nonnegative-real-p (car value)))) + (defun ebox-style--nonnegative-size-p (value) - "Return non-nil when VALUE is not a negative numeric size." - (not (ebox-style--negative-numeric-size-p value))) + "Return non-nil when VALUE is one canonical edge size." + (or (ebox-style--nonnegative-real-p value) + (ebox-style--pixel-size-p value))) + +(defun ebox-style--horizontal-size-p (value) + "Return non-nil when VALUE is a canonical horizontal size." + (or (ebox-style--nonnegative-real-p value) + (ebox-style--pixel-size-p value) + (memq value '(viewport auto min-content max-content + fit-content stretch contain)) + (equal value '(viewport)) + (and (proper-list-p value) (= (length value) 2) + (eq (car value) 'fit-content) + (ebox-style--horizontal-size-p (cadr value))))) + +(defun ebox-style--min-horizontal-size-p (value) + "Return non-nil when VALUE is a canonical minimum horizontal size." + (and (not (eq value 'none)) (ebox-style--horizontal-size-p value))) + +(defun ebox-style--max-horizontal-size-p (value) + "Return non-nil when VALUE is a canonical maximum horizontal size." + (or (eq value 'none) (ebox-style--horizontal-size-p value))) + +(defun ebox-style--vertical-size-p (value) + "Return non-nil when VALUE is a canonical vertical size." + (or (ebox-style--nonnegative-real-p value) + (memq value '(viewport-height auto)) + (equal value '(viewport-height)))) + +(defun ebox-style--min-vertical-size-p (value) + "Return non-nil when VALUE is a canonical minimum vertical size." + (ebox-style--nonnegative-real-p value)) + +(defun ebox-style--max-vertical-size-p (value) + "Return non-nil when VALUE is a canonical maximum vertical size." + (or (ebox-style--nonnegative-real-p value) + (eq value 'none) + (eq value 'viewport-height) + (equal value '(viewport-height)))) (defun ebox-style--nonnegative-number-p (value) "Return non-nil when VALUE is nil or a non-negative number." @@ -565,6 +702,7 @@ (defun ebox-style--grid-placement-p (value) "Return non-nil when VALUE is a valid public Grid placement." (or (null value) + (eq value 'auto) (and (integerp value) (> value 0)) (and (proper-list-p value) (pcase value @@ -573,39 +711,169 @@ (`(,start :span ,span) (and (integerp start) (> start 0) (integerp span) (> span 0))) + (`(:span ,span) + (and (integerp span) (> span 0))) (`(,start ,end) (and (integerp start) (> start 0) (integerp end) (> end start))))))) -(defun ebox-style--display-p (value) - "Return non-nil when VALUE is nil or an Ebox display pair." - (or (null value) - (and (listp value) (= (length value) 2) - (cl-every #'symbolp value)))) - (defun ebox-style--outer-p (value) "Return non-nil when VALUE is an Ebox outer participation value." (memq value '(inline block))) -(defun ebox-style--layout-p (value) - "Return non-nil when VALUE is an Ebox child layout value." - (memq value '(normal row column flex grid))) - -(defun ebox-style--font-p (value) - "Return non-nil when VALUE is an Ebox font face specification." - (or (null value) (symbolp value) (stringp value) (proper-list-p value))) - (defun ebox-style--font-family-p (value) "Return non-nil when VALUE is an Ebox font family." - (or (null value) (stringp value) (symbolp value))) + (or (null value) + (and (stringp value) (not (string-empty-p value))) + (symbolp value))) -(defun ebox-style--font-height-p (value) - "Return non-nil when VALUE is an Emacs face height." - (or (null value) (numberp value) (functionp value))) +(defun ebox-style--color-p (value) + "Return non-nil when VALUE is a canonical or deferred color fact." + (or (null value) (stringp value) (eq value 'currentColor) + (tp-paint-slot-p value))) -(defun ebox-style--font-attribute-p (value) - "Return non-nil when VALUE is a symbolic font attribute." - (or (null value) (symbolp value))) +(defun ebox-style--font-size-p (value) + "Return non-nil when VALUE is a positive CSS reference-pixel size." + (or (null value) (and (numberp value) (> value 0)))) + +(defun ebox-style--font-weight-p (value) + "Return non-nil when VALUE is a canonical CSS font weight." + (or (null value) + (memq value '(normal bold bolder lighter + 100 200 300 400 500 600 700 800 900)))) + +(defun ebox-style--font-style-p (value) + "Return non-nil when VALUE is a canonical CSS font style." + (or (null value) (memq value '(normal italic oblique)))) + +(defun ebox-style--border-width-p (value) + "Return non-nil when VALUE is an exact nonnegative pixel width." + (and (integerp value) (>= value 0))) + +(defun ebox-style--border-style-p (value) + "Return non-nil when VALUE is a supported canonical border style." + (memq value '(none solid))) + +(defun ebox-style--text-decoration-line-p (value) + "Return non-nil when VALUE is a canonical text-decoration line set." + (let ((values (if (listp value) value (list value)))) + (or (equal values '(none)) + (and values + (cl-every (lambda (item) + (memq item '(underline overline line-through))) + values) + (= (length values) + (length (delete-dups (copy-sequence values)))))))) + +(defun ebox-style--text-decoration-style-p (value) + "Return non-nil when VALUE is a canonical text-decoration style." + (memq value '(solid double dotted dashed wavy))) + +(defun ebox-style--nonnegative-integer-p (value) + "Return non-nil when VALUE is a nonnegative integer." + (and (integerp value) (>= value 0))) + +(defun ebox-style--cross-align-p (value) + "Return non-nil when VALUE is a Row/Column cross alignment." + (memq value '(start center end stretch))) + +(defun ebox-style--flex-direction-p (value) + "Return non-nil when VALUE is a canonical Flex direction." + (memq value '(row row-reverse column column-reverse))) + +(defun ebox-style--flex-wrap-p (value) + "Return non-nil when VALUE is a canonical Flex wrapping mode." + (memq value '(nowrap wrap wrap-reverse))) + +(defun ebox-style--justify-content-p (value) + "Return non-nil when VALUE is a canonical content justification." + (memq value '(normal start end flex-start flex-end center stretch + space-between space-around space-evenly))) + +(defun ebox-style--align-items-p (value) + "Return non-nil when VALUE is a canonical item alignment." + (memq value '(normal start end stretch flex-start flex-end center baseline))) + +(defun ebox-style--align-content-p (value) + "Return non-nil when VALUE is a canonical multi-line alignment." + (memq value '(normal start end stretch flex-start flex-end center + space-between space-around space-evenly))) + +(defun ebox-style--justify-items-p (value) + "Return non-nil when VALUE is a canonical Grid item justification." + (memq value '(stretch start end center))) + +(defun ebox-style--align-self-p (value) + "Return non-nil when VALUE is a canonical parent-relative alignment." + (memq value '(auto normal start end stretch flex-start flex-end + center baseline))) + +(defun ebox-style--grid-auto-flow-p (value) + "Return non-nil when VALUE is a canonical Grid auto-flow axis." + (memq value '(row column))) + +(defun ebox-style--grid-fixed-track-p (value) + "Return non-nil when VALUE is one nonnegative fixed Grid track." + (or (ebox-style--nonnegative-real-p value) + (and (proper-list-p value) (= (length value) 1) + (ebox-style--nonnegative-real-p (car value))))) + +(defun ebox-style--grid-fr-track-p (value) + "Return non-nil when VALUE is one positive fractional Grid track." + (and (proper-list-p value) (= (length value) 2) + (eq (car value) 'fr) + (numberp (cadr value)) (> (cadr value) 0))) + +(defun ebox-style--grid-track-p (value &optional allow-repeat) + "Return non-nil when VALUE is one Grid track. +ALLOW-REPEAT permits a top-level repeat expression." + (cond + ((memq value '(auto min-content max-content)) t) + ((ebox-style--grid-fixed-track-p value) t) + ((ebox-style--grid-fr-track-p value) t) + ((and (proper-list-p value) (= (length value) 3) + (eq (car value) 'minmax)) + (let ((minimum (cadr value)) (maximum (caddr value))) + (and (not (and (consp minimum) + (memq (car minimum) '(minmax repeat)))) + (not (and (consp maximum) + (memq (car maximum) '(minmax repeat)))) + (ebox-style--grid-track-p minimum nil) + (not (ebox-style--grid-fr-track-p minimum)) + (ebox-style--grid-track-p maximum nil)))) + ((and allow-repeat (proper-list-p value) (= (length value) 3) + (eq (car value) 'repeat) + (integerp (cadr value)) (> (cadr value) 0)) + (let ((body (caddr value))) + (or (ebox-style--grid-track-p body nil) + (and (proper-list-p body) body + (cl-every (lambda (track) + (ebox-style--grid-track-p track nil)) + body))))) + (t nil))) + +(defun ebox-style--grid-track-list-p (value) + "Return non-nil when VALUE is a valid Grid track list." + (or (null value) + (and (proper-list-p value) value + (cl-every (lambda (track) + (ebox-style--grid-track-p track t)) + value)))) + +(defun ebox-style--grid-auto-track-p (value) + "Return non-nil when VALUE denotes at most one implicit Grid track." + (or (null value) + (ebox-style--grid-track-p value nil) + (and (proper-list-p value) (= (length value) 1) + (ebox-style--grid-track-p (car value) nil)))) + +(defun ebox-style--order-p (value) + "Return non-nil when VALUE is an integer parent ordering key." + (integerp value)) + +(defun ebox-style--justify-self-p (value) + "Return non-nil when VALUE is a canonical Grid self alignment." + (memq value '(auto stretch start end center))) (defun ebox-style--wrap-mode-p (value) "Return non-nil when VALUE is a public Box wrapping mode." @@ -624,18 +892,41 @@ generated wrappers that already contain exact line breaks." (defun ebox-style--validator (name) "Return schema validator named NAME." (pcase name - ((or 'horizontal-size 'nonnegative-size) - #'ebox-style--nonnegative-size-p) + ('horizontal-size #'ebox-style--horizontal-size-p) + ('min-horizontal-size #'ebox-style--min-horizontal-size-p) + ('max-horizontal-size #'ebox-style--max-horizontal-size-p) + ('vertical-size #'ebox-style--vertical-size-p) + ('min-vertical-size #'ebox-style--min-vertical-size-p) + ('max-vertical-size #'ebox-style--max-vertical-size-p) + ('nonnegative-size #'ebox-style--nonnegative-size-p) ('nonnegative-number #'ebox-style--nonnegative-number-p) ('positive-integer #'ebox-style--positive-integer-p) ('grid-placement #'ebox-style--grid-placement-p) - ('display #'ebox-style--display-p) ('outer #'ebox-style--outer-p) - ('layout #'ebox-style--layout-p) - ('font #'ebox-style--font-p) ('font-family #'ebox-style--font-family-p) - ('font-height #'ebox-style--font-height-p) - ('font-attribute #'ebox-style--font-attribute-p) + ('color #'ebox-style--color-p) + ('font-size #'ebox-style--font-size-p) + ('font-weight #'ebox-style--font-weight-p) + ('font-style #'ebox-style--font-style-p) + ('border-width #'ebox-style--border-width-p) + ('border-style #'ebox-style--border-style-p) + ('text-decoration-line #'ebox-style--text-decoration-line-p) + ('text-decoration-style #'ebox-style--text-decoration-style-p) + ('nonnegative-integer #'ebox-style--nonnegative-integer-p) + ('cross-align #'ebox-style--cross-align-p) + ('flex-direction #'ebox-style--flex-direction-p) + ('flex-wrap #'ebox-style--flex-wrap-p) + ('justify-content #'ebox-style--justify-content-p) + ('align-items #'ebox-style--align-items-p) + ('align-content #'ebox-style--align-content-p) + ('justify-items #'ebox-style--justify-items-p) + ('align-self #'ebox-style--align-self-p) + ('grid-auto-flow #'ebox-style--grid-auto-flow-p) + ('grid-track-list #'ebox-style--grid-track-list-p) + ('grid-track #'ebox-style--grid-auto-track-p) + ('order #'ebox-style--order-p) + ('flex-factor #'ebox-style--nonnegative-number-p) + ('justify-self #'ebox-style--justify-self-p) ('wrap-mode #'ebox-style--wrap-mode-p) ('boolean (lambda (value) (memq value '(nil t)))) (_ (lambda (_value) t)))) @@ -656,11 +947,12 @@ generated wrappers that already contain exact line breaks." (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) (gap . ebox-style--gap-shorthand) + (grid-column-span . ebox-style--grid-column-span-shorthand) + (grid-row-span . ebox-style--grid-row-span-shorthand) (flex-flow . ebox-style--flex-flow-shorthand) (flex . ebox-style--flex-shorthand)))) @@ -680,15 +972,71 @@ generated wrappers that already contain exact line breaks." (defun ebox-style--schema-impacts (property) "Return immutable impact classes for PROPERTY metadata." - (or (plist-get property :impacts) - (and (eq (plist-get property :group) 'typography) - '(geometry paint)) - (list (plist-get property :dirty-kind)))) + (let ((name (plist-get property :name)) + (group (plist-get property :group))) + (or (plist-get property :impacts) + (and (eq group 'typography) '(geometry paint)) + (and (or (string-prefix-p ":padding" (symbol-name name)) + (and (string-prefix-p ":border" (symbol-name name)) + (not (string-suffix-p "-color" (symbol-name name))) + (not (eq name :border-color)))) + '(geometry paint)) + (list (plist-get property :dirty-kind))))) (defun ebox-style--schema-projections (property) "Return immutable projection owners for PROPERTY metadata." - (or (plist-get property :projections) - (delq nil (list (plist-get property :signature))))) + (let ((name (plist-get property :name)) + (group (plist-get property :group)) + (contexts (plist-get property :contexts))) + (or (plist-get property :projections) + (cond + ((eq group 'typography) '(measurement paint)) + ((memq 'item contexts) '(parent-participation)) + ((or (string-prefix-p ":padding" (symbol-name name)) + (and (string-prefix-p ":border" (symbol-name name)) + (not (string-suffix-p "-color" (symbol-name name))) + (not (eq name :border-color)))) + '(layout paint)) + ((eq group 'paint) '(paint)) + ((eq group 'structure) '(structure layout)) + (t '(layout)))))) + +(defun ebox-style--schema-domain-owner (property) + "Return the domain owner of PROPERTY's computed projection." + (let ((name (plist-get property :name)) + (group (plist-get property :group)) + (contexts (plist-get property :contexts))) + (cond + ((eq group 'typography) 'font-metrics-projection) + ((memq 'item contexts) 'direct-parent-layout) + ((eq group 'paint) 'tp-contribution) + ((or (memq name '(:gap :flex-flow)) + (cl-some + (lambda (tag) + (memq name (ebox-layout-config-property-names tag))) + '(row column flex grid))) + 'ebox-layout-config) + (t 'ebox-box-frame)))) + +(defun ebox-style--schema-metadata (property) + "Return detached, complete registry metadata for PROPERTY." + (let ((metadata (copy-tree property))) + (setq metadata + (plist-put metadata :namespace + (if (memq (plist-get property :name) + '(:wrap-mode :item-gap :cross-align + :border-top-p :grid-column-span + :grid-row-span)) + 'ebox + 'css))) + (setq metadata + (plist-put metadata :owner + (ebox-style--schema-domain-owner property))) + (setq metadata + (plist-put metadata :impacts + (ebox-style--schema-impacts property))) + (plist-put metadata :projections + (ebox-style--schema-projections property)))) (defun ebox-style--schema-definition (property) "Return one ECSS package definition for Ebox PROPERTY metadata." @@ -699,7 +1047,7 @@ generated wrappers that already contain exact line breaks." (copy-sequence (plist-get property :aliases))) :impacts (ebox-style--schema-impacts property) :projections (ebox-style--schema-projections property) - :metadata property) + :metadata (ebox-style--schema-metadata property)) (ebox-style--schema-options property))) (defconst ebox-style--schema-package @@ -711,11 +1059,12 @@ generated wrappers that already contain exact line breaks." (defconst ebox-style--property-index (let ((table (make-hash-table :test #'eq))) - (dolist (property ebox-style--property-definitions table) - (puthash (plist-get property :id) property table) - (puthash (plist-get property :name) property table) - (dolist (alias (plist-get property :aliases)) - (puthash alias property table)))) + (dolist (source ebox-style--property-definitions table) + (let ((property (ebox-style--schema-metadata source))) + (puthash (plist-get property :id) property table) + (puthash (plist-get property :name) property table) + (dolist (alias (plist-get property :aliases)) + (puthash alias property table))))) "Ebox-owned immutable lookup index over property definitions.") (defconst ebox-style-schemas @@ -837,44 +1186,6 @@ consume them even though Ebox does not interpret their names." 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 @@ -900,13 +1211,46 @@ not affect declaration compilation and are intentionally excluded." (pcase validator ('nonnegative-number (and (numberp value) (< value 0))) - ((or 'horizontal-size 'nonnegative-size) - (ebox-style--negative-numeric-size-p value)) + ('horizontal-size (not (ebox-style--horizontal-size-p value))) + ('min-horizontal-size (not (ebox-style--min-horizontal-size-p value))) + ('max-horizontal-size (not (ebox-style--max-horizontal-size-p value))) + ('vertical-size (not (ebox-style--vertical-size-p value))) + ('min-vertical-size (not (ebox-style--min-vertical-size-p value))) + ('max-vertical-size (not (ebox-style--max-vertical-size-p value))) + ('nonnegative-size (not (ebox-style--nonnegative-size-p value))) ('positive-integer (and (numberp value) (not (ebox-style--positive-integer-p value)))) ('grid-placement (and (or (numberp value) (proper-list-p value)) (not (ebox-style--grid-placement-p value)))) + ('font-family (not (ebox-style--font-family-p value))) + ('color (not (ebox-style--color-p value))) + ('font-size (not (ebox-style--font-size-p value))) + ('font-weight (not (ebox-style--font-weight-p value))) + ('font-style (not (ebox-style--font-style-p value))) + ('border-width (not (ebox-style--border-width-p value))) + ('border-style (not (ebox-style--border-style-p value))) + ('text-decoration-line + (not (ebox-style--text-decoration-line-p value))) + ('text-decoration-style + (not (ebox-style--text-decoration-style-p value))) + ('nonnegative-integer + (not (ebox-style--nonnegative-integer-p value))) + ('cross-align (not (ebox-style--cross-align-p value))) + ('flex-direction (not (ebox-style--flex-direction-p value))) + ('flex-wrap (not (ebox-style--flex-wrap-p value))) + ('justify-content (not (ebox-style--justify-content-p value))) + ('align-items (not (ebox-style--align-items-p value))) + ('align-content (not (ebox-style--align-content-p value))) + ('justify-items (not (ebox-style--justify-items-p value))) + ('align-self (not (ebox-style--align-self-p value))) + ('grid-auto-flow (not (ebox-style--grid-auto-flow-p value))) + ('grid-track-list (not (ebox-style--grid-track-list-p value))) + ('grid-track (not (ebox-style--grid-auto-track-p value))) + ('order (not (ebox-style--order-p value))) + ('flex-factor + (and (numberp value) (not (ebox-style--nonnegative-number-p value)))) + ('justify-self (not (ebox-style--justify-self-p value))) ('wrap-mode (not (ebox-style--wrap-mode-p value))))) @@ -920,14 +1264,37 @@ not affect declaration compilation and are intentionally excluded." (plist-get schema :name) value)) declarations) -(defun ebox-style--merge-ordered-author-declarations (declarations) - "Expand ordered Ebox DECLARATIONS as distinct normalization groups. -Ebox currently preserves CSS shorthand/longhand declaration order, while its -own author validator rejects exact aliases and explicitly exclusive outputs." - (apply #'ecss-merge-declarations - ebox-style-schemas - (cl-loop for (property value) on declarations by #'cddr - collect (list property value)))) +(defconst ebox-style--edge-projection-groups + '((:padding-top ebox/padding-top ebox/padding-block-start) + (:padding-right ebox/padding-right ebox/padding-inline-end) + (:padding-bottom ebox/padding-bottom ebox/padding-block-end) + (:padding-left ebox/padding-left ebox/padding-inline-start) + (:margin-top ebox/margin-top ebox/margin-block-start) + (:margin-right ebox/margin-right ebox/margin-inline-end) + (:margin-bottom ebox/margin-bottom ebox/margin-block-end) + (:margin-left ebox/margin-left ebox/margin-inline-start)) + "Canonical facts sharing one fixed horizontal-tb/ltr physical edge.") + +(defconst ebox-style--edge-projection-targets + (cl-loop for (target . properties) in ebox-style--edge-projection-groups + append (mapcar (lambda (property) (cons property target)) + properties)) + "Canonical edge property to fixed physical projection target.") + +(defun ebox-style--validate-edge-projection-conflicts (declarations) + "Reject canonical DECLARATIONS that target one physical edge twice." + (let ((seen (make-hash-table :test #'eq))) + (cl-loop for (property _value) on declarations by #'cddr + for target = (alist-get property + ebox-style--edge-projection-targets) + when target + do (when-let* ((previous (gethash target seen))) + (signal 'ecss-invalid-declaration + (list :duplicate-edge-target target + :first-property previous + :second-property property))) + and do (puthash target property seen))) + declarations) (defun ebox-style-compile-declarations (plist &optional strict) "Compile Ebox PLIST aliases to ECSS schema declarations. @@ -935,7 +1302,6 @@ When STRICT is non-nil, reject properties outside the Ebox style domain." (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))) @@ -953,28 +1319,23 @@ When STRICT is non-nil, reject properties outside the Ebox style domain." property))) (let ((compiled (ebox-style--validate-declaration-values - (ebox-style--merge-ordered-author-declarations canonical)))) + (ebox-style--validate-edge-projection-conflicts + (ecss-expand-declarations ebox-style-schemas canonical))))) (when (>= (hash-table-count ebox-style--declaration-cache) ebox-style--declaration-cache-max-entries) (clrhash ebox-style--declaration-cache)) (puthash cache-key compiled ebox-style--declaration-cache) (copy-sequence compiled)))))) -(defconst ebox-style--text-form-properties - '(:color :background-color - :font :font-family :font-height :font-weight :font-slant) - "Canonical property names accepted by the Text author form.") - (defun ebox-style--form-property-allowed-p (tag property) "Return non-nil when author TAG accepts PROPERTY metadata." - (let ((name (plist-get property :name)) - (contexts (plist-get property :contexts))) - (if (eq tag 'text) - (memq name ebox-style--text-form-properties) - (or (memq 'box contexts) - (memq 'item contexts) - (and (memq tag '(flex grid)) - (memq tag contexts)))))) + (let ((contexts (plist-get property :contexts)) + (author-contexts (plist-get property :author-contexts))) + (if author-contexts + (memq tag author-contexts) + (or (memq tag contexts) + (and (not (eq tag 'text)) + (or (memq 'box contexts) (memq 'item contexts))))))) (defun ebox-style-validate-form-declarations (tag declarations) "Validate canonical DECLARATIONS for Text/Box author TAG." @@ -998,10 +1359,27 @@ When STRICT is non-nil, reject properties outside the Ebox style domain." (ebox-style--validate-declaration-values declarations) declarations) +(defun ebox-style--validate-author-values (plist) + "Reject explicit author values outside the public property grammars. +An ECSS schema may use nil as an internal initial value, but nil is not an +author value unless the public grammar is boolean." + (unless (ebox-style--valid-plist-p plist) + (user-error "Ebox style declarations must be an even property list")) + (cl-loop for (name value) on plist by #'cddr + for property = (ebox-style--property name) + when (and property + (null value) + (not (eq (plist-get property :validator) 'boolean))) + do (error "ebox: invalid value for %S: nil" + (plist-get property :name))) + plist) + (defun ebox-style-compile-form (tag plist) "Compile evaluated author PLIST once and validate it for TAG." (ebox-style-validate-form-declarations - tag (ebox-style-compile-declarations plist t))) + tag + (ebox-style-compile-declarations + (ebox-style--validate-author-values plist) t))) (defun ebox-style-declaration-properties (declarations predicate) "Return canonical author properties in DECLARATIONS matching PREDICATE." @@ -1062,6 +1440,8 @@ ORIGIN, LAYER, and SCOPE use ECSS cascade semantics." (defconst ebox-style--paint-delta-properties '(ebox/color ebox/background-color + ebox/text-decoration-line ebox/text-decoration-color + ebox/text-decoration-style ebox/visibility ebox/border-top-color ebox/border-right-color ebox/border-bottom-color ebox/border-left-color) "Canonical paint properties that may change without changing geometry.") @@ -1273,13 +1653,34 @@ child colors are handled by `ebox-style--theme-parent-delta-computed'." (plist-get (ecss-computed-style-values style) (ebox-style-schema-id property))) +(defvar ebox-style--computed-snapshot-cache + (make-hash-table :test #'eq :weakness 'key) + "Immutable Ebox projection snapshots keyed by computed style identity.") + (defun ebox-style--computed-snapshot (style &optional specified-p) - "Return one detached ECSS values snapshot for STYLE projection. -When SPECIFIED-P is non-nil, include declaration-winner facts." - (list (ecss-computed-style-values style) - (ecss-computed-style-active-properties style) - (and specified-p - (ecss-computed-style-specified-properties style)))) + "Return one internal immutable ECSS snapshot for STYLE projection. +When SPECIFIED-P is non-nil, include declaration-winner facts. Ebox +materializes each immutable computed style once per snapshot shape; callers +must treat the returned values as read-only." + (let* ((entry + (or (gethash style ebox-style--computed-snapshot-cache) + (let ((created (vector nil nil))) + (puthash style created ebox-style--computed-snapshot-cache) + created))) + (index (if specified-p 1 0)) + (snapshot (aref entry index))) + (or snapshot + (let* ((other (aref entry (- 1 index))) + (values (if other (nth 0 other) + (ecss-computed-style-values style))) + (active (if other (nth 1 other) + (ecss-computed-style-active-properties style))) + (created + (list values active + (and specified-p + (ecss-computed-style-specified-properties style))))) + (aset entry index created) + created)))) (defun ebox-style--snapshot-active-p (snapshot property) "Return non-nil when SNAPSHOT actively contributes PROPERTY." @@ -1311,6 +1712,26 @@ SNAPSHOT reuses a previously detached ECSS values snapshot when supplied." (plist-get values id)))))) result)) +(defun ebox-style--project-edge-values (style values snapshot) + "Resolve logical/physical VALUES through STYLE's cross-property winners. +SNAPSHOT supplies the already-read computed value table." + (let* ((edge-names + (mapcar (lambda (entry) + (ebox-style-canonical-name (car entry))) + ebox-style--edge-projection-targets)) + (result + (cl-loop for (property value) on values by #'cddr + unless (memq property edge-names) + append (list property value)))) + (dolist (group ebox-style--edge-projection-groups result) + (let* ((target (car group)) + (winner + (ecss-computed-style-precedence-winner style (cdr group)))) + (when winner + (setq result + (plist-put result target + (plist-get (car snapshot) winner)))))))) + (defun ebox-style--specified-property-p (style property) "Return non-nil when STYLE PROPERTY has a declaration winner." (ecss-computed-style-specified-p style property)) @@ -1371,7 +1792,9 @@ explicit generation SOURCE-INDEX; facts are never recovered from its handle." (defun ebox-style--apply-engine-values (box values) "Project public style VALUES into BOX engine fields." (ebox-style--reset-box-engine-style box) - (let ((expanded (ebox-style-expand-ebox-plist values))) + (let ((expanded + (ebox-style--expand-engine-plist + (ebox-style--without-font-values values)))) (cl-loop for (property value) on expanded by #'cddr do (plist-put box property value))) box) @@ -1394,10 +1817,11 @@ explicit generation SOURCE-INDEX; facts are never recovered from its handle." (if (not (eq cached missing)) cached (let ((expanded - (ebox-style-expand-ebox-plist - (if text-p - (ebox-style--text-values style snapshot) - (ebox-style--box-values style snapshot))))) + (ebox-style--expand-engine-plist + (ebox-style--without-font-values + (if text-p + (ebox-style--text-values style snapshot) + (ebox-style--box-values style snapshot)))))) (puthash style expanded cache) expanded)))) @@ -1411,7 +1835,10 @@ explicit generation SOURCE-INDEX; facts are never recovered from its handle." (defun ebox-style--box-values (style &optional snapshot) "Return computed box values ready for Ebox engine projection." (let* ((snapshot (or snapshot (ebox-style--computed-snapshot style))) - (values (ebox-style--context-values style 'box nil snapshot))) + (values + (ebox-style--project-edge-values + style (ebox-style--context-values style 'box nil snapshot) + snapshot))) (when (and (ebox-style--snapshot-active-p snapshot :color) (null (plist-get values :color))) (setq values @@ -1420,32 +1847,48 @@ explicit generation SOURCE-INDEX; facts are never recovered from its handle." (defun ebox-style--text-values (style snapshot) "Return computed STYLE values owned by canonical Text." - (cl-loop for (name value) on (ebox-style--box-values style snapshot) - by #'cddr - when (or (ebox-style-property-accepted-p 'text name) - (eq name :wrap-mode)) - append (list name value))) + (let ((values (ebox-style--context-values style 'text nil snapshot))) + (when (and (ebox-style--snapshot-active-p snapshot :color) + (null (plist-get values :color))) + (setq values + (plist-put values :color ebox-style--default-foreground))) + ;; Wrap policy is authored only by Box, then inherited as Text's + ;; measurement input without creating a second Text author property. + (when (ebox-style--snapshot-active-p snapshot :wrap-mode) + (setq values + (plist-put values :wrap-mode + (plist-get (car snapshot) 'ebox/wrap-mode)))) + values)) -(defun ebox-style--closed-inheritance-values (style) +(defconst ebox-style--font-property-names + '(:font-family :font-size :font-weight :font-style) + "Canonical font inputs owned by one FontMetricsProjection.") + +(defun ebox-style--without-font-values (values) + "Return computed VALUES without canonical font inputs." + (cl-loop for (property value) on values by #'cddr + unless (memq property ebox-style--font-property-names) + append (list property value))) + +(defun ebox-style--closed-inheritance-values (style &optional snapshot) "Return STYLE's immutable canonical inheritance projection." (let* ((missing (make-symbol "ebox-inheritance-missing")) (cached (gethash style ebox-style--closed-inheritance-cache missing))) (if (not (eq cached missing)) cached - (let* ((snapshot (ebox-style--computed-snapshot style)) + (let* ((snapshot (or snapshot (ebox-style--computed-snapshot style))) (values (ebox-style--box-values style snapshot)) result) - (dolist (property '(:color :font :font-family :font-height - :font-weight :font-slant :wrap-mode)) + (dolist (property '(:color :wrap-mode)) (when (plist-member values property) (setq result (plist-put result property (plist-get values property))))) (puthash style result ebox-style--closed-inheritance-cache) result)))) -(defun ebox-style-apply-closed-inheritance (node style) +(defun ebox-style-apply-closed-inheritance (node style &optional snapshot) "Apply only closed computed inheritance from STYLE to fresh canonical NODE." - (let ((values (ebox-style--closed-inheritance-values style))) + (let ((values (ebox-style--closed-inheritance-values style snapshot))) (plist-put node :ebox-computed-style style) (cl-loop for (property value) on values by #'cddr do (plist-put node property value))) @@ -1461,7 +1904,7 @@ until their private encoding is removed." (defconst ebox-style--container-wrapper-neutral-properties '(:width :height :box-sizing :color - :font :font-family :font-height :font-weight :font-slant) + :font-family :font-size :font-weight :font-style) "Container properties that do not require an internal visual wrapper.") (defun ebox-style--container-wrapper-needed-p (style &optional snapshot) @@ -1506,21 +1949,6 @@ until their private encoding is removed." (unless (ebox-style--specified-property-p style 'ebox/wrap-mode) (plist-put wrapper :wrap-mode nil)))) -(defun ebox-style--grid-values (values) - "Translate canonical container VALUES to Grid engine property names." - (let ((row-present (plist-member values :row-gap)) - (column-present (plist-member values :column-gap))) - (when row-present - (setq values - (plist-put values :grid-row-gap (plist-get values :row-gap)))) - (when column-present - (setq values - (plist-put values :grid-column-gap - (plist-get values :column-gap)))) - (cl-loop for (property value) on values by #'cddr - unless (memq property '(:row-gap :column-gap)) - append (list property value)))) - (defun ebox-style--apply-container (node style &optional snapshot) "Apply computed STYLE to Flex or Grid NODE." (let* ((type (plist-get node :ebox-type)) @@ -1529,10 +1957,7 @@ until their private encoding is removed." (values (append (ebox-style--context-values style 'box nil snapshot) (ebox-style--context-values style type nil snapshot))) - (raw (append metadata - (if (eq type 'grid) - (ebox-style--grid-values values) - values)))) + (raw (append metadata values))) (plist-put node :raw-props raw) (plist-put node :props (if (eq type 'flex) @@ -1604,7 +2029,7 @@ until their private encoding is removed." (defconst ebox-style--item-projection-properties '(:order :flex-grow :flex-shrink :flex-basis :align-self - :grid-column :grid-row :grid-column-span :grid-row-span) + :grid-column :grid-row :justify-self) "Canonical node fields owned by computed parent participation style.") (defun ebox-style--transfer-node-property (old new property) @@ -1650,44 +2075,32 @@ owned by NEW; only fields owned by the unchanged computed style are shared." do (plist-put node property value))) node) -(defun ebox-style--apply-display-axes (node style snapshot) - "Apply computed display and public axes from STYLE SNAPSHOT to NODE." - (let* ((display-specified-p - (ebox-style--specified-property-p style 'ebox/display)) - (outer-specified-p - (ebox-style--specified-property-p style 'ebox/outer)) - (layout-specified-p - (ebox-style--specified-property-p style 'ebox/layout))) +(defun ebox-style--apply-display-axes (node style) + "Apply computed public outer participation from STYLE to NODE." + (let ((outer-specified-p + (ebox-style--specified-property-p style 'ebox/outer))) (if (eq (plist-get node :ebox-kind) 'text) (progn - (when (or display-specified-p outer-specified-p layout-specified-p) + (when outer-specified-p (error "Ebox Text is fixed inline and rejects display axes")) (plist-put node :display '(inline flow))) - (when (ebox-style--snapshot-active-p snapshot :display) - (plist-put node :display - (ebox-style-computed-value style :display))) - (when (or outer-specified-p layout-specified-p) - (let* ((display (or (plist-get node :display) '(block flow))) - (outer (if outer-specified-p - (ebox-style-computed-value style :outer) - (car display))) - (layout (if layout-specified-p - (ebox-style-computed-value style :layout) - (cadr display)))) + (when outer-specified-p + (let ((display (or (plist-get node :display) '(block flow)))) (plist-put node :display - (list outer (if (eq layout 'normal) 'flow layout))))))) + (list (ebox-style-computed-value style :outer) + (cadr display))))))) node) -(defun ebox-style-apply-computed (node style) +(defun ebox-style-apply-computed (node style &optional snapshot) "Apply ECSS computed STYLE consequences to semantic Ebox NODE." (unless (ecss-computed-style-p style) (signal 'wrong-type-argument (list 'ecss-computed-style-p style))) (let* ((type (plist-get node :ebox-type)) (config (plist-get node :ebox-layout-config)) (kind (and config (ebox-layout-config-kind config))) - (snapshot (ebox-style--computed-snapshot style t))) + (snapshot (or snapshot (ebox-style--computed-snapshot style t)))) (plist-put node :ebox-computed-style style) - (ebox-style--apply-display-axes node style snapshot) + (ebox-style--apply-display-axes node style) (cond ((or (memq (plist-get node :ebox-kind) '(text box)) (eq type 'box)) @@ -1730,7 +2143,7 @@ owned by NEW; only fields owned by the unchanged computed style are shared." (list (car entry) (cdr entry))) entries)))) -(defconst ebox-style-ebox-property-rules +(defconst ebox-style--engine-projection-rules '((:padding :expand (:padding-top :padding-right :padding-bottom :padding-left) :mode trbl) @@ -1788,17 +2201,17 @@ owned by NEW; only fields owned by the unchanged computed style are shared." :border-bottom-color :border-left-color) :mode trbl) - (:border-top-width :to :border-top-p :conv bool) - (:border-top-style :to nil) + (:border-top-width :to :border-top-pixel :conv border-pixel) + (:border-top-style :to :border-top-style) (:border-top-color :to :border-top-color :conv color) - (:border-bottom-width :to :border-bottom-p :conv bool) - (:border-bottom-style :to nil) + (:border-bottom-width :to :border-bottom-pixel :conv border-pixel) + (:border-bottom-style :to :border-bottom-style) (:border-bottom-color :to :border-bottom-color :conv color) - (:border-left-width :to :border-left-pixel :conv pixel) - (:border-left-style :to nil) + (:border-left-width :to :border-left-pixel :conv border-pixel) + (:border-left-style :to :border-left-style) (:border-left-color :to :border-left-color :conv color) - (:border-right-width :to :border-right-pixel :conv pixel) - (:border-right-style :to nil) + (:border-right-width :to :border-right-pixel :conv border-pixel) + (:border-right-style :to :border-right-style) (:border-right-color :to :border-right-color :conv color) (:width :to :width :conv preferred-size) (:min-width :to :min-width :conv min-size) @@ -1806,24 +2219,32 @@ owned by NEW; only fields owned by the unchanged computed style are shared." (:height :to :height) (:min-height :to :min-height) (:max-height :to :max-height) + (:outer :to nil) (:content :to :content) (:box-sizing :to :box-sizing) (:color :to :color :conv color) (:background-color :to :bgcolor :conv color) (:bgcolor :to :bgcolor :conv color) + (:text-decoration-line :to :text-decoration-line) + (:text-decoration-color :to :text-decoration-color :conv color) + (:text-decoration-style :to :text-decoration-style) (:text-align :to :text-align) - (:vertical-align :to :vertical-align :conv vertical-align) (:overflow :to :overflow) (:wrap-mode :to :wrap-mode) (:display :to nil) (:visibility :to :visibility)) - "Compatibility rules that expand public Ebox properties to engine keys.") + "Canonical property projections into private runtime storage keys.") (defun ebox-style--convert (value converter) "Convert VALUE using Ebox engine CONVERTER." (pcase converter ('pixel (or (ebox--nonnegative-horizontal-size-pixels value nil) 0)) ('size-pixel (or (ebox--nonnegative-horizontal-size-pixels value nil) 0)) + ('border-pixel + (unless (ebox-style--border-width-p value) + (error "ebox: border width must be a nonnegative integer pixel value: %S" + value)) + value) ('preferred-size (ebox--normalize-horizontal-size-value value ebox--preferred-size-keywords)) @@ -1840,11 +2261,6 @@ owned by NEW; only fields owned by the unchanged computed style are shared." (error "ebox: line values cannot be negative: %S" value)) value) 0)) - ('bool - (when (ebox-style--negative-numeric-size-p value) - (error "ebox: border width values cannot be negative: %S" value)) - (and value (not (eq value 0)))) - ('vertical-align (if (eq value 'middle) 'center value)) ('color (ebox-style--parse-color value)) (_ value))) @@ -1854,7 +2270,7 @@ owned by NEW; only fields owned by the unchanged computed style are shared." :margin-left-pixel :margin-right-pixel :margin-top-height :margin-bottom-height :border-left-pixel :border-right-pixel - :border-top-p :border-bottom-p) + :border-top-pixel :border-bottom-pixel) "Engine edge longhands that cannot represent negative geometry.") (defun ebox-style--validate-edge-longhands (plist) @@ -1868,6 +2284,18 @@ owned by NEW; only fields owned by the unchanged computed style are shared." (error "ebox: %S cannot be negative: %S" property value)))) plist)) +(defun ebox-style--apply-border-used-widths (plist) + "Return PLIST with border widths resolved against their styles. +Canonical width remains in the computed style; private engine pixels are the +used geometry and become zero when the corresponding style is not `solid'." + (dolist (side '(top right bottom left) plist) + (let ((width-key (intern (format ":border-%s-pixel" side))) + (style-key (intern (format ":border-%s-style" side)))) + (when (or (plist-member plist width-key) + (plist-member plist style-key)) + (unless (eq (plist-get plist style-key) 'solid) + (setq plist (plist-put plist width-key 0))))))) + (defun ebox-style--split-value (value mode n) "Split VALUE according to MODE for N targets." (pcase mode @@ -1878,10 +2306,10 @@ owned by NEW; only fields owned by the unchanged computed style are shared." (_ (make-list n value)))) (defun ebox-style--get-ebox-rule (property) - "Return Ebox compatibility expansion rule for PROPERTY." - (cdr (assq property ebox-style-ebox-property-rules))) + "Return Ebox private engine projection rule for PROPERTY." + (cdr (assq property ebox-style--engine-projection-rules))) -(defun ebox-style-expand-ebox-property (property value) +(defun ebox-style--expand-engine-property (property value) "Expand public Ebox PROPERTY with VALUE to engine longhands." (let ((rule (ebox-style--get-ebox-rule property))) (cond @@ -1890,7 +2318,7 @@ owned by NEW; only fields owned by the unchanged computed style are shared." (let* ((targets (plist-get rule :expand)) (mode (plist-get rule :mode)) (parts (ebox-style--split-value value mode (length targets)))) - (cl-mapcan #'ebox-style-expand-ebox-property targets parts))) + (cl-mapcan #'ebox-style--expand-engine-property targets parts))) ((plist-member rule :to) (let ((target (plist-get rule :to)) (converter (plist-get rule :conv))) @@ -1899,16 +2327,83 @@ owned by NEW; only fields owned by the unchanged computed style are shared." nil))) (t (list property value))))) -(defun ebox-style-expand-ebox-plist (plist) - "Expand PLIST using Ebox compatibility property rules." +(defun ebox-style--engine-projection-targets (property) + "Return private engine keys potentially projected from PROPERTY." + (let* ((name (or (ebox-style-canonical-name property) property)) + (rule (ebox-style--get-ebox-rule name))) + (cond + ((null rule) (list name)) + ((plist-get rule :expand) + (delete-dups + (cl-mapcan #'ebox-style--engine-projection-targets + (plist-get rule :expand)))) + ((plist-member rule :to) + (when-let* ((target (plist-get rule :to))) + (list target))) + (t (list name))))) + +(defun ebox-style--expand-engine-plist (plist) + "Project canonical PLIST into private Ebox runtime fields." (let (expanded) (while plist (let ((property (pop plist)) (value (pop plist))) (setq expanded (nconc expanded - (ebox-style-expand-ebox-property property value))))) - (ebox-style--validate-edge-longhands expanded))) + (ebox-style--expand-engine-property property value))))) + (ebox-style--apply-border-used-widths + (ebox-style--validate-edge-longhands expanded)))) + +(defun ebox-style--border-delta-value + (declarations full-declarations style name) + "Return border NAME from delta, complete declarations, STYLE, or initial." + (let* ((definition (ebox-style--property name)) + (id (plist-get definition :id))) + (cond + ((plist-member declarations id) (plist-get declarations id)) + ((plist-member full-declarations id) (plist-get full-declarations id)) + ((and style (ecss-computed-style-p style)) + (ebox-style-computed-value style name)) + (t (copy-tree (plist-get definition :initial)))))) + +(defun ebox-style--expand-engine-delta + (declarations full-declarations current-style) + "Project canonical DECLARATIONS against CURRENT-STYLE. +Ordinary properties remain change-local. Border width/style pairs are +completed from FULL-DECLARATIONS and CURRENT-STYLE before deriving their used +engine pixels." + (let (ordinary affected-sides) + (cl-loop for (id value) on declarations by #'cddr + for definition = (ebox-style--property id) + for name = (plist-get definition :name) + for side = (cl-loop for candidate in '(top right bottom left) + when (memq name + (list + (intern (format + ":border-%s-width" + candidate)) + (intern (format + ":border-%s-style" + candidate)))) + return candidate) + if side do (cl-pushnew side affected-sides) + else do (setq ordinary (append ordinary (list name value)))) + (let ((expanded (ebox-style--expand-engine-plist ordinary))) + (dolist (side (nreverse affected-sides) expanded) + (let ((width-name (intern (format ":border-%s-width" side))) + (style-name (intern (format ":border-%s-style" side)))) + (setq expanded + (append + expanded + (ebox-style--expand-engine-plist + (list width-name + (ebox-style--border-delta-value + declarations full-declarations + current-style width-name) + style-name + (ebox-style--border-delta-value + declarations full-declarations + current-style style-name)))))))))) (provide 'ebox-style) diff --git a/ebox-surface.el b/ebox-surface.el index a83a962..c4dcaea 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -13,6 +13,7 @@ (require 'cl-lib) (require 'seq) (require 'ebox-buffer-backend) +(require 'ebox-font) (require 'ebox-style) (require 'ebox-tree) (require 'ebox-layout) @@ -49,6 +50,9 @@ (defvar ebox-surface--inline-style-value-cache nil "Per-projection cache for selector-free computed inline styles.") +(defvar ebox-surface--font-window nil + "Canonical target window frozen for one Surface projection.") + (defun ebox-surface--mixed-range-splice-p (state) "Return non-nil when STATE's mixed proof contains one Range splice." (cl-some @@ -633,7 +637,8 @@ and resize paths must consume this same choice." :display-signature (value :display-signature (with-current-buffer buffer - (ebox--current-display-signature))) + (ebox--display-signature-for-window window))) + :display-window window :scroll-offsets (ebox-surface--scroll-offsets scroll-table))))) @@ -1025,23 +1030,25 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil." success) (unwind-protect (progn - (tp-with-transaction - (ebox-surface--stage-signal-values signals context-values) - (if surface - (tp-surface-update surface producer) - (setq surface - (tp-surface-mount - buffer producer - (append - '(:capability content :inhibit-read-only t - :coordinate-mounts t) - (when-let* ((bridge - (with-current-buffer buffer - ebox-surface--tp-observer))) - (list :observers (list bridge))))))) - (ebox-surface--publish-runtime-state - buffer surface old-state report-base after-publication - framework-participant observation-context)) + (let ((ebox-surface--font-window + (plist-get context-values :display-window))) + (tp-with-transaction + (ebox-surface--stage-signal-values signals context-values) + (if surface + (tp-surface-update surface producer) + (setq surface + (tp-surface-mount + buffer producer + (append + '(:capability content :inhibit-read-only t + :coordinate-mounts t) + (when-let* ((bridge + (with-current-buffer buffer + ebox-surface--tp-observer))) + (list :observers (list bridge))))))) + (ebox-surface--publish-runtime-state + buffer surface old-state report-base after-publication + framework-participant observation-context))) (setq success t) (let ((scroll-keys (if framework-participant @@ -1124,9 +1131,10 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil." (let* ((old-width (plist-get state :viewport-width)) (old-height (plist-get state :viewport-height)) (target-height (or height old-height)) + (window (ebox-surface--buffer-display-window buffer)) (display-signature (with-current-buffer buffer - (ebox--current-display-signature))) + (ebox--display-signature-for-window window))) (display-changed (not (equal display-signature (plist-get state :display-signature)))) @@ -1859,14 +1867,16 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil." (setq on-mismatch 'root)) (unwind-protect (progn - (tp-with-transaction - (ebox-surface--stage-signal-values signals context-values) - (tp-surface-update-scoped - surface objects producer - (list :on-mismatch on-mismatch :return-report nil)) - (ebox-surface--publish-runtime-state - buffer surface old-state report-base after-publication - framework-participant observation-context)) + (let ((ebox-surface--font-window + (plist-get context-values :display-window))) + (tp-with-transaction + (ebox-surface--stage-signal-values signals context-values) + (tp-surface-update-scoped + surface objects producer + (list :on-mismatch on-mismatch :return-report nil)) + (ebox-surface--publish-runtime-state + buffer surface old-state report-base after-publication + framework-participant observation-context))) (setq success t) (let ((scroll-keys (if framework-participant @@ -2043,6 +2053,7 @@ published generation so a failed transaction has no cache state to restore." (plist-put reused-state :selector-tree-token selector-tree-token) (plist-put reused-state :subject subject) + (plist-put reused-state :node node) (plist-put reused-state :parent-style-state parent-style-state) reused-state) @@ -2058,7 +2069,8 @@ published generation so a failed transaction has no cache state to restore." 0 (if parent-binding 1 0))) :stylesheet-signature stylesheet-signature - :subject subject))) + :subject subject + :node node))) (plist-put new-state :compute (let ((captured new-state) @@ -2142,6 +2154,38 @@ published generation so a failed transaction has no cache state to restore." (puthash key style ebox-surface--inline-style-value-cache) style))))) +(defun ebox-surface--project-font + (node style &optional parent-font-fact snapshot) + "Install NODE's font fact from STYLE, capability, and PARENT-FONT-FACT." + (let ((snapshot (or snapshot (ebox-style--computed-snapshot style t)))) + (plist-put + node :ebox-font-fact + (ebox-font-project-values + (car snapshot) (nth 2 snapshot) + (ebox--current-display-signature) + (or ebox-surface--font-window + (ebox-surface--buffer-display-window (current-buffer))) + parent-font-fact))) + node) + +(defun ebox-surface--refresh-font-projections (root) + "Re-resolve retained computed font facts below candidate ROOT. +This is a host-capability projection only: it reuses each immutable computed +style and never re-runs selector matching or cascade." + (cl-labels + ((visit (node parent-font-fact) + (when (and (listp node) (not (stringp node))) + (when-let* ((style (plist-get node :ebox-computed-style)) + ((ecss-computed-style-p style))) + (ebox-surface--project-font + node style parent-font-fact + (ebox-style--computed-snapshot style t))) + (let ((fact (plist-get node :ebox-font-fact))) + (ebox-tree-for-each-direct-child + node (lambda (child) (visit child fact))))))) + (visit root nil)) + root) + (defun ebox-surface--closed-canonical-node-p (source-index node) "Return non-nil when NODE in SOURCE-INDEX needs closed inheritance." (let ((declarations @@ -2260,18 +2304,27 @@ source-handle-to-subject table when the requested local preparation completed." subject declarations parent-style))))) (when (and node style (not (eq node old-node))) - (cond - ((and source-stable-p - (ebox-style--computed-projection-transferable-p - old-node node style)) - (ebox-style--transfer-computed-projection - old-node node style)) - ((and (null (plist-get node :ebox-computed-style)) - (ebox-surface--closed-canonical-node-p - new-source-index node)) - (ebox-style-apply-closed-inheritance node style)) - (t - (ebox-style-apply-computed node style))) + (let ((snapshot + (ebox-style--computed-snapshot style t))) + (ebox-surface--project-font + node style + (and parent-id + (plist-get (gethash parent-id new-nodes) + :ebox-font-fact)) + snapshot) + (cond + ((and source-stable-p + (ebox-style--computed-projection-transferable-p + old-node node style)) + (ebox-style--transfer-computed-projection + old-node node style)) + ((and (null (plist-get node :ebox-computed-style)) + (ebox-surface--closed-canonical-node-p + new-source-index node)) + (ebox-style-apply-closed-inheritance + node style snapshot)) + (t + (ebox-style-apply-computed node style snapshot)))) (plist-put node :ebox-computed-style style) (plist-put node :ebox-candidate-computed-style-p t)) (remhash node-id visiting) @@ -2303,18 +2356,35 @@ source-handle-to-subject table when the requested local preparation completed." (when-let* ((subject (ebox-source--subject-table-get subject-table (ebox-tree-node-source-handle node)))) - (if (and (hash-table-p ebox-surface--inline-style-value-cache) - (not (ebox-surface--node-style-binding-required-p - source-index node))) + (let* ((parent-subject (ecss-subject-parent subject)) + (parent-state (and parent-subject + (gethash parent-subject states-by-subject))) + (parent-node (and parent-state (plist-get parent-state :node))) + (parent-font-fact + (and parent-node (plist-get parent-node :ebox-font-fact))) + (precomputed-p + (plist-get node :ebox-candidate-computed-style-p)) + (precomputed-style + (and precomputed-p (plist-get node :ebox-computed-style)))) + (if (and (hash-table-p ebox-surface--inline-style-value-cache) + (not (ebox-surface--node-style-binding-required-p + source-index node))) (when-let* ((parent (ecss-subject-parent subject)) (binding (gethash parent bindings-by-subject))) (puthash subject binding bindings-by-subject) (when-let* ((state (gethash parent states-by-subject))) (puthash subject state states-by-subject)) (let ((style (tp-binding-read binding))) - (unless (equal style (plist-get node :ebox-computed-style)) - (ebox-style-apply-closed-inheritance node style)))) - (let ((style + (unless (and precomputed-p (equal style precomputed-style)) + (let ((snapshot (ebox-style--computed-snapshot style t))) + (ebox-surface--project-font + node style parent-font-fact snapshot) + (unless (equal style (plist-get node :ebox-computed-style)) + (ebox-style-apply-closed-inheritance + node style snapshot))))) + (when precomputed-p + (cl-remf node :ebox-candidate-computed-style-p))) + (let* ((style (tp-binding-read (ebox-surface--node-style-binding source-index object node subject @@ -2322,12 +2392,15 @@ source-handle-to-subject table when the requested local preparation completed." style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p)))) - (unless (equal style (plist-get node :ebox-computed-style)) - (if (and (null (plist-get node :ebox-computed-style)) - (ebox-surface--closed-canonical-node-p - source-index node)) - (ebox-style-apply-closed-inheritance node style) - (ebox-style-apply-computed node style))))))) + (unless (and precomputed-p (equal style precomputed-style)) + (let ((snapshot (ebox-style--computed-snapshot style t))) + (ebox-surface--project-font node style parent-font-fact snapshot) + (unless (equal style (plist-get node :ebox-computed-style)) + (if (and (null (plist-get node :ebox-computed-style)) + (ebox-surface--closed-canonical-node-p + source-index node)) + (ebox-style-apply-closed-inheritance node style snapshot) + (ebox-style-apply-computed node style snapshot)))))))))) (defun ebox-surface--static-style-state-p (state &optional seen) "Return non-nil when STATE's inherited binding closure is static. @@ -3611,8 +3684,8 @@ box with wrapper chrome falls back to the retained wrapper renderer." (= (or (ebox-get cached-box :margin-bottom-height) 0) 0) (= (or (ebox-get cached-box :border-left-pixel) 0) 0) (= (or (ebox-get cached-box :border-right-pixel) 0) 0) - (not (ebox-get cached-box :border-top-p)) - (not (ebox-get cached-box :border-bottom-p)) + (= (or (ebox-get cached-box :border-top-pixel) 0) 0) + (= (or (ebox-get cached-box :border-bottom-pixel) 0) 0) (eq (ebox-get cached-box :vertical-align) 'top) (null (ebox-get cached-box :surface-properties)))) (visible @@ -4459,6 +4532,9 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (when (ebox-style--text-paint-owner-p box) (when-let* ((font (ebox-buffer--font-face box))) (push font faces)) + (when-let* ((decoration + (ebox-buffer--text-decoration-face box))) + (push decoration faces)) (when-let* ((color (plist-get box :color))) (push (ebox-surface--foreground-face color) faces))) (when-let* ((background (plist-get box :bgcolor))) @@ -4482,12 +4558,14 @@ ANCESTOR-P means BOX wraps a more specific rendered surface." ((or ancestor-p (cl-intersection roles '(content content-owner pt pb pl pr))) (setq faces (ebox-surface--box-face-contributions box)))) - (when (and (memq 'bt roles) (plist-get box :border-top-p)) + (when (and (memq 'bt roles) + (> (or (plist-get box :border-top-pixel) 0) 0)) (setq faces (append faces (list (list :overline (or (plist-get box :border-top-color) t)))))) - (when (and (memq 'bb roles) (plist-get box :border-bottom-p)) + (when (and (memq 'bb roles) + (> (or (plist-get box :border-bottom-pixel) 0) 0)) (setq faces (append faces @@ -4698,10 +4776,15 @@ relative face contributions and performs the final merge during prepare." (when-let* ((node (gethash (plist-get dirty :node-id) nodes)) (box (ebox-fragment-style-source-node node)) (region-id (plist-get box :region-id))) - (dolist (entry '((:border-top-p . bt) (:border-bottom-p . bb))) - (when (memq (car entry) (plist-get dirty :changed-keys)) + (dolist (entry '(((:border-top-width :border-top-style) + bt :border-top-pixel) + ((:border-bottom-width :border-bottom-style) + bb :border-bottom-pixel))) + (when (cl-intersection (car entry) + (plist-get dirty :changed-keys)) (ebox-surface--set-horizontal-border-role - fragments region-id (cdr entry) (plist-get box (car entry))))))))) + fragments region-id (cadr entry) + (> (or (plist-get box (caddr entry)) 0) 0)))))))) (defun ebox-surface--repaint-fragment (fragment old-state new-state &optional old-face-cache new-face-cache) @@ -4978,6 +5061,27 @@ published runtime and must be consumed without clearing those shared nodes." author-style-pending-p (plist-get previous-state :cascade-active-p) (plist-get previous-state :cascade-required-p)))) + (surface-display-signature + (and signals + (tp-signal-read + (ebox-surface--signals-display signals)))) + ;; Font resolution, text measurement, and final paint must see + ;; one immutable host capability for the whole transaction. + (ebox--render-display-signature + (or surface-display-signature + ebox--render-display-signature + (ebox--display-signature))) + (ebox-surface--font-window + (or ebox-surface--font-window + (ebox-surface--buffer-display-window + (current-buffer)))) + (_font-capability-projection + (when (and previous-state surface-display-signature + (not (equal + surface-display-signature + (plist-get previous-state + :display-signature)))) + (ebox-surface--refresh-font-projections root))) (projection (ebox-surface--projection-start context root style-required-p projection-kind previous-state @@ -5043,10 +5147,6 @@ published runtime and must be consumed without clearing those shared nodes." (funcall (if (cdr axes) #'tp-signal-read #'tp-signal-peek) (ebox-surface--signals-viewport-height signals)))) - (display-signature - (and signals - (tp-signal-read - (ebox-surface--signals-display signals)))) (scroll-offsets (and signals (tp-signal-peek @@ -5055,7 +5155,7 @@ published runtime and must be consumed without clearing those shared nodes." (ebox-surface--projection-state root previous-state state-overrides projection-kind stylesheet-active-p cascade-required-p axes viewport-width - viewport-height display-signature scroll-offsets))) + viewport-height surface-display-signature scroll-offsets))) (plist-put state :source-index source-index) (when reuse-context-axes-p (plist-put state :viewport-dependent-node-ids @@ -5140,7 +5240,8 @@ published runtime and must be consumed without clearing those shared nodes." (ebox-surface--projection-state root previous-state state-overrides nil stylesheet-active-p cascade-required-p axes - viewport-width viewport-height display-signature + viewport-width viewport-height + surface-display-signature scroll-offsets)) (plist-put state :source-index source-index) (plist-put state :style-binding-states diff --git a/ebox-tree.el b/ebox-tree.el index 0a4b9b4..28bd633 100644 --- a/ebox-tree.el +++ b/ebox-tree.el @@ -13,6 +13,7 @@ (require 'ecss-selector) (require 'ebox-source) (require 'ebox-style) +(require 'ebox-node-factory) (require 'ebox-child-range) (defvar ebox-tree--incoming-source-indexes nil @@ -657,7 +658,7 @@ handles, reused node objects, and cycles without consulting node metadata." "Properties accepted only on a direct Flex child Box.") (defconst ebox-tree--grid-participation-keys - '(:grid-column :grid-row :grid-column-span :grid-row-span :justify-self) + '(:grid-column :grid-row :justify-self) "Properties accepted only on a direct Grid child Box.") (defconst ebox-tree--participation-keys @@ -797,10 +798,12 @@ and all explicit identities are compared with `equal'. Return ROOT on success." (defconst ebox-tree--excluded-source-keys (append ebox-tree--runtime-source-keys ebox-tree--child-source-keys + ebox-node-factory--style-projection-keys '(:ebox-style-overrides :ebox-source-handle :ebox-grid-config :ebox-computed-style :ebox-style-wrapper + :ebox-font-fact :ebox-style-generated-wrapper :ebox-author-style-count :ebox-author-style-required-p :ebox-text-value :ebox-candidate-computed-style-p)) @@ -831,7 +834,7 @@ look like a geometry mutation." ;; retained node may arrive once from an already-expanded style ;; snapshot and later from fresh shorthand declarations; comparing ;; those source spellings directly creates fake geometry dirtiness. - (raw (ebox-style-expand-ebox-plist raw)) + (raw (ebox-style--expand-engine-plist raw)) (table (make-hash-table :test #'eq))) (cl-loop for (key value) on raw by #'cddr unless (memq key '(:color :background-color :bgcolor :host-ref @@ -840,19 +843,9 @@ look like a geometry mutation." (when (plist-member raw :gap) (let* ((gap (plist-get raw :gap)) (row (if (consp gap) (car gap) gap)) - (column (if (and (consp gap) (cdr gap)) (cadr gap) row)) - ;; Flex and Grid deliberately have separate canonical - ;; property vocabularies. Using Grid aliases for a Flex node - ;; makes an unchanged `gap' look like geometry dirtiness on - ;; the next retained generation. - (row-key (if (eq (plist-get node :ebox-type) 'grid) - :grid-row-gap - :row-gap)) - (column-key (if (eq (plist-get node :ebox-type) 'grid) - :grid-column-gap - :column-gap))) - (puthash row-key row table) - (puthash column-key column table) + (column (if (and (consp gap) (cdr gap)) (cadr gap) row))) + (puthash :row-gap row table) + (puthash :column-gap column table) (remhash :gap table))) (let (keys) (maphash (lambda (key _value) (push key keys)) table) diff --git a/ebox.el b/ebox.el index a83ad78..528647a 100644 --- a/ebox.el +++ b/ebox.el @@ -23,7 +23,8 @@ "Directory containing the active Ebox Lisp sources.") (defconst ebox--compile-sources - '("ebox-cache.el" "ebox-source.el" "ebox-style.el" "ebox-layout-config.el" + '("ebox-cache.el" "ebox-source.el" "ebox-font.el" "ebox-style.el" + "ebox-layout-config.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" @@ -42,6 +43,7 @@ (require 'subr-x) (require 'ebox-cache) (require 'ebox-source) +(require 'ebox-font) (require 'ebox-style) (require 'ebox-layout-config) (require 'ebox-node-factory) @@ -741,78 +743,6 @@ indexes. This prepares component identity without exposing a component API." "Compile an Ebox author form into one opaque canonical input." (ebox-dsl-build-input dsl)) -;;; ============================================================ -;;; Property Rules: 配置驱动的 Shorthand/Longhand 映射 -;;; ============================================================ -;; -;; 规则格式自描述,无需 type 标记: -;; - 值是 list → shorthand,展开到列表中的属性 -;; - 值是 (keyword . converter) → longhand,转换后写入目标 -;; - 值是 keyword → 直通,属性名不变 - -(defconst ebox-property-rules ebox-style-ebox-property-rules - "Compatibility alias for Ebox public property expansion rules.") - -;;; --- 值转换器 --- - -(defun ebox--convert (value converter) - "Convert VALUE using CONVERTER." - (ebox-style--convert value converter)) - -;;; --- 值拆分器 --- - -(defun ebox--split-trbl (value) - "Split VALUE using CSS TRBL rules → (top right bottom left)." - (ebox-style--split-trbl value)) - -(defun ebox--split-pair (value) - "Split VALUE into pair → (start end)." - (ebox-style--split-pair value)) - -(defconst ebox--border-styles - '(none hidden dotted dashed solid double groove ridge inset outset) - "Valid CSS border-style keywords.") - -(defun ebox--wsc-classify (item) - "Classify ITEM as width, style, or color. Returns (type . value)." - (ebox-style--wsc-classify item)) - -(defun ebox--split-wsc (value) - "Split VALUE into (width style color) using CSS type-based parsing. -Values can appear in any order; each is identified by type." - (ebox-style--split-wsc value)) - -(defun ebox--split-value (value mode n) - "Split VALUE according to MODE for N targets." - (ebox-style--split-value value mode n)) - -;;; --- 核心展开函数 --- - -(defun ebox--get-rule (property) - "Get rule for PROPERTY from `ebox-property-rules'." - (ebox-style--get-ebox-rule property)) - -(defun ebox--expand-property (property value) - "Expand PROPERTY with VALUE according to rules. Returns a plist." - (ebox-style-expand-ebox-property property value)) - -;;; --- 辅助函数 --- - -(defun ebox--atom-consp (list) - "Check if LIST is a strict dotted pair (atom . atom)." - (ebox-style--atom-consp list)) - -(defun ebox--parse-color (color) - "Parse COLOR to a real color string. -Supports: t (default fg), nil, string, (light . dark) cons." - (ebox-style--parse-color color)) - -(defun ebox--expand-plist (plist) - "Expand PLIST using `ebox-property-rules'." - (ebox-style-expand-ebox-plist plist)) - -;;; --- 核心 API --- - ;;; ============================================================ ;;; Buffer Runtime State & Scroll State ;;; ============================================================ @@ -1308,8 +1238,8 @@ current visible slice. Prefix misses continue through the normal renderer." (= (or (ebox-get box :margin-bottom-height) 0) 0) (= (or (ebox-get box :border-left-pixel) 0) 0) (= (or (ebox-get box :border-right-pixel) 0) 0) - (not (ebox-get box :border-top-p)) - (not (ebox-get box :border-bottom-p)) + (= (or (ebox-get box :border-top-pixel) 0) 0) + (= (or (ebox-get box :border-bottom-pixel) 0) 0) (eq (ebox-get box :vertical-align) 'top) (null (ebox-get box :surface-properties)) visible))) @@ -1360,8 +1290,8 @@ proof becomes usable for an input event." (= (or (ebox-get box :margin-bottom-height) 0) 0) (= (or (ebox-get box :border-left-pixel) 0) 0) (= (or (ebox-get box :border-right-pixel) 0) 0) - (not (ebox-get box :border-top-p)) - (not (ebox-get box :border-bottom-p)) + (= (or (ebox-get box :border-top-pixel) 0) 0) + (= (or (ebox-get box :border-bottom-pixel) 0) 0) (eq (ebox-get box :vertical-align) 'top) (null (ebox-get box :surface-properties))))) @@ -3831,23 +3761,44 @@ Defaults to the current buffer." (nreverse result))) (defconst ebox--region-update-longhand-props - '(:box-sizing + (append + '(:box-sizing :width :min-width :max-width :height :min-height :max-height :padding-left-pixel :padding-right-pixel :padding-top-height :padding-bottom-height :margin-left-pixel :margin-right-pixel :margin-top-height :margin-bottom-height - :border-left-pixel :border-left-color - :border-right-pixel :border-right-color - :border-top-p :border-top-color - :border-bottom-p :border-bottom-color + :border-left-pixel :border-left-style :border-left-color + :border-right-pixel :border-right-style :border-right-color + :border-top-pixel :border-top-style :border-top-color + :border-bottom-pixel :border-bottom-style :border-bottom-color :color :bgcolor :text-align :vertical-align :overflow :wrap-mode - :scroll-offset :visibility) + :scroll-offset :visibility) + ebox-style--item-projection-properties) "Longhand box properties accepted by `ebox-region-update'.") +(defconst ebox--region-update-control-properties + '(:content :scroll-offset) + "Surface controls accepted by `ebox-region-update' outside style schemas.") + +(defun ebox--region-update-style-properties (props) + "Return PROPS without region-owned content and scroll controls." + (cl-loop for (property value) on props by #'cddr + unless (memq property ebox--region-update-control-properties) + append (list property value))) + +(defun ebox--region-update-author-form (box) + "Return the public author form whose closed schema applies to BOX." + (if (eq (plist-get box :ebox-kind) 'text) + 'text + (pcase (ebox-tree-layout-kind box) + ('normal 'box) + ((and kind (or 'row 'column 'flex 'grid)) kind) + (_ 'box)))) + (defun ebox--region-update-normalize-property (property) "Return the box longhand field for region-update PROPERTY." (pcase property @@ -3884,6 +3835,8 @@ Defaults to the current buffer." ((memq target-key ebox--region-update-longhand-props) (when (not (equal (ebox-get box target-key) value)) (setq style-changed-p t))) + ((ebox-style-schema-id key) + (setq style-changed-p t)) (t (push key unknown-keys)))) (when (and content-seen (not (stringp content-value))) @@ -3928,8 +3881,35 @@ cannot be changed through the unpublished batch root." buffer region-id t)) (ebox-tree-copy-node-structure (plist-get state :root-node))))) +(defun ebox--region-update-layout-delta (box declarations) + "Apply LayoutConfig DECLARATIONS to BOX and return changed property names." + (when-let* ((config (and (ebox-box-node-p box) + (ebox-box-node-layout box))) + (kind (ebox-layout-config-kind config)) + (author-kind (if (eq kind 'normal) 'box kind)) + (names (ebox-layout-config-property-names author-kind)) + (delta + (ebox-style-declaration-properties + declarations + (lambda (property) + (memq (plist-get property :name) names))))) + (let* ((normalized + (ebox-layout-config-props + (ebox-layout-config-for-form author-kind delta))) + (props (copy-tree (ebox-layout-config-props config))) + changed) + (cl-loop for (name _value) on delta by #'cddr + for value = (plist-get normalized name) + unless (equal (plist-get props name) value) + do (setq props (plist-put props name value)) + and do (push name changed)) + (when changed + (plist-put box :ebox-layout-config + (ebox-layout-config-with-props config props))) + (nreverse changed)))) + (defun ebox--surface-region-apply-props - (root source-index region-id expanded declarations) + (root source-index region-id expanded declarations style-declarations) "Apply DECLARATIONS then EXPANDED fields to REGION-ID in ROOT." (let ((box (ebox--root-region-box root region-id)) (candidate-source-index source-index) @@ -3947,6 +3927,17 @@ cannot be changed through the unpublished batch root." source-index old-handle :declarations declarations))) (setq candidate-source-index (car binding)) (plist-put box :ebox-source-handle (cdr binding)))) + (when (plist-member style-declarations 'ebox/outer) + (let* ((outer (plist-get style-declarations 'ebox/outer)) + (display (plist-get box :display)) + (next (list outer (or (cadr display) 'flow)))) + (unless (equal display next) + (plist-put box :display next) + (push :outer changed-keys)))) + (setq changed-keys + (nconc (ebox--region-update-layout-delta + box style-declarations) + changed-keys)) (cl-loop for (key value) on expanded by #'cddr for target-key = (ebox--region-update-normalize-property key) when (or (eq target-key :content) @@ -4195,12 +4186,23 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." (candidate-box (or (ebox--root-region-box published-root region-id) (user-error "Ebox region handle no longer resolves to a box"))) - (expanded (ebox--expand-plist props)) + (style-declarations + (ebox-style-compile-form + (ebox--region-update-author-form candidate-box) + (ebox--region-update-style-properties props))) (declarations (ebox-style-merge-declarations (ebox-style-node-declarations source-index candidate-box) - (ebox-style-compile-declarations props))) + style-declarations)) + (expanded + (append + (cl-loop for (property value) on props by #'cddr + when (memq property ebox--region-update-control-properties) + append (list property value)) + (ebox-style--expand-engine-delta + style-declarations declarations + (plist-get candidate-box :ebox-computed-style)))) (old-style-values (and (plist-member expanded :overflow) (list :overflow (ebox-get candidate-box :overflow)))) @@ -4235,7 +4237,7 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." (applied (ebox--surface-region-apply-props candidate-root source-index - region-id expanded declarations)) + region-id expanded declarations style-declarations)) (changed-keys (plist-get applied :changed-keys)) (candidate-source-index (plist-get applied :source-index)) (dirty-kind diff --git a/tests/ebox-child-range-tests.el b/tests/ebox-child-range-tests.el index 441694a..9142202 100644 --- a/tests/ebox-child-range-tests.el +++ b/tests/ebox-child-range-tests.el @@ -644,10 +644,12 @@ (cl-labels ((rows (selected) (cl-loop for id from 1 to 12 + for content = (ebox-test-text (format "row-%02d" id)) collect - (ebox-test-box - :key id (ebox-test-text (format "row-%02d" id)) :width '(420) - :color (and (= id selected) "#2F6B43")))) + (if (= id selected) + (ebox-test-box + :key id :width '(420) :color "#2F6B43" content) + (ebox-test-box :key id :width '(420) content)))) (root () (ebox-test-box :width '(900) :color "#172033" :wrap-mode 'none @@ -976,7 +978,7 @@ (let* ((buffer (generate-new-buffer " *ebox-range-no-flatten*")) (static (cl-loop for index below 100 - collect (ebox-test-box :key (list 'static index) + collect (ebox-test-box :key (format "static-%d" index) (ebox-test-text (number-to-string index))))) (root (apply #'ebox-test-column (append static @@ -1042,7 +1044,7 @@ (apply #'ebox-test-column (append (cl-loop for index below static-count - collect (ebox-test-box :key (list 'static index) + collect (ebox-test-box :key (format "static-%d" index) (ebox-test-text "s"))) (list (ebox-test-child-range 'items (ebox-test-box :key 'old (ebox-test-text "o")))))))) @@ -1238,7 +1240,7 @@ (apply #'ebox-test-column (append (cl-loop for index below 500 - collect (ebox-test-box :key (list 'static index) + collect (ebox-test-box :key (format "static-%d" index) (ebox-test-text "s"))) (list (ebox-test-child-range 'items))))) report) @@ -1250,7 +1252,7 @@ candidate 'items (ebox-child-range-test--input (cl-loop for index below 64 - collect (ebox-test-box :key (list 'new index) + collect (ebox-test-box :key (format "new-%d" index) (ebox-test-text "n"))))) (cl-letf (((symbol-function 'ebox-incremental--candidate-full-index-preparation) diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index 99b19d1..dffce27 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -595,8 +595,8 @@ "Repeated equivalent style declarations compile through ECSS once." (clrhash ebox-style--declaration-cache) (let ((calls 0) - (original (symbol-function 'ecss-merge-declarations))) - (cl-letf (((symbol-function 'ecss-merge-declarations) + (original (symbol-function 'ecss-expand-declarations))) + (cl-letf (((symbol-function 'ecss-expand-declarations) (lambda (&rest arguments) (cl-incf calls) (apply original arguments)))) @@ -1038,57 +1038,6 @@ (kill-buffer buffer))))) -(ert-deftest ebox-commit-keeps-root-owner-for-dynamic-cascade-source () - "A TP-dependent cascade binding must preserve root-owner fallback." - (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) - (color (tp-signal-create "#111111")) - (calls 0) - (buffer nil)) - (ebox-style-add-rule - ".card" (list :color (tp-computed (lambda () (tp-signal-read color)))) - :layer 'components) - (unwind-protect - (progn - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-dynamic-style-proof*") - (ebox-test-column - (ebox-test-box :key 'card :class "card" (ebox-test-text "Before") - :width '(40)) - (ebox-test-box :key 'stable (ebox-test-text "Stable") - :width '(40))))) - (let* ((state (ebox--buffer-render-state buffer)) - (style-states (plist-get state :style-binding-states)) - (binding-state - (cl-loop for value being the hash-values of style-states - when (and (plist-get value :binding) - (not (ebox-surface--static-style-state-p - value))) - return value))) - (should binding-state) - (should (> (tp-binding-dependency-count - (plist-get binding-state :binding)) - (if (plist-get binding-state :parent-binding) 1 0))) - (should-not (ebox-surface--static-style-state-p binding-state))) - (let ((original - (symbol-function 'ebox-incremental--layout-owner-plan))) - (cl-letf (((symbol-function 'ebox-incremental--layout-owner-plan) - (lambda (&rest arguments) - (cl-incf calls) - (apply original arguments)))) - (ebox-commit - buffer - (ebox-test-column - (ebox-test-box :key 'card :class "card" (ebox-test-text "After") - :width '(40)) - (ebox-test-box :key 'stable (ebox-test-text "Stable") - :width '(40)))) - (should (= calls 0))))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - - - (ert-deftest ebox-commit-formatting-context-reflow-owns-variable-line-siblings () "Two variable-line owners should publish through their nearest stack context. The context owns the complete local block; the root and untouched header/footer @@ -1299,7 +1248,7 @@ remain retained identities." (ebox-test-box :key 'right :source-identity 'right (ebox-test-text "R"))) (old-root (ebox-test-grid :key 'grid :width '(80) :grid-template-columns - '((36) (36)) :column-gap '(0 (8)) + '((36) (36)) :column-gap '(8) (ebox-test-box :key 'left :source-identity 'left (ebox-test-text "left-old")) (ebox-test-box :key 'right :source-identity 'right (ebox-test-text "right-old")))) (buffer nil) @@ -1320,7 +1269,10 @@ remain retained identities." '(span-patch owner-scoped))) (should-not (plist-get report :tp-full-root)) (should-not (plist-get report :tp-scope-fallback)) - (should (= 1 (plist-get report :tp-text-operations))) + ;; The 8px Grid gap keeps the two changed slots disjoint. TP still + ;; publishes one scoped transaction, with one exact text operation + ;; per changed slot instead of replacing the unchanged gap. + (should (= 2 (plist-get report :tp-text-operations))) (should (string-match-p "L" (ebox-commit-test--buffer-string buffer))) (setq fresh @@ -1328,7 +1280,7 @@ remain retained identities." (generate-new-buffer-name " *ebox-multi-owner-variable-slots-fresh*") (ebox-test-grid :key 'grid :width '(80) :grid-template-columns - '((36) (36)) :column-gap '(0 (8)) + '((36) (36)) :column-gap '(8) (ebox-test-box :key 'left :source-identity 'left (ebox-test-text "L")) (ebox-test-box :key 'right :source-identity 'right (ebox-test-text "R"))))) (should (equal @@ -1347,7 +1299,7 @@ remain retained identities." ((root (left right) (ebox-test-grid :key 'grid :width '(80) :grid-template-columns '((36) (36)) - :column-gap '(0 (8)) + :column-gap '(8) (ebox-test-box :key 'left :source-identity 'left (ebox-test-text left)) (ebox-test-box :key 'right :source-identity 'right (ebox-test-text right)))) (candidate (buffer) @@ -1682,7 +1634,7 @@ remain retained identities." :key 'geometry-context :width '(80) (ebox-test-column (ebox-test-box :key 'left :source-identity 'left - (ebox-test-text left) :font 'bold) + (ebox-test-text left) :font-weight 'bold) (ebox-test-box :key 'right :source-identity 'right (ebox-test-text right)))) (ebox-test-box :key 'paint-a :source-identity 'paint-a @@ -1867,7 +1819,7 @@ remain retained identities." (ebox-candidate-replace-host-ref candidate 'left (ebox-test-box :key 'left :source-identity 'left - (ebox-test-text left) :font 'bold)) + (ebox-test-text left) :font-weight 'bold)) (ebox-candidate-replace-host-ref candidate 'right (ebox-test-box :key 'right :source-identity 'right @@ -1991,28 +1943,39 @@ remain retained identities." (defun ebox-commit-test--allocation-closure-root (toast paint-a paint-b &optional width footer-overflow root-overflow) "Return a generic whole-line Flex allocation-closure fixture." - (ebox-test-box - :key 'root :width (list (or width 800)) - :height (and root-overflow 1) :overflow root-overflow - (ebox-test-column - (ebox-test-box :key 'status :source-identity 'status - (ebox-test-text "Theme: Light") :width '(200)) - (ebox-test-box :key 'paint-a :source-identity 'paint-a - (ebox-test-text "paint-a") :color paint-a) - (ebox-test-box :key 'paint-b :source-identity 'paint-b - (ebox-test-text "paint-b") :bgcolor paint-b) - (ebox-test-box - :key 'footer-owner :width (list (or width 800)) - (ebox-test-column - (ebox-test-flex - :key 'footer :width (list (or width 800)) :flex-wrap 'wrap - :overflow footer-overflow :gap '(1 (4)) - (ebox-test-box - :key 'toast-slot :width 'stretch :min-width 0 - :flex-grow 1 :flex-shrink 1 :flex-basis '(0) - (ebox-test-column - (ebox-test-box :key 'toast :source-identity 'toast (ebox-test-text toast)))) - (ebox-test-box :key 'peer (ebox-test-text "database.sqlite")))))))) + (let* ((footer + (apply + #'ebox-test-flex + (append + (list :key 'footer :width (list (or width 800)) + :flex-wrap 'wrap :gap '(1 (4))) + (when footer-overflow (list :overflow footer-overflow)) + (list + (ebox-test-box + :key 'toast-slot :width 'stretch :min-width 0 + :flex-grow 1 :flex-shrink 1 :flex-basis '(0) + (ebox-test-column + (ebox-test-box :key 'toast :source-identity 'toast + (ebox-test-text toast)))) + (ebox-test-box :key 'peer + (ebox-test-text "database.sqlite")))))) + (content + (ebox-test-column + (ebox-test-box :key 'status :source-identity 'status + (ebox-test-text "Theme: Light") :width '(200)) + (ebox-test-box :key 'paint-a :source-identity 'paint-a + (ebox-test-text "paint-a") :color paint-a) + (ebox-test-box :key 'paint-b :source-identity 'paint-b + (ebox-test-text "paint-b") :bgcolor paint-b) + (ebox-test-box + :key 'footer-owner :width (list (or width 800)) + (ebox-test-column footer))))) + (apply + #'ebox-test-box + (append + (list :key 'root :width (list (or width 800))) + (when root-overflow (list :height 1 :overflow root-overflow)) + (list content))))) (defun ebox-commit-test--allocation-closure-candidate (buffer toast paint-a paint-b) diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index 2cef7c8..aa66a54 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -380,41 +380,37 @@ full-updates)) (defconst ebox-test--region-update-paint-cases - '((:color "#123456" "#123456") - (:bgcolor "#FDE68A" "#FDE68A") - (:border-left-color "#111111" "#111111") - (:border-right-color "#222222" "#222222") - (:border-top-color "#333333" "#333333") - (:border-bottom-color "#444444" "#444444") - (:border-top-p t t) - (:border-bottom-p t t)) - "Paint-only box longhand update cases: (PROPERTY VALUE EXPECTED).") + '((:color "#123456" :color "#123456") + (:bgcolor "#FDE68A" :bgcolor "#FDE68A") + (:border-left-color "#111111" :border-left-color "#111111") + (:border-right-color "#222222" :border-right-color "#222222") + (:border-top-color "#333333" :border-top-color "#333333") + (:border-bottom-color "#444444" :border-bottom-color "#444444") + (:visibility hidden :visibility hidden)) + "Paint updates as (AUTHOR VALUE ENGINE-FIELD EXPECTED).") (defconst ebox-test--region-update-geometry-cases - '((:box-sizing content-box content-box owner-rerender) - (:width (120) 120 owner-rerender) - (:min-width (100) 100 owner-rerender) - (:max-width (140) 140 owner-rerender) - (:height 3 3 owner-rerender) - (:min-height 3 3 owner-rerender) - (:max-height 1 1 owner-rerender) - (:padding-left-pixel 9 9 span-patch) - (:padding-right-pixel 10 10 span-patch) - (:padding-top-height 1 1 span-patch) - (:padding-bottom-height 1 1 span-patch) - (:margin-left-pixel 7 7 owner-rerender) - (:margin-right-pixel 8 8 owner-rerender) - (:margin-top-height 1 1 owner-rerender) - (:margin-bottom-height 1 1 owner-rerender) - (:border-left-pixel 5 5 span-patch) - (:border-right-pixel 6 6 span-patch) - (:text-align center center span-patch) - (:vertical-align middle center span-patch) - (:overflow hidden hidden owner-rerender) - (:wrap-mode char char span-patch) - (:scroll-offset 1 1 span-patch) - (:visibility hidden hidden span-patch)) - "Geometry box longhand update cases: (PROPERTY VALUE EXPECTED STRATEGY).") + '((:box-sizing content-box :box-sizing content-box owner-rerender) + (:width (120) :width 120 owner-rerender) + (:min-width (100) :min-width 100 owner-rerender) + (:max-width (140) :max-width 140 owner-rerender) + (:height 3 :height 3 owner-rerender) + (:min-height 3 :min-height 3 owner-rerender) + (:max-height 1 :max-height 1 owner-rerender) + (:padding-left (9) :padding-left-pixel 9 span-patch) + (:padding-right (10) :padding-right-pixel 10 span-patch) + (:padding-top 1 :padding-top-height 1 span-patch) + (:padding-bottom 1 :padding-bottom-height 1 span-patch) + (:margin-left (7) :margin-left-pixel 7 owner-rerender) + (:margin-right (8) :margin-right-pixel 8 owner-rerender) + (:margin-top 1 :margin-top-height 1 owner-rerender) + (:margin-bottom 1 :margin-bottom-height 1 owner-rerender) + (:border-top-p t :border-top-pixel 1 span-patch) + (:text-align center :text-align center span-patch) + (:overflow hidden :overflow hidden owner-rerender) + (:wrap-mode char :wrap-mode char span-patch) + (:scroll-offset 1 :scroll-offset 1 span-patch)) + "Geometry updates as (AUTHOR VALUE ENGINE-FIELD EXPECTED STRATEGY).") (defun ebox-test--region-update-case-properties (cases) "Return the update property names covered by CASES." @@ -463,22 +459,15 @@ (should (ebox-test--face-has-key-value-p face :background "#1E293B"))))) -(ert-deftest ebox-ecss-value-resolver-uses-tp-computed-contract () - "ECSS should resolve tagged TP values while preserving literal functions." - (let ((calls 0) - (literal (lambda () 'literal))) - (should - (= (plist-get - (ebox-style-compute - (list :font-height - (tp-computed (lambda () (cl-incf calls) 1.25)))) - :font-height) - 1.25)) - (should (= calls 1)) - (should - (eq (plist-get (ebox-style-compute (list :font-height literal)) - :font-height) - literal)))) +(ert-deftest ebox-font-size-rejects-runtime-and-function-values () + "Standalone Ebox font size accepts only positive CSS reference pixels." + (should-error + (ebox-style-compute + (list :font-size (tp-computed (lambda () 16)))) + :type 'error) + (should-error + (ebox-style-compute (list :font-size (lambda () 16))) + :type 'error)) (ert-deftest ebox-ecss-computed-fact-retains-owner-impact-and-provenance () "The Ebox package schema should remain attached to one computed fact." @@ -513,6 +502,11 @@ (paint-value (ecss-computed-property-fact-value fact))) (should (equal '(geometry paint) (ecss-computed-property-fact-impacts fact))) + (should (equal '(measurement paint) + (ecss-computed-property-fact-projections fact))) + (should (eq (plist-get + (ecss-computed-property-fact-metadata fact) :owner) + 'font-metrics-projection)) (should (eq geometry-value paint-value)) (should (equal geometry-value (ecss-computed-style-value style 'ebox/font-weight))))) @@ -551,7 +545,9 @@ (lambda (computed) (cl-incf provenance-reads) (funcall original-provenance computed)))) - (should (ebox-style--context-values style 'box t))) + (should (ebox-style--context-values style 'box t)) + (should (ebox-style--context-values style 'box t)) + (should (ebox-style--context-values style 'box nil))) (should (= values-reads 1)) (should (= active-reads 1)) (should (= specified-reads 1)) @@ -590,31 +586,6 @@ (should (= active-reads 1)) (should (= specified-reads 1)))) -(ert-deftest ebox-cascade-inherits-color-but-honors-explicit-nil () - "A child nil color should block inheritance without inheriting background." - (ebox-test--reset-runtime-state) - (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) - (root (ebox-test-box :class 'panel :wrap-mode 'none :width '(96) - (ebox-test-box :id 'child :color nil :width '(96) - (ebox-test-text "Child"))))) - (ebox-style-add-rule - ".panel" '(:color "#E2E8F0" :bgcolor "#0F172A") - :layer 'components) - (ebox-test--with-rendered-buffer root - (let* ((runtime-root (ebox-test--runtime-root (current-buffer))) - (runtime-child (car (ebox-tree-layout-children runtime-root))) - (child-style (plist-get runtime-child :ebox-computed-style)) - (values (ecss-computed-style-values child-style)) - (active (ecss-computed-style-active-properties child-style))) - (should (memq 'ebox/color active)) - (should (null (plist-get values 'ebox/color))) - (should (null (plist-get values 'ebox/background-color))) - (should-not (memq 'ebox/background-color active)) - (goto-char (point-min)) - (search-forward "Child") - (let ((face (get-text-property (match-beginning 0) 'face))) - (should (equal (car face) '(:inherit default)))))))) - (ert-deftest ebox-default-foreground-reset-preserves-explicit-background () "Resolve GUI default foreground without inheriting its background." (let ((original (symbol-function 'face-attribute))) @@ -700,8 +671,8 @@ (ebox-test--reset-runtime-state) (let* ((root (ebox-test-box :wrap-mode 'none :font-family "Parent Family" - :font-height 1.25 :font-weight 'bold - (ebox-test-box :id 'child :font-family "Child Family" + :font-size 16 :font-weight 'bold + (ebox-test-box :id 'child :font-family "Child Family" (ebox-test-text "MMMM")))) measured-face) (cl-letf (((symbol-function 'ebox--string-pixel-width) @@ -716,18 +687,18 @@ (values (ecss-computed-style-values style)) (face (ebox-test--first-content-face (buffer-string)))) (should (equal (plist-get values 'ebox/font-family) "Child Family")) - (should (= (plist-get values 'ebox/font-height) 1.25)) + (should (= (plist-get values 'ebox/font-size) 16)) (should (eq (plist-get values 'ebox/font-weight) 'bold)) (should (equal (ebox-test--effective-face-value face :family) "Child Family")) - (should (= (ebox-test--effective-face-value face :height) 1.25)) + (should (= (ebox-test--effective-face-value face :height) 120)) (should (eq (ebox-test--effective-face-value face :weight) 'bold))))) (should measured-face) (should (equal (ebox-test--effective-face-value measured-face :family) "Child Family")) - (should (= (ebox-test--effective-face-value measured-face :height) 1.25)))) + (should (= (ebox-test--effective-face-value measured-face :height) 120)))) (ert-deftest ebox-column-background-owns-blank-lines-with-child-override () "A column background should fill its blank lines without hiding child paint." @@ -1781,6 +1752,31 @@ (should props) (should (plist-member props 'face)))) +(ert-deftest ebox-text-decoration-projects-to-one-emacs-face () + "Canonical decoration facts should remain paint-only and visibly compose." + (let* ((style + (ebox-style-compute + '(:text-decoration-line (underline overline line-through) + :text-decoration-color "#336699" + :text-decoration-style wavy))) + (face (plist-get + (ebox-buffer-paint-text-properties style 'content) 'face)) + (rendered + (ebox-render + (ebox-test-text + "Decorated" + :text-decoration-line '(underline overline line-through) + :text-decoration-color "#336699" + :text-decoration-style 'wavy))) + (rendered-face (get-text-property 0 'face rendered))) + (dolist (candidate (list face rendered-face)) + (should (ebox-test--face-has-key-value-p + candidate :underline '(:color "#336699" :style wave))) + (should (ebox-test--face-has-key-value-p + candidate :overline "#336699")) + (should (ebox-test--face-has-key-value-p + candidate :strike-through "#336699"))))) + (ert-deftest ebox-render-keeps-tp-paint-slot-address-stable () "A TP paint slot should recolor retained output without property rewrites." (let* ((slot (tp-paint-slot-create '(:foreground "red"))) @@ -2120,7 +2116,7 @@ "Detailed snapshots should expose explicit L2 footprint signatures." (ebox-test--reset-runtime-state) (let* ((box (ebox-test-box (ebox-test-text "Footprint") :width '(120) - :height 1 :border t)) + :height 1 :border '(1 solid))) (node-id (ebox--ensure-node-id (ebox-test-root box)))) (ebox-test--with-rendered-buffer box (let* ((snapshot (ebox--ensure-layout-snapshot-details @@ -2531,7 +2527,7 @@ collect (ebox-test-box (ebox-test-text (format "Fluid %02d" index)) - :border t)))) + :border '(1 solid))))) buffer) (unwind-protect (progn @@ -2571,7 +2567,7 @@ collect (ebox-test-box (ebox-test-text (format "Fluid section %02d" index)) - :border t)))) + :border '(1 solid))))) buffer) (unwind-protect (progn @@ -2611,7 +2607,7 @@ (ebox-test-text content) :width 'viewport :wrap-mode 'word - :border t)))) + :border '(1 solid))))) buffer) (unwind-protect (progn @@ -2645,7 +2641,7 @@ (ebox-test-text content) :width 'viewport :wrap-mode 'word - :border t)))) + :border '(1 solid))))) (slot-probe-count 0) (original (symbol-function 'ebox-buffer--rendered-in-existing-slots)) buffer) @@ -3712,8 +3708,7 @@ (ordinary-height-nodes (cl-loop for (property value) in '((:height (viewport-height)) - (:min-height (- (viewport-height) 1)) - (:max-height (+ (viewport-height) 1))) + (:max-height (viewport-height))) collect (apply #'ebox-test-box (ebox-test-text "Ordinary") @@ -3721,8 +3716,7 @@ (flex-height-nodes (cl-loop for (property value) in '((:height (viewport-height)) - (:min-height (- (viewport-height) 1)) - (:max-height (+ (viewport-height) 1))) + (:max-height (viewport-height))) collect (apply #'ebox-test-flex (append @@ -4148,7 +4142,7 @@ "Render cache plist signatures should be built linearly." (ebox-test--reset-runtime-state) (let ((node (ebox-test-root - (ebox-test-box (ebox-test-text "Signature") :width 80 :border t))) + (ebox-test-box (ebox-test-text "Signature") :width 80 :border '(1 solid)))) (original-append (symbol-function 'append)) (append-calls 0)) (cl-letf (((symbol-function 'append) @@ -4234,9 +4228,9 @@ (ert-deftest ebox-transparent-preformatted-box-fast-path-is-exact () "Transparent exact-width wrappers should match the generic box pipeline." (ebox-test--reset-runtime-state) - (let* ((child-a (ebox-test-box (ebox-test-text "A") :width '(viewport) :border t + (let* ((child-a (ebox-test-box (ebox-test-text "A") :width '(viewport) :border '(1 solid) :region-id 'child-a)) - (child-b (ebox-test-box (ebox-test-text "B") :width '(viewport) :border t + (child-b (ebox-test-box (ebox-test-text "B") :width '(viewport) :border '(1 solid) :region-id 'child-b)) (parent (ebox-test-column :width '(viewport) :region-id 'parent child-a child-b)) @@ -4343,11 +4337,12 @@ (ebox-test-box (ebox-test-text (nth 0 case)) :width '(120) :height 5 :box-sizing 'border-box :overflow (nth 1 case) - :vertical-align (nth 2 case) :wrap-mode 'none)) + :wrap-mode 'none)) (box (ebox-test-root box-input)) (calls 0) (original (symbol-function 'ebox--window-render-flat-wrapper-chunk-lines))) + (plist-put box :vertical-align (nth 2 case)) (plist-put box :ebox-content-width-exact-p t) (cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines) (lambda (&rest arguments) @@ -4756,12 +4751,12 @@ (should-not (text-property-search-forward 'ebox-bb left-id t)) (let ((full-rerenders (ebox-test--count-full-surface-updates - (ebox-test--region-update left-id - :border-bottom-p t - :border-bottom-color "blue")))) + (ebox-test--region-update + left-id :border-bottom '(1 solid "blue"))))) (should (= full-rerenders 0))) (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'paint-patch)) + (should (memq (plist-get report :strategy) + '(span-patch owner-rerender))) (should-not (plist-get report :root-rerender))) (let (bottom-face) (save-excursion @@ -4806,9 +4801,8 @@ (save-restriction (goto-char (1+ (point-min))) (narrow-to-region (point) (point-max)) - (ebox-test--region-update left-id - :border-bottom-p t - :border-bottom-color "blue")) + (ebox-test--region-update + left-id :border-bottom '(1 solid "blue"))) (should (equal old-metadata (plist-get old-state :surface-fragments))) (should (cl-every (lambda (fragment) @@ -4818,9 +4812,9 @@ (ert-deftest ebox-horizontal-border-removal-preserves-other-border-colors () "Removing top/bottom border surfaces must not corrupt other border faces." (ebox-test--reset-runtime-state) - (dolist (case '((:border-top-p bt (bb bl br)) - (:border-bottom-p bb (bt bl br)))) - (let* ((property (nth 0 case)) + (dolist (case '(((:border-top-p nil) bt (bb bl br)) + ((:border-bottom-width 0) bb (bt bl br)))) + (let* ((update (nth 0 case)) (removed-role (nth 1 case)) (kept-roles (nth 2 case)) (layout (ebox-test-box (ebox-test-text "Border") @@ -4841,34 +4835,46 @@ (setq face (get-text-property (point) 'face))) (forward-char 1))) face))) - (ebox-test--region-update region-id property nil) + (apply #'ebox-test--region-update region-id update) (should-not (face-at-role removed-role)) (dolist (role kept-roles) (should (ebox-test--border-face-has-color-p (face-at-role role) role "#D97757")))))))) -(ert-deftest ebox-side-border-color-patch-toggles-default-paint-exactly () - "Side color patches should match default and explicit full-render paint." +(ert-deftest ebox-horizontal-border-capability-error-preserves-mounted-state () + "An unsupported horizontal width must fail before replacing publication." + (ebox-test--reset-runtime-state) + (let* ((input + (ebox-test-box :id "root" (ebox-test-text "Border") + :border '(1 solid "#D97757"))) + (buffer (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-border-capability*") + input)) + (state (ebox--buffer-render-state buffer)) + (text (with-current-buffer buffer (buffer-string)))) + (unwind-protect + (progn + (should-error + (ebox-selector-update-buffer + buffer "#root" :border-top '(2 solid "#D97757"))) + (should (eq state (ebox--buffer-render-state buffer))) + (should (equal-including-properties + text (with-current-buffer buffer (buffer-string))))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-side-border-color-patch-replaces-paint-exactly () + "Side color replacements should match the full-render paint result." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-box + (ebox-test-box (ebox-test-text "Border") :width 80 - :border-left-pixel 2 :border-left-color "#AA0000" - :border-right-pixel 2 :border-right-color "#AA0000")) + :border-left-width 2 :border-left-style 'solid + :border-left-color "#AA0000" + :border-right-width 2 :border-right-style 'solid + :border-right-color "#AA0000")) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (ebox-test--region-update region-id - :border-left-color nil - :border-right-color nil) - (dolist (role '(bl br)) - (let ((face - (ebox-test--face-at-property - (buffer-string) (alist-get role ebox-region-types)))) - (should - (ebox-test--face-has-key-value-p face :inverse-video t)) - (should-not (ebox-test--face-has-key-p face :background)))) - (should (ebox-test--buffer-visually-matches-runtime-render-p)) (ebox-test--region-update region-id :border-left-color "#00AA00" :border-right-color "#00AA00") @@ -4886,7 +4892,7 @@ "Runtime rerender should resolve viewport-sized boxes with the new context." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-width 240)) - (ebox-test-box (ebox-test-text "Viewport") :width '(viewport) :border t))) + (ebox-test-box (ebox-test-text "Viewport") :width '(viewport) :border '(1 solid)))) buffer) (unwind-protect (progn @@ -5509,8 +5515,9 @@ (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-box :id "root" :height 4 :box-sizing 'border-box :overflow - 'scroll :padding-top 1 :padding-bottom 1 :border-top-p - t :border-top-color "red" :border-bottom-p t + 'scroll :padding-top 1 :padding-bottom 1 :border-top-p + t :border-top-color "red" + :border-bottom-width 1 :border-bottom-style 'solid :border-bottom-color "blue" (ebox-test-column (ebox-test-box :id "a" (ebox-test-text "A") :height 1) @@ -6175,7 +6182,7 @@ :text-align 'left)) (right (ebox-test-box (ebox-test-text "Right") :width '(80) :height 1)) (layout (ebox-test-row left right)) - (left-id (car (ebox-region-ids layout)))) + (left-id (car (ebox-region-ids left)))) (ebox-test--with-rendered-buffer layout (ebox-test--region-update left-id :text-align 'center) (let ((report (ebox-test--buffer-update-report))) @@ -6189,6 +6196,51 @@ (buffer-substring-no-properties (point-min) (point-max)))) (should (ebox-test--buffer-propertized-matches-runtime-render-p))))) +(ert-deftest ebox-region-update-completes-border-width-style-pairs () + "A one-property border delta should retain its computed counterpart." + (ebox-test--reset-runtime-state) + (let* ((input + (ebox-test-box (ebox-test-text "Border") :width 80 + :border-left '(2 none "#AA0000"))) + (region-id (car (ebox-region-ids input)))) + (ebox-test--with-rendered-buffer input + (ebox-test--region-update region-id :border-left-style 'solid) + (let ((box (gethash region-id (ebox--buffer-region-box-table + (current-buffer))))) + (should (= (ebox-get box :border-left-pixel) 2))) + (ebox-test--region-update region-id :border-left-width 3) + (let ((box (gethash region-id (ebox--buffer-region-box-table + (current-buffer))))) + (should (= (ebox-get box :border-left-pixel) 3))) + (ebox-test--region-update region-id :border-left-style 'none) + (let ((box (gethash region-id (ebox--buffer-region-box-table + (current-buffer))))) + (should (= (ebox-get box :border-left-pixel) 0))) + (should (ebox-test--buffer-propertized-matches-runtime-render-p))))) + +(ert-deftest ebox-region-update-projects-outer-and-layout-config () + "Region updates should project structural and LayoutConfig facts once." + (ebox-test--reset-runtime-state) + (let* ((input + (ebox-test-row :id "axis" :outer 'inline :item-gap 1 + (ebox-test-box (ebox-test-text "A")) + (ebox-test-box (ebox-test-text "B")))) + (buffer (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-axis-update*") input))) + (unwind-protect + (progn + (ebox-selector-update-buffer + buffer "#axis" :outer 'block :item-gap 3) + (let* ((root (plist-get (ebox--buffer-render-state buffer) + :root-node)) + (props (ebox-layout-config-props + (ebox-box-node-layout root)))) + (should (equal (plist-get root :display) '(block row))) + (should (= (plist-get props :item-gap) 3)) + (should (with-current-buffer buffer + (ebox-test--buffer-propertized-matches-runtime-render-p))))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + (ert-deftest ebox-region-update-border-box-inline-change-uses-span-patch () "Border-box left/right spacing can be internal when outer width is fixed." (ebox-test--reset-runtime-state) @@ -6196,7 +6248,7 @@ :box-sizing 'border-box)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-test--region-update region-id :padding-left-pixel 8) + (ebox-test--region-update region-id :padding-left '(8)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :patch-ops) '(span-patch))) @@ -6209,7 +6261,7 @@ :box-sizing 'content-box)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-test--region-update region-id :padding-left-pixel 8) + (ebox-test--region-update region-id :padding-left '(8)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :patch-ops) '(owner-rerender))) @@ -6224,7 +6276,7 @@ :wrap-mode 'char)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-test--region-update region-id :padding-left-pixel 100) + (ebox-test--region-update region-id :padding-left '(100)) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) '(span-patch owner-rerender))) @@ -6233,27 +6285,35 @@ (should-not (plist-get report :tp-scope-fallback)) (should (ebox-test--buffer-propertized-matches-runtime-render-p)))))) -(ert-deftest ebox-region-update-matrix-covers-all-box-longhands () - "The update tests should classify every non-content box longhand." - (let ((covered (append (ebox-test--region-update-case-properties - ebox-test--region-update-paint-cases) - (ebox-test--region-update-case-properties - ebox-test--region-update-geometry-cases)))) - (should-not (cl-set-difference ebox--region-update-longhand-props - covered - :test #'eq)) - (should-not (cl-set-difference covered - ebox--region-update-longhand-props - :test #'eq)) - (should-not (cl-set-difference ebox--paint-style-signature-keys - (ebox-test--region-update-case-properties - ebox-test--region-update-paint-cases) - :test #'eq)))) +(ert-deftest ebox-region-update-matrix-uses-only-public-properties () + "Region update coverage must not preserve private engine spellings." + (dolist (property + (append (ebox-test--region-update-case-properties + ebox-test--region-update-paint-cases) + (ebox-test--region-update-case-properties + ebox-test--region-update-geometry-cases))) + (should (or (memq property ebox--region-update-control-properties) + (ebox-style-schema-id property)))) + (dolist (property '(:padding-left-pixel :padding-right-pixel + :padding-top-height :padding-bottom-height + :margin-left-pixel :margin-right-pixel + :margin-top-height :margin-bottom-height + :border-left-pixel :border-right-pixel + :border-top-pixel :border-bottom-pixel + :border-left-style :border-right-style + :border-top-style :border-bottom-style + :vertical-align)) + (should-not + (memq property + (append (ebox-test--region-update-case-properties + ebox-test--region-update-paint-cases) + (ebox-test--region-update-case-properties + ebox-test--region-update-geometry-cases)))))) (ert-deftest ebox-region-update-all-paint-longhands-use-paint-patch () "Every paint longhand should patch text properties without replacing spans." (dolist (case ebox-test--region-update-paint-cases) - (pcase-let ((`(,property ,value ,expected) case)) + (pcase-let ((`(,property ,value ,engine-property ,expected) case)) (ert-info ((format "property %S" property)) (ebox-test--reset-runtime-state) (let* ((box (ebox-test-box (ebox-test-text "Paint") :width '(120) @@ -6270,7 +6330,8 @@ (should (eq (gethash region-id ebox--region-box-table) runtime-box)) (should-not (eq runtime-box box)) - (should (equal (ebox-get runtime-box property) expected))) + (should (equal (ebox-get runtime-box engine-property) + expected))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should (equal (plist-get report :patch-ops) @@ -6281,7 +6342,8 @@ (ert-deftest ebox-region-update-all-geometry-longhands-use-minimal-strategy () "Every geometry longhand should use a correct scoped non-root strategy." (dolist (case ebox-test--region-update-geometry-cases) - (pcase-let ((`(,property ,value ,expected ,_strategy) case)) + (pcase-let + ((`(,property ,value ,engine-property ,expected ,_strategy) case)) (ert-info ((format "property %S" property)) (ebox-test--reset-runtime-state) (let* ((box (ebox-test-box (ebox-test-text "Alpha\nBeta\nGamma\nDelta") @@ -6301,10 +6363,11 @@ (should (eq (gethash region-id ebox--region-box-table) runtime-box)) (should-not (eq runtime-box box)) - (should (equal (ebox-get runtime-box property) expected))) + (should (equal (ebox-get runtime-box engine-property) + expected))) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) - '(span-patch owner-rerender))) + '(paint-patch span-patch owner-rerender))) (should (equal (plist-get report :dirty-kinds) '(geometry))) (should (eq (plist-get report :owner-type) 'box)) (should-not (plist-get report :tp-full-root)) @@ -6373,43 +6436,6 @@ (should (equal (plist-get report :dirty-kinds) '(paint))) (should-not (plist-get report :full-rerender))))))) -(ert-deftest ebox-vertical-align-middle-alias-matches-center () - "Creation and updates should normalize middle to center before rendering." - (ebox-test--reset-runtime-state) - (let ((middle (ebox-test-box (ebox-test-text "X") :height 3 - :vertical-align 'middle))) - (should (equal (ebox--expand-property :vertical-align 'middle) - '(:vertical-align center))) - (should (eq (ebox-get (ebox-test-root middle) :vertical-align) - 'center)) - (should (= (cl-position-if - (lambda (line) - (string-match-p "X" (substring-no-properties line))) - (ebox-string-lines (ebox-render middle))) - 1)) - (let* ((updated (ebox-test-box (ebox-test-text "X") :height 3 - :vertical-align 'top)) - (region-id (car (ebox-region-ids updated)))) - (ebox-test--with-rendered-buffer updated - (goto-char (point-min)) - (search-forward "X") - (should (= (line-number-at-pos (match-beginning 0)) 1)) - (ebox-test--region-update region-id :vertical-align 'middle) - (should (eq (ebox-style-node-specified-value - updated :vertical-align) - 'top)) - (should (eq (ebox-get (gethash region-id - (ebox--buffer-region-box-table - (current-buffer))) - :vertical-align) - 'center)) - (goto-char (point-min)) - (search-forward "X") - (should (= (line-number-at-pos (match-beginning 0)) 2)) - (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) - 'span-patch)) - (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) - (ert-deftest ebox-update-reuses-shorthand-property-expansion () (ebox-test--reset-runtime-state) (let* ((box (ebox-test-box (ebox-test-text "Box") :width 60)) @@ -6417,7 +6443,7 @@ (ebox-test--with-rendered-buffer box (ebox-test--region-update region-id :padding '(1 2) - :border '(2 "#abcdef") + :border '(1 "#abcdef") :margin-inline 3) (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table @@ -6426,8 +6452,7 @@ (should (= (ebox-get runtime-box :padding-top-height) 1)) (should (= (ebox-get runtime-box :padding-right-pixel) (* 2 (ebox--space-pixel-width)))) - (should (= (ebox-get runtime-box :border-left-pixel) - (* 2 (ebox--space-pixel-width)))) + (should (= (ebox-get runtime-box :border-left-pixel) 1)) (should (equal (ebox-get runtime-box :border-right-color) "#abcdef")) (should (= (ebox-get runtime-box :margin-left-pixel) (* 3 (ebox--space-pixel-width)))))))) @@ -6461,8 +6486,7 @@ (should (= (ebox-get runtime-box :padding-top-height) 1)) (should (= (ebox-get runtime-box :padding-right-pixel) (* 2 (ebox--space-pixel-width)))) - (should (= (ebox-get runtime-box :border-left-pixel) - (ebox--space-pixel-width))) + (should (= (ebox-get runtime-box :border-left-pixel) 1)) (should (equal (ebox-get runtime-box :border-right-color) "#abcdef")) (should (= (ebox-get runtime-box :margin-left-pixel) (* 3 (ebox--space-pixel-width))))) @@ -6585,15 +6609,13 @@ (ebox-test--region-update region-id :color "#fedcba") (ebox-test--assert-visible-overflow-paint (buffer-string) "#fedcba" "#123456" region-id) - (ebox-test--region-update region-id :color nil) - (cl-loop for line in (ebox-string-lines (buffer-string)) - for glyph in '(?A ?B ?C) - for glyph-pos = (cl-position glyph line) - do (should (integerp glyph-pos)) - do (should-not - (ebox-test--face-has-key-p - (get-text-property glyph-pos 'face line) - :foreground)))))) + (let ((published (buffer-string))) + (should-error + (ebox-test--region-update region-id :color nil) + :type 'error) + (should (equal-including-properties published (buffer-string))) + (ebox-test--assert-visible-overflow-paint + (buffer-string) "#fedcba" "#123456" region-id))))) (ert-deftest ebox-owner-rerender-keeps-visible-overflow-unowned () "Owner rerenders should not restore box ownership to visible overflow." @@ -6856,7 +6878,6 @@ :width 140 :height 5 :overflow 'scroll - :vertical-align 'top :color "#112233" :bgcolor "#ddeeff")) (region-id (ebox--ensure-region-id (ebox-test-root box))) @@ -6864,7 +6885,6 @@ :width 140 :height 5 :overflow 'hidden - :vertical-align 'top :color "#112233" :bgcolor "#ddeeff")) (expected (progn @@ -6937,7 +6957,7 @@ (ebox-test-box (ebox-test-text "") :width '(viewport) :height 1) (ebox-test-box :id "root" :width '(viewport) :height - '(- (viewport-height) 2) :overflow + '(viewport-height) :overflow 'scroll (apply #'ebox-test-column (append (list :width '(viewport) :padding '(1 (8))) @@ -6979,7 +6999,7 @@ :rendered-lines (length (plist-get state :rendered-content-lines))))) - (should (= (plist-get state :content-height) 12))) + (should (= (plist-get state :content-height) 14))) (when-let* ((materialize (plist-get state :materialize-content-lines))) (plist-put @@ -7008,7 +7028,7 @@ (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-box :id "root" :width '(viewport) :height - '(- (viewport-height) 2) :overflow 'scroll + '(viewport-height) :overflow 'scroll (apply #'ebox-test-column (append (list :width '(viewport)) (cl-loop for idx below 40 collect @@ -7026,7 +7046,7 @@ (ebox--intrinsic-layout-measurement t)) (setq rendered (ebox-render layout))) (should (= calls 0)) - (should (= (ebox-string-height rendered) 10)) + (should (= (ebox-string-height rendered) 12)) (let ((ebox-viewport-width 240) (ebox-viewport-height 12) (ebox--intrinsic-layout-measurement nil)) @@ -7038,7 +7058,7 @@ (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-box :id "root" :width '(viewport) :height - '(- (viewport-height) 10) :overflow 'scroll + '(viewport-height) :overflow 'scroll (apply #'ebox-test-column (append (list :width '(viewport)) (cl-loop for idx below 80 collect @@ -7084,7 +7104,7 @@ (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-box :id "root" :width '(viewport) :height - '(- (viewport-height) 10) :overflow 'scroll + '(viewport-height) :overflow 'scroll (apply #'ebox-test-column (append (list :width '(viewport)) (cl-loop for idx below 80 collect @@ -7148,7 +7168,7 @@ (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-box :id "root" :width '(viewport) :height - '(- (viewport-height) 10) :overflow 'scroll + '(viewport-height) :overflow 'scroll (apply #'ebox-test-column (append (list :width '(viewport)) (cl-loop for idx below 80 collect @@ -7335,36 +7355,6 @@ (should (equal expected-signatures actual-signatures)) (should-not producer))) -(ert-deftest ebox-flex-window-aligned-wrapper-falls-back-to-full-prefix () - "Non-left-aligned flex prefixes should use the full-render fallback." - (ebox-test--reset-runtime-state) - (let* ((node - (apply #'ebox-test-flex - (append - (list :width '(100) :text-align 'center :flex-flow '(row wrap) - :row-gap 0 :column-gap 0) - (cl-loop for char across "ABCDEF" collect - (ebox-test-box (ebox-test-text (char-to-string char)) :width - '(50) :height 1))))) - (full-lines (ebox-string-lines (ebox-render node))) - (expected-signatures - (mapcar #'ebox-test--visual-line-signature - (seq-take full-lines 3))) - (ebox--render-cache-table (make-hash-table :test 'equal)) - (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) - (first (ebox-test--call-input-node - #'ebox--render-stack-window-lines node 1)) - (extended - (ebox-test--call-input-node - #'ebox--render-stack-window-lines node 3 (plist-get first :cache))) - (producer (ebox-test--call-input-node - #'ebox--render-node-window-lines node 3)) - (actual-signatures - (mapcar #'ebox-test--visual-line-signature - (plist-get extended :lines)))) - (should (equal expected-signatures actual-signatures)) - (should-not producer))) - (ert-deftest ebox-canonical-column-scroll-window-renders-a-bounded-prefix () "A canonical Column should expose only the requested scroll prefix." (ebox-test--reset-runtime-state) @@ -7809,27 +7799,29 @@ (= 1 (ebox--flex-window-completion-prefix-tail-rewrite-count (list :box (ebox-test-root - (ebox-test-box :border-bottom-p t + (ebox-test-box :border-bottom-width 1 + :border-bottom-style 'solid :padding-bottom 0)))))) (should (= 1 (ebox--flex-window-completion-prefix-tail-rewrite-count (list :box (ebox-test-root - (ebox-test-box :border-bottom-p t + (ebox-test-box :border-bottom-width 1 + :border-bottom-style 'solid :padding-bottom 0 :margin-bottom 2)))))) (should (= 0 (ebox--flex-window-completion-prefix-tail-rewrite-count (list :box (ebox-test-root - (ebox-test-box :border-bottom-p t + (ebox-test-box :border-bottom-width 1 + :border-bottom-style 'solid :padding-bottom 2)))))) (should (= 0 (ebox--flex-window-completion-prefix-tail-rewrite-count (list :box (ebox-test-root - (ebox-test-box :border-bottom-p nil - :padding-bottom 0))))))) + (ebox-test-box :padding-bottom 0))))))) (ert-deftest ebox-flex-window-cache-rejects-short-incomplete-exact-hit () "An underfilled exact-limit cache must resume from its prefix seed." @@ -11253,8 +11245,8 @@ explicit visible-window handoff may admit its full-content publication." (ebox-test-box :id "width-only" :width '(viewport)) (ebox-test-box :id "height-only" :height '(viewport-height)) - (ebox-test-flex :id "flex-height" :min-height - '(- (viewport-height) 1) + (ebox-test-flex :id "flex-height" :height + '(viewport-height) (ebox-test-box (ebox-test-text "Flex"))))))) (let* ((state (ebox--buffer-render-state buffer)) (root (plist-get state :root-node)) @@ -12475,18 +12467,18 @@ explicit visible-window handoff may admit its full-content publication." (let* ((box (ebox-test-box (ebox-test-text "Atomic") :width '(120) :bgcolor "#111111" - :padding-left-pixel 2)) + :padding-left 2)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (should-error (ebox-test--region-update region-id :bgcolor "#222222" - :padding-left-pixel -1) + :padding-left -1) :type 'error) (should (equal (ebox-style-node-specified-value box :bgcolor) "#111111")) (should (= (ebox-style-node-specified-value - box :padding-left-pixel) + box :padding-left) 2))))) (ert-deftest ebox-region-update-no-op-skips-rollback-snapshot () @@ -12724,7 +12716,7 @@ explicit visible-window handoff may admit its full-content publication." (ebox-test-box (ebox-test-text "Border") :width 60 :padding '(1 1) - :border t))) + :border '(1 solid)))) (left-face (ebox-test--face-at-property rendered 'ebox-bl)) (right-face (ebox-test--face-at-property rendered 'ebox-br)) (top-face (ebox-test--face-at-property rendered 'ebox-bt)) @@ -13034,7 +13026,7 @@ publication with properties intact." (ebox-test-box (ebox-test-text "Header line") :id 'header :width (list 300)) (ebox-test-box (ebox-test-text "Secret content") :id 'secret - :width (list 300) :border t + :width (list 300) :border '(1 solid) :bgcolor "#224466" :color "#FFFFFF") (ebox-test-box (ebox-test-text "Footer line") :id 'footer :width (list 300)))))) @@ -13072,11 +13064,11 @@ face patch must match that exactly (issue014)." (lambda (parent-color) (ebox-test-box :id 'parent :width (list 400) - :border (list '(4) 'solid parent-color) + :border (list 1 'solid parent-color) :padding '(1 (12)) (ebox-test-column (ebox-test-box :id 'child (ebox-test-text "CHILD") :width (list 200) - :border '((4) solid "#00AA00")))))) + :border '(1 solid "#00AA00")))))) (visual-runs (lambda (buffer role) (with-current-buffer buffer diff --git a/tests/ebox-dsl-tests.el b/tests/ebox-dsl-tests.el index 802b6fc..d5a0053 100644 --- a/tests/ebox-dsl-tests.el +++ b/tests/ebox-dsl-tests.el @@ -113,7 +113,7 @@ sum (ebox-dsl-test--tree-count child predicate))))) (ert-deftest ebox-style-expands-ebox-aliases-to-canonical-longhands () - "Existing Ebox aliases should normalize to CSS-like longhand properties." + "Approved aliases and shorthands should produce canonical longhands." (let ((style (ebox-style-compute (list :bgcolor "#ffffff" :padding '(1 2) @@ -124,7 +124,7 @@ (should (= (plist-get style :padding-inline-end) 2)) (should (= (plist-get style :padding-block-end) 1)) (should (= (plist-get style :padding-inline-start) 2)) - (should (= (plist-get style :margin-inline-start) 3)) + (should (= (plist-get style :margin-left) 3)) (should (equal (plist-get style :border-color) "#333333")))) (ert-deftest ebox-style-classifies-incremental-effects () @@ -134,8 +134,8 @@ (should (eq (ebox-style-dirty-kind :padding-inline-start) 'geometry)) (should (eq (ebox-style-dirty-kind :width) 'geometry)) (should (eq (ebox-style-dirty-kind :outer) 'structure)) - (should (eq (ebox-style-dirty-kind :layout) 'structure)) - (should (eq (ebox-style-dirty-kind :display) 'structure))) + (should-not (ebox-style-dirty-kind :layout)) + (should-not (ebox-style-dirty-kind :display))) (ert-deftest ebox-typed-form-projects-outer-and-layout-to-display () "A typed Box form should project its two axes to one display pair." @@ -143,6 +143,14 @@ (should (equal (ebox--computed-display (ebox-dsl-test--root node)) '(inline flex))))) +(ert-deftest ebox-inline-outer-participates-in-the-cascade () + "An inline outer declaration should outrank a matching stylesheet rule." + (let ((ebox-style-stylesheet (ecss-stylesheet-create))) + (ebox-style-add-rule ".card" '(:outer block)) + (let ((node (ebox-dsl-test--root + (ebox-build '(box :class "card" :outer inline "A"))))) + (should (equal (ebox--computed-display node) '(inline flow)))))) + (ert-deftest ebox-style-unrelated-facts-preserve-typed-layout-config () "Unrelated computed facts must not replace direct typed layout values." (dolist (case '((flex @@ -174,6 +182,80 @@ (equal (ebox-style-compile-declarations '(:bgcolor "red") t) (ebox-style-compile-declarations '(:background-color "red") t)))) +(ert-deftest ebox-canonical-property-vocabulary-is-the-only-author-path () + "The frozen G4b vocabulary should have one canonical spelling and owner." + (dolist (entry '((:font-size . ebox/font-size) + (:font-style . ebox/font-style) + (:text-decoration-line . ebox/text-decoration-line) + (:text-decoration-color . ebox/text-decoration-color) + (:text-decoration-style . ebox/text-decoration-style) + (:item-gap . ebox/item-gap) + (:cross-align . ebox/cross-align) + (:justify-self . ebox/justify-self))) + (should (eq (ebox-style-schema-id (car entry)) (cdr entry)))) + (should (eq (ebox-style-schema-id :bgcolor) + (ebox-style-schema-id :background-color))) + (should (eq (ebox-style-schema-id :font-slant) + (ebox-style-schema-id :font-style))) + (dolist (entry '((:padding-top . ebox/padding-top) + (:padding-right . ebox/padding-right) + (:padding-bottom . ebox/padding-bottom) + (:padding-left . ebox/padding-left) + (:margin-top . ebox/margin-top) + (:margin-right . ebox/margin-right) + (:margin-bottom . ebox/margin-bottom) + (:margin-left . ebox/margin-left))) + (should (eq (ebox-style-schema-id (car entry)) (cdr entry)))) + (should-not (eq (ebox-style-schema-id :padding-top) + (ebox-style-schema-id :padding-block-start))) + (should-not (eq (ebox-style-schema-id :margin-left) + (ebox-style-schema-id :margin-inline-start))) + (dolist (name '(:font :font-height :border-bottom-p + :grid-row-gap :grid-column-gap :vertical-align + :display :layout :content :surface-properties :face + :padding-top-height :padding-right-pixel + :padding-bottom-height :padding-left-pixel + :margin-top-height :margin-right-pixel + :margin-bottom-height :margin-left-pixel + :border-right-pixel :border-left-pixel)) + (should-not (ebox-style-schema-id name))) + (dolist (name '(:border-top-p :grid-column-span :grid-row-span)) + (should (plist-get (ebox-style-property name) :shorthand)))) + +(ert-deftest ebox-normal-text-align-is-not-a-layout-wide-property () + "Only the Normal Box form owns text alignment author input." + (should (ebox-build '(box :text-align center "A"))) + (dolist (tag '(row column flex grid)) + (should-error + (ebox-build (list tag :text-align 'center "A")) + :type 'error))) + +(ert-deftest ebox-edge-projection-uses-one-ecss-winner-order () + "Logical and physical edges conflict locally and cascade across sources." + (should-error + (ebox-style-compile-declarations + '(:padding-left (4) :padding-inline-start (8)) t) + :type 'ecss-invalid-declaration) + (let* ((base (ebox-style-compile-declarations '(:padding (0 (8))) t)) + (override + (ebox-style-compile-declarations '(:padding-left (24)) t)) + (style + (ebox-style-compute-subject + (ecss-subject-create :type "box") + (ebox-style-merge-declarations base override)))) + (should (equal (plist-get (ebox-style--box-values style) :padding-left) + '(24)))) + (let ((ebox-style-stylesheet (ecss-stylesheet-create))) + (ebox-style-add-rule ".card" '(:margin-inline-start (2))) + (ebox-style-add-rule "#target" '(:margin-left (6))) + (let ((style + (ebox-style-compute-subject + (ecss-subject-create + :type "box" :id "target" :classes '("card")) + nil))) + (should (equal (plist-get (ebox-style--box-values style) :margin-left) + '(6)))))) + (ert-deftest ebox-style-rejects-canonical-and-alias-duplicates () "One declaration source should not spell one property twice." (should-error @@ -185,22 +267,24 @@ '(:background-color "red" :bgcolor "blue") t) :type 'error)) -(ert-deftest ebox-style-preserves-ordered-shorthand-longhand-normalization () - "Ebox author pairs remain ordered normalization groups before cutover." - (let ((border-then-top - (ebox-style-compile-declarations - '(:border "red" :border-top none) t)) - (top-then-border - (ebox-style-compile-declarations - '(:border-top none :border "red") t))) - (should (eq 'none - (plist-get border-then-top 'ebox/border-top-style))) - (should (equal "red" - (plist-get border-then-top 'ebox/border-right-color))) - (should (eq 'solid - (plist-get top-then-border 'ebox/border-top-style))) - (should (equal "red" - (plist-get top-then-border 'ebox/border-top-color))))) +(ert-deftest ebox-author-declaration-is-one-atomic-normalization-group () + "One author group rejects overlaps while later cascade groups may override." + (dolist (declarations + '((:padding 1 :padding-left 2) + (:border "red" :border-top none) + (:gap 1 :row-gap 2) + (:flex-flow (row wrap) :flex-wrap nowrap) + (:flex (1 1 auto) :flex-grow 2) + (:border-top-p t :border-top-style none))) + (should-error + (ebox-style-compile-declarations declarations t) + :type 'ecss-invalid-declaration)) + (should + (equal + (ebox-style-merge-declarations + (ebox-style-compile-declarations '(:gap 1) t) + (ebox-style-compile-declarations '(:row-gap 2) t)) + '(ebox/row-gap 2 ebox/column-gap 1)))) (ert-deftest ebox-style-property-registry-rejects-alias-collisions () "An alias should never shadow another canonical property." @@ -226,7 +310,7 @@ (setcar (plist-get metadata :contexts) 'damaged) (should (eq :background-color (plist-get (ebox-style-property :bgcolor) :name))) - (should (equal '(box) + (should (equal '(text box) (plist-get (ebox-style-property :bgcolor) :contexts))))) (ert-deftest ebox-style-border-present-is-a-typed-shorthand-not-an-alias () @@ -239,8 +323,49 @@ '(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." +(ert-deftest ebox-border-width-unit-is-always-integer-pixels () + "Border width should not reuse Box edge column/pixel wrapper grammar." + (should + (equal + (ebox-style-compile-declarations '(:border (1 solid "#687386")) t) + '(ebox/border-top-width 1 ebox/border-top-style solid + ebox/border-top-color "#687386" + ebox/border-right-width 1 ebox/border-right-style solid + ebox/border-right-color "#687386" + ebox/border-bottom-width 1 ebox/border-bottom-style solid + ebox/border-bottom-color "#687386" + ebox/border-left-width 1 ebox/border-left-style solid + ebox/border-left-color "#687386"))) + (should-error + (ebox-style-compile-declarations + '(:border ((1) solid "#687386")) t) + :type 'error)) + +(ert-deftest ebox-border-engine-projection-keeps-exact-pixels () + "Border projection must not reinterpret pixels as character columns." + (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7))) + (should + (equal + (ebox-style--expand-engine-plist + '(:border-left-width 1 :border-left-style solid + :border-right-width 2 :border-right-style solid + :padding-left 1 :margin-right (3))) + '(:border-left-pixel 1 :border-left-style solid + :border-right-pixel 2 :border-right-style solid + :padding-left-pixel 7 :margin-right-pixel 3)))) + (should + (equal + (ebox-style--expand-engine-plist + '(:border-left-width 2 :border-left-style none)) + '(:border-left-pixel 0 :border-left-style none))) + (should + (equal + (ebox-style--expand-engine-plist + '(:border-top-width 2 :border-top-style solid)) + '(:border-top-pixel 2 :border-top-style solid)))) + +(ert-deftest ebox-style-declaration-cache-expands-shorthand-once () + "A cached declaration should not expand its shorthand again." (let ((calls 0) (original (symbol-function 'ebox-style--padding-shorthand))) (clrhash ebox-style--declaration-cache) @@ -862,7 +987,7 @@ "The typed Grid constructor should emit one canonical closed property set." (let* ((layout (ebox-grid-layout-create - :grid-template-columns '((20) 1fr) + :grid-template-columns '((20) (fr 1)) :grid-template-rows '(1 auto) :grid-auto-columns '((30)) :grid-auto-rows '(2) @@ -897,15 +1022,28 @@ (dolist (value ebox--grid-content-alignment-values) (should (ebox-grid-layout-create :justify-content value)) (should (ebox-grid-layout-create :align-content value))) + (dolist (value '(min-content max-content)) + (should (ebox-grid-layout-create :grid-template-columns (list value))) + (should (ebox-grid-layout-create :grid-template-rows (list value))) + (should (ebox-grid-layout-create :grid-auto-columns value)) + (should (ebox-grid-layout-create :grid-auto-rows value))) + (should + (ebox-grid-layout-create + :grid-template-columns '((repeat 2 (auto (fr 1)))))) (dolist (plist '((:grid-auto-flow diagonal) (:grid-template-columns (bogus)) (:grid-template-rows (bogus)) (:grid-template-columns (0fr)) (:grid-template-columns (0.0fr)) + (:grid-template-columns (1fr)) + (:grid-template-columns (fr 1)) + (:grid-template-columns min-content) (:grid-template-columns ((fr 0))) (:grid-template-columns ((minmax (fr 1) auto))) (:grid-template-columns ((minmax (minmax (10) (20)) auto))) + (:grid-template-columns ((repeat 0 auto))) + (:grid-auto-columns ((repeat 2 auto))) (:grid-auto-columns ((10) (20))) (:row-gap -1) (:column-gap (-1)) @@ -1161,7 +1299,41 @@ (should-error (ebox-build form) :type 'error)) (should-error (ebox-build (ebox-test-box (ebox-test-text "raw runtime node"))) + :type 'error) + (should-error + (ebox-build + (list 'box :color (tp-computed (lambda () "#112233")) "dynamic")) + :type 'error) + (should-error + (ebox-build + (list 'box :color + (tp-paint-slot-create '(:foreground "#112233")) "slot")) :type 'error)) +(ert-deftest ebox-build-rejects-explicit-nil-outside-boolean-grammar () + "Internal nil initial values must not widen the public author grammar." + (dolist (form + '((text :color nil "A") + (text :font-size nil "A") + (grid :grid-template-columns nil "A") + (grid :grid-template-rows nil "A") + (grid :grid-auto-columns nil "A") + (grid :grid-auto-rows nil "A"))) + (should-error (ebox-build form) :type 'error)) + (should (ebox-build '(box :border-top-p nil "A")))) + +(ert-deftest ebox-source-metadata-uses-the-frozen-public-grammar () + "Key, class, and id should remain source facts with closed value domains." + (should + (ebox-build + '(box :key stable :id 42 :class (card selected) "Valid"))) + (dolist (form '((box :key nil "bad") + (box :key (not stable) "bad") + (box :id nil "bad") + (box :id 1.5 "bad") + (box :class (card 2) "bad") + (box :class (card . selected) "bad"))) + (should-error (ebox-build form) :type 'error))) + (provide 'ebox-dsl-tests) ;;; ebox-dsl-tests.el ends here diff --git a/tests/ebox-flex-tests.el b/tests/ebox-flex-tests.el index b6823d0..dc3d610 100644 --- a/tests/ebox-flex-tests.el +++ b/tests/ebox-flex-tests.el @@ -260,8 +260,8 @@ (should (= (apply #'+ widths) 150)) (should (> (car widths) (cadr widths))))) -(ert-deftest ebox-flex-basis-supports-content-and-intrinsic-keywords () - "Flex basis content and intrinsic keywords should use content-derived sizes." +(ert-deftest ebox-flex-basis-supports-intrinsic-keywords () + "Flex basis intrinsic keywords should use content-derived sizes." (let* ((content "aa bbbb") (max-content (ebox--string-pixel-width content)) (min-content (ebox--string-pixel-width "bbbb")) @@ -269,7 +269,7 @@ (ebox-test-flex :width '(300) (ebox-test-flex-item (ebox-test-box (ebox-test-text content) :width '(160) :wrap-mode 'word) - :flex-basis 'content) + :flex-basis 'max-content) (ebox-test-flex-item (ebox-test-box (ebox-test-text content) :width '(160) :wrap-mode 'word) :flex-basis 'min-content))) @@ -733,8 +733,8 @@ (let* ((layout (ebox-test-flex :width '(120) (ebox-test-row - (ebox-test-box (ebox-test-text "Left") :border t) - (ebox-test-box (ebox-test-text "Right") :border t)))) + (ebox-test-box (ebox-test-text "Left") :border '(1 solid)) + (ebox-test-box (ebox-test-text "Right") :border '(1 solid))))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render layout))))) (should (cl-every (lambda (width) (<= width 120)) widths)))) @@ -1032,13 +1032,13 @@ :height 3 :box-sizing 'border-box :padding '(0 (8)) - :border t + :border '(1 solid) :wrap-mode 'none) (ebox-test-box (ebox-test-text "sample box three") :height 3 :box-sizing 'border-box :padding '(0 (8)) - :border t + :border '(1 solid) :wrap-mode 'none))) (plain (substring-no-properties (ebox-flex-test--render layout)))) (should-not (string-match-p "REWRAPPED" plain)) @@ -1090,7 +1090,7 @@ (ebox-test-box (ebox-test-text "Bottom") :width '(80))) :width '(180) :box-sizing 'border-box - :border t))) + :border '(1 solid)))) (plain (substring-no-properties (ebox-flex-test--render layout)))) (should-not (string-match-p "REWRAPPED" plain)) (should (string-match-p "Top" plain)) @@ -1126,7 +1126,7 @@ "One child Box should own both frame and Flex participation facts." (let* ((left (ebox-test-box (ebox-test-text "A") :width '(60) :flex-grow 1 :flex-basis '(60) - :box-sizing 'border-box :border t)) + :box-sizing 'border-box :border '(1 solid))) (right (ebox-test-box (ebox-test-text "B") :width '(40))) (layout (ebox-test-flex :width '(160) left right)) (left-id (car (ebox-region-ids (ebox-test-root left)))) @@ -1176,20 +1176,22 @@ (ebox-test-box (ebox-test-text "A") :width '(60) :height 1) (ebox-test-box (ebox-test-text "B") :width '(60) :height 1) (ebox-test-box (ebox-test-text "C") :width '(60) :height 1)))))) - (dolist (justify '(flex-start flex-end center space-between space-around space-evenly start end left right normal)) + (dolist (justify '(normal start end flex-start flex-end center stretch + space-between space-around space-evenly)) (should (stringp (ebox-render (ebox-test-flex :width '(220) :justify-content justify (ebox-test-box (ebox-test-text "A") :width '(60)) (ebox-test-box (ebox-test-text "B") :width '(60))))))) - (dolist (align '(stretch flex-start flex-end center baseline normal start end self-start self-end)) + (dolist (align '(normal start end stretch flex-start flex-end center baseline)) (should (stringp (ebox-render (ebox-test-flex :width '(220) :height 4 :align-items align (ebox-test-box (ebox-test-text "A") :width '(60) :height 1) (ebox-test-box (ebox-test-text "B") :width '(60) :height 1)))))) - (dolist (align '(stretch flex-start flex-end center space-between space-around space-evenly baseline normal start end)) + (dolist (align '(normal start end stretch flex-start flex-end center + space-between space-around space-evenly)) (should (stringp (ebox-render (ebox-test-flex :width '(130) :height 5 :flex-wrap 'wrap diff --git a/tests/ebox-font-tests.el b/tests/ebox-font-tests.el new file mode 100644 index 0000000..b7d9b3f --- /dev/null +++ b/tests/ebox-font-tests.el @@ -0,0 +1,152 @@ +;;; ebox-font-tests.el --- Font projection contracts -*- lexical-binding: t; -*- + +;;; Code: + +(require 'ert) +(require 'ebox) + +(ert-deftest ebox-canonical-property-cutover-shares-one-resolved-font-fact () + "Real Surface measurement and paint share one capability-resolved fact." + (let* ((ebox-font--cache (make-hash-table :test #'equal)) + (resolver-calls 0) + (ebox-font--resolver + (lambda (face _window) + (cl-incf resolver-calls) + (list :identity (list 'resolved-font (copy-tree face)) + :metrics (vector 'metrics (copy-tree face)))))) + (let* ((below + (ebox-font--project + :size 1.39 :weight 'normal :style 'normal + :capability-signature 'display-a)) + (above + (ebox-font--project + :size 1.4 :weight 'normal :style 'normal + :capability-signature 'display-a)) + (same + (ebox-font--project + :size 1.4 :weight 'normal :style 'normal + :capability-signature 'display-a)) + (changed-capability + (ebox-font--project + :size 1.4 :weight 'normal :style 'normal + :capability-signature 'display-b))) + (should (= (ebox-font-fact-requested-decipoints below) 10)) + (should (= (ebox-font-fact-requested-decipoints above) 11)) + (should (eq above same)) + (should-not (eq above changed-capability)) + (let ((identity (ebox-font-fact-resolved-font-identity above)) + (metrics (ebox-font-fact-measurement-metrics above))) + (setf (plist-get identity :css-weight) 900) + (aset metrics 0 'damaged) + (should (= (plist-get + (ebox-font-fact-resolved-font-identity above) + :css-weight) + 400)) + (should (eq (aref (ebox-font-fact-measurement-metrics above) 0) + 'metrics))) + (should (= resolver-calls 3))) + (setq resolver-calls 0 + ebox-font--cache (make-hash-table :test #'equal)) + (let ((capability 'display-a) + (buffer (generate-new-buffer " *ebox-font-contract*"))) + (unwind-protect + (cl-letf (((symbol-function 'ebox--display-signature-for-window) + (lambda (_window) capability))) + (ebox-render-to-buffer + buffer + (ebox-build + '(box :width (viewport) + (text :font-size 16 :font-style italic "A")))) + (let* ((state-a (ebox--buffer-render-state buffer)) + (node-a + (car (ebox-tree-node-children + (plist-get state-a :root-node)))) + (fact-a (plist-get node-a :ebox-font-fact)) + (measurement-fact nil) + (paint-fact nil) + (measurement-function + (symbol-function 'ebox-font-measurement-face)) + (paint-function (symbol-function 'ebox-font-paint-face)) + measured face) + (cl-letf (((symbol-function 'ebox-font-measurement-face) + (lambda (projection) + (setq measurement-fact projection) + (funcall measurement-function projection))) + ((symbol-function 'ebox-font-paint-face) + (lambda (projection) + (setq paint-fact projection) + (funcall paint-function projection)))) + (setq measured (ebox--propertize-typography "A" node-a) + face (ebox-buffer--font-face node-a))) + (should (ebox-font-fact-p fact-a)) + (should (= resolver-calls 1)) + (should (= (hash-table-count ebox-font--cache) 1)) + (should (= (ebox-font-fact-requested-decipoints fact-a) 120)) + (should (eq fact-a measurement-fact)) + (should (eq fact-a paint-fact)) + (should (integerp (plist-get face :height))) + (should (= (plist-get face :height) 120)) + (should (eq (plist-get face :slant) 'italic)) + (should (numberp (ebox-string-pixel-width measured))) + (setq capability 'display-b) + (let ((tp--surface-publication-step-function + (lambda (step _surface) + (when (eq step 'client-state) + (error "Reject font capability publication"))))) + (should-error + (ebox-surface-update-buffer-viewport + buffer (plist-get state-a :viewport-width) + (plist-get state-a :viewport-height)))) + (let* ((rolled-back (ebox--buffer-render-state buffer)) + (rolled-back-node + (car (ebox-tree-node-children + (plist-get rolled-back :root-node))))) + (should (eq rolled-back state-a)) + (should (eq (plist-get rolled-back-node :ebox-font-fact) + fact-a))) + (ebox-surface-update-buffer-viewport + buffer (plist-get state-a :viewport-width) + (plist-get state-a :viewport-height)) + (let* ((state-b (ebox--buffer-render-state buffer)) + (node-b + (car (ebox-tree-node-children + (plist-get state-b :root-node)))) + (fact-b (plist-get node-b :ebox-font-fact))) + (should-not (eq fact-a fact-b)) + (should (eq (ebox-font-fact-capability-signature fact-b) + 'display-b)) + (should (= resolver-calls 2)) + (should (= (hash-table-count ebox-font--cache) 2))))) + (when (buffer-live-p buffer) + (kill-buffer buffer)))))) + +(ert-deftest ebox-relative-font-weight-resolves-before-emacs-paint () + "CSS relative weight should not leak into an Emacs face attribute." + (let ((ebox-font--cache (make-hash-table :test #'equal)) + (ebox-font--resolver + (lambda (face _window) + (list :identity (list :face face) :metrics nil))) + (buffer (generate-new-buffer " *ebox-font-relative*"))) + (unwind-protect + (cl-letf (((symbol-function 'ebox--display-signature-for-window) + (lambda (_window) 'display-a))) + (ebox-render-to-buffer + buffer + (ebox-build + '(box :font-weight 400 + (text :font-weight bolder "Bold")))) + (let* ((root (plist-get (ebox--buffer-render-state buffer) + :root-node)) + (child (car (ebox-tree-node-children root))) + (fact (plist-get child :ebox-font-fact))) + (should (= (plist-get + (ebox-font-fact-resolved-font-identity fact) + :css-weight) + 700)) + (should (eq (plist-get (ebox-font-paint-face fact) :weight) + 'bold)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(provide 'ebox-font-tests) +;;; ebox-font-tests.el ends here diff --git a/tests/ebox-grid-tests.el b/tests/ebox-grid-tests.el index bbbb13f..16ba772 100644 --- a/tests/ebox-grid-tests.el +++ b/tests/ebox-grid-tests.el @@ -30,17 +30,44 @@ "Fractional tracks should fill a definite grid width." (let* ((node (ebox-test-grid :width '(120) - :grid-template-columns '(1fr 2fr) + :grid-template-columns '((fr 1) (fr 2)) (ebox-test-box (ebox-test-text "A")) (ebox-test-box (ebox-test-text "B")))) (lines (ebox-string-lines (ebox-render node)))) (should (= (length lines) 1)) (should (= (ebox--string-pixel-width (car lines)) 120)))) +(ert-deftest ebox-grid-intrinsic-track-keywords-use-distinct-measures () + "min-content and max-content should reuse Ebox intrinsic measurements." + (let* ((left (ebox-test-root (ebox-test-text "alpha beta"))) + (right (ebox-test-root (ebox-test-text "gamma delta epsilon"))) + (entries (list (list :node left :column 1 :column-span 1) + (list :node right :column 2 :column-span 1))) + (rendered (make-hash-table :test #'eq)) + (tracks (ebox-grid--normalize-tracks + '(min-content max-content) 'columns))) + (puthash left "alpha beta" rendered) + (puthash right "gamma delta epsilon" rendered) + (let ((sizes (ebox-grid--resolve-sizes + tracks 2 'columns entries rendered 0 nil))) + (should + (= (car sizes) (ebox--line-min-content-pixel "alpha beta"))) + (should + (= (cadr sizes) (ebox--string-pixel-width "gamma delta epsilon"))) + (should (< (car sizes) (ebox--string-pixel-width "alpha beta")))))) + +(ert-deftest ebox-grid-repeat-expands-a-complete-track-list () + "repeat should duplicate its ordered track-list body." + (should + (equal + (ebox-grid--normalize-tracks '((repeat 2 (auto (fr 1)))) 'columns) + '((:kind auto :factor 0) (:kind fr :factor 1) + (:kind auto :factor 0) (:kind fr :factor 1))))) + (ert-deftest ebox-grid-repeated-render-reuses-construction-time-config () "Grid render and resize paths should not reparse canonical track config." (let* ((node (ebox-test-grid :width '(120) - :grid-template-columns '(1fr 2fr) + :grid-template-columns '((fr 1) (fr 2)) (ebox-test-box (ebox-test-text "A")) (ebox-test-box (ebox-test-text "B")))) (calls 0) @@ -102,8 +129,8 @@ :width '(120) :grid-template-columns '((120)) (ebox-test-row - (ebox-test-box (ebox-test-text "Left") :border t) - (ebox-test-box (ebox-test-text "Right") :border t)))) + (ebox-test-box (ebox-test-text "Left") :border '(1 solid)) + (ebox-test-box (ebox-test-text "Right") :border '(1 solid))))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node))))) (should (equal widths '(120))))) @@ -148,7 +175,7 @@ "Grid wrapper borders should stay on one right edge across child lines." (let* ((node (ebox-test-grid :width '(120) - :grid-template-columns '((30) auto 1fr) + :grid-template-columns '((30) auto (fr 1)) :grid-template-rows '(2) :gap '(1 (4)) :justify-content 'space-between @@ -165,7 +192,7 @@ (let* ((header (ebox-test-box (ebox-test-text "Header") :width '(720))) (grid (ebox-test-grid :width '(718) - :grid-template-columns '((220) 1fr) + :grid-template-columns '((220) (fr 1)) :gap '(1 (12)) :border "#C97252" (ebox-test-box (ebox-test-text "Fixed") :width '(196) diff --git a/tests/ebox-package-tests.el b/tests/ebox-package-tests.el index b6a46db..cc6249b 100644 --- a/tests/ebox-package-tests.el +++ b/tests/ebox-package-tests.el @@ -129,7 +129,7 @@ (should (commandp 'ebox-byte-compile)) (should (equal ebox--compile-sources - '("ebox-cache.el" "ebox-source.el" "ebox-style.el" + '("ebox-cache.el" "ebox-source.el" "ebox-font.el" "ebox-style.el" "ebox-layout-config.el" "ebox-node-factory.el" "ebox-child-range.el" diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 63cafb4..94f120f 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -177,7 +177,11 @@ (should pixelwise) (if (eq window 'current-frame-window) 901 333))) ((symbol-function 'window-body-height) - (lambda (_window &optional _pixelwise) 31))) + (lambda (_window &optional _pixelwise) 31)) + ((symbol-function 'ebox--display-signature-for-window) + (lambda (window) + (should (eq window 'current-frame-window)) + 'current-frame-capability))) (let ((values (ebox-surface--context-values buffer nil nil))) (should (= 899 (plist-get values :viewport-width))) (should (= 31 (plist-get values :viewport-height))))) @@ -366,7 +370,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (cons 'grid (lambda () (ebox-test-grid - :width '(220) :grid-template-columns '((70) 1fr) + :width '(220) :grid-template-columns '((70) (fr 1)) :grid-template-rows '(2) :gap '(1 (8)) :border "#475569" (ebox-test-box :key 'grid-left (ebox-test-text "A\nAA") @@ -602,9 +606,10 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (input (ebox-test-box :width 20 - (ebox-test-text - "native" :color 'ebox/default-foreground))) + (ebox-test-text "native"))) (node (ebox-test-root input)) + (text-node (car (ebox-box-node-children node))) + (_ (plist-put text-node :color 'ebox/default-foreground)) (package (ebox-native-reflow--compile-layout-package node)) (document (plist-get package :document)) (styles (plist-get document :styles)) @@ -820,7 +825,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-surface-test--render-fresh (lambda () (ebox-test-box - :font-height 1.25 + :font-size 16 (ebox-test-box (ebox-test-text "Inherited")))) t)) (should (> calls 0)))) @@ -1253,7 +1258,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." collect (ebox-test-flex-item (ebox-test-box - :key (list 'viewport-reflow-item index) + :key (format "viewport-reflow-item-%d" index) (ebox-test-text (format "item-%02d" index)) :width '(70) :padding '(0 1) @@ -1671,7 +1676,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-render-to-buffer buffer (ebox-test-box - :font-height 1.25 + :font-size 16 :width '(viewport) (ebox-test-box (ebox-test-text "Inherited")))) (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) @@ -2014,8 +2019,8 @@ candidate cannot hide mutations by restoring the old hash-table pointer." ebox-surface--context-signals)) (revision (tp-surface-revision surface)) (next-signature '(ebox-test-theme dark))) - (cl-letf (((symbol-function 'ebox--display-signature) - (lambda () next-signature))) + (cl-letf (((symbol-function 'ebox--display-signature-for-window) + (lambda (_window) next-signature))) (ebox-rerender-buffer-with-context buffer 120 4)) (should (= (tp-surface-revision surface) (1+ revision))) (should (equal @@ -2034,10 +2039,12 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ert-deftest ebox-inline-style-reuse-projects-onto-fresh-text-node () "A reused computed style must still materialize a fresh Text projection." - (let* ((builder (ebox-source-builder-create)) + (let* ((foreground "#0F172A") + (background "#F8FAFC") + (builder (ebox-source-builder-create)) (parent-declarations - '(ebox/color foreground-slot - ebox/background-color background-slot)) + (list 'ebox/color foreground + 'ebox/background-color background)) (parent-handle (ebox-source-builder-bind builder :identity 'parent :declarations parent-declarations)) @@ -2098,12 +2105,12 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-surface-prepare-inline-candidate-styles old-state candidate '(1 2)) (should (eq child-style (plist-get new-child :ebox-computed-style))) - (should (eq 'foreground-slot (plist-get new-child :color))) + (should (equal foreground (plist-get new-child :color))) (should (equal "New" (ebox-text-node-value new-child))) (should (eq child-handle (ebox-node-source-handle new-child))) (dolist (property ebox-tree--participation-keys) (should-not (plist-member new-child property))) - (should (eq 'background-slot (plist-get parent :bgcolor))))) + (should (equal background (plist-get parent :bgcolor))))) (ert-deftest ebox-style-theme-delta-reuses-cascade-facts () "Copy a paint-only Theme delta without rerunning ECSS cascade computation." @@ -2177,7 +2184,7 @@ changes. Geometry and non-inherited computed values must remain identical." (ecss-computed-style-values delta))) (let ((parent-font-new (ebox-style-compute-subject - parent '(ebox/color "#222222" ebox/font-height 2.0)))) + parent '(ebox/color "#222222" ebox/font-size 20)))) (should-not (ebox-style--theme-parent-delta-computed style declarations parent-old parent-font-new))))) @@ -2194,16 +2201,16 @@ changes. Geometry and non-inherited computed values must remain identical." (should-not (memq :ebox-content-width-exact-p (ebox-tree-node-local-changed-keys old new))))) -(ert-deftest ebox-tree-grid-source-signature-canonicalizes-layout-aliases () - "Equivalent Grid gap/paint aliases must not become geometry dirtiness." +(ert-deftest ebox-tree-grid-source-signature-canonicalizes-gap-shorthand () + "Equivalent Grid gap forms must not become geometry dirtiness." (let ((old (list :ebox-type 'grid :raw-props '(:width stretch - :grid-template-columns (1fr 1fr) - :grid-row-gap 1 :grid-column-gap (12) + :grid-template-columns ((fr 1) (fr 1)) + :row-gap 1 :column-gap (12) :color "#252A2E" :background-color "#F8F5EE"))) (new (list :ebox-type 'grid :raw-props '(:width stretch - :grid-template-columns (1fr 1fr) + :grid-template-columns ((fr 1) (fr 1)) :gap (1 (12)) :color "#F2EEE4" :bgcolor "#1B1F20")))) (should-not (memq :props (ebox-tree-node-local-changed-keys old new))) @@ -2218,10 +2225,10 @@ changes. Geometry and non-inherited computed values must remain identical." :padding-inline-end 2 :padding-block-end 0 :padding-inline-start 2 - :border-top-width (1) - :border-right-width (1) - :border-bottom-width (1) - :border-left-width (1) + :border-top-width 1 + :border-right-width 1 + :border-bottom-width 1 + :border-left-width 1 :border-top-style solid :border-right-style solid :border-bottom-style solid @@ -2237,7 +2244,7 @@ changes. Geometry and non-inherited computed values must remain identical." :raw-props '(:width stretch :gap (1 (12)) :padding (0 2) - :border ((1) solid "#687386") + :border (1 solid "#687386") :align-items center :color "#F2EEE4" :bgcolor "#1B1F20")))) @@ -2249,11 +2256,11 @@ changes. Geometry and non-inherited computed values must remain identical." (let* ((parent (ecss-subject-create :type "box")) (child (ecss-subject-create :type "box" :parent parent)) (parent-old - (ebox-style-compute-subject - parent '(ebox/font-height 1.0))) + (ebox-style-compute-subject + parent '(ebox/font-size 16))) (parent-new - (ebox-style-compute-subject - parent '(ebox/font-height 2.0))) + (ebox-style-compute-subject + parent '(ebox/font-size 20))) (declarations '(ebox/color "#ffffff")) (style (ebox-style-compute-subject child declarations parent-old)))