feat(ebox): publish standalone low-level package

Split the verified renderer, layout engine, Grid support, native boundary, tests, examples, and paired documentation into the independent Ebox repository. Keep ETAF and application concerns outside this package.
This commit is contained in:
Kinneyzhang 2026-08-05 09:15:35 +08:00
commit 8a8e862098
54 changed files with 66070 additions and 0 deletions

90
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,90 @@
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y emacs-nox python3
- name: Run local verification suite
run: make ci EMACS=emacs PYTHON=python3
- name: Run package-lint
run: make package-lint-install EMACS=emacs
native-build:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install Rust verification toolchain
run: rustup toolchain install stable --profile minimal --component rustfmt,clippy
- name: Check native formatting
run: cargo +stable fmt --manifest-path native/Cargo.toml -- --check
- name: Lint native targets
run: cargo +stable clippy --locked --manifest-path native/Cargo.toml --all-targets -- -D warnings
- name: Test native runtime
run: cargo +stable test --locked --manifest-path native/Cargo.toml
- name: Build native module
run: cargo +stable build --locked --manifest-path native/Cargo.toml --release
- name: Verify platform module artifact
shell: bash
run: |
case "$RUNNER_OS" in
Linux) test -f native/target/release/libebox_native_reflow.so ;;
macOS) test -f native/target/release/libebox_native_reflow.dylib ;;
Windows) test -f native/target/release/ebox_native_reflow.dll ;;
*) exit 1 ;;
esac
native-msrv:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install Rust minimum supported toolchain
run: rustup toolchain install 1.82.0 --profile minimal
- name: Test native runtime at MSRV
run: cargo +1.82.0 test --locked --manifest-path native/Cargo.toml
- name: Build native module at MSRV
run: cargo +1.82.0 build --locked --manifest-path native/Cargo.toml --release
- name: Verify MSRV platform module artifact
shell: bash
run: |
case "$RUNNER_OS" in
Linux) test -f native/target/release/libebox_native_reflow.so ;;
macOS) test -f native/target/release/libebox_native_reflow.dylib ;;
Windows) test -f native/target/release/ebox_native_reflow.dll ;;
*) exit 1 ;;
esac

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
*.elc
__pycache__
scripts/ebox-scroll-profile.el
native/target/
# Local oh-my-codex runtime state/logs.
.omx/
# Local isolated implementation worktrees.
.worktrees/

9
AGENTS.md Normal file
View File

@ -0,0 +1,9 @@
# Ebox repository guidance
This repository is the standalone low-level Ebox package. It owns box nodes, style normalization, measurement, layout, rendering, buffer publication, selectors, incremental updates, Grid, and the optional Rust reflow module. The sibling `etaf` repository owns the higher-level text-application framework; `etaf-playground` and `ebox-playground` are separate example packages.
Keep this repository independent from ETAF. Do not add Components, Runtime state, Data Controllers, UI controls, or application examples here. Public Ebox code may depend on its own modules only; private `ebox--*` functions are implementation details.
Use `apply_patch` for hand-edited changes. Keep each file lexically bound, document public APIs, preserve deterministic rendering, and add a focused regression test for behavior changes. Run `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs` before claiming completion. Run `make native-rust-tests` when changing `native/` and run `make docs-contract-tests` when changing documentation or the active file boundary.
The old `emacs-box` checkout is a historical full-stack source tree. It is intentionally not a dependency of this package and should not be edited as part of Ebox work unless a separate task explicitly targets that legacy repository.

37
DESIGN.md Normal file
View File

@ -0,0 +1,37 @@
# Ebox design
Ebox is the low-level rendering engine. Its job is to turn a declarative node tree into measured layout fragments and then publish those fragments into an Emacs buffer. It is deliberately smaller than an application framework.
## Design principles
- One source tree, one style normalizer, one layout pipeline, one buffer owner.
- A public node is data; rendering and mutation happen only at explicit entry points.
- Stable identity comes from node identity and explicit keys, never from visible text or selector strings.
- Layout changes are measured before publication; a failed candidate preserves the previous buffer state.
- The native module is an optional accelerator with an exact Elisp fallback.
- New abstractions must remove duplication or make an existing public workflow simpler.
## Pipeline
```text
node tree
-> normalized style
-> measured formatting context
-> layout fragments and snapshot
-> render context
-> buffer patch/publication
```
The owners are `ebox-tree.el` for identity and traversal, `ebox-style.el` for style semantics, `ebox-measure.el` for display-sensitive measurement, `ebox-layout.el`/`ebox-flex.el`/`ebox-grid.el` for geometry, `ebox-fragment.el` for fragment facts, `ebox-buffer-backend.el` for text properties and buffer edits, and `ebox-incremental.el` for dirty planning and publication.
## Public boundary
Use `ebox-create`, `ebox-column`, `ebox-row`, `ebox-flex`, `ebox-grid`, `ebox-build`, `ebox-render`, `ebox-render-to-buffer`, `ebox-commit`, and selector entry points. Do not call `ebox--*` names from an application or a sibling package. Higher-level Component, reactive, behavior, data, and control concepts belong to ETAF.
## Layout scope
Ebox supports character and pixel dimensions, padding, margins, borders, colors, faces, overflow, wrapping, row/column/flex formatting, and Grid tracks including fixed, fractional, implicit, gap, placement, span, and alignment behavior. CSS compatibility is intentionally partial: browser cascade, percentages, absolute positioning, z-index, shadows, full typography, and browser-level bidi are outside this package.
## Verification rule
Every behavior change needs a focused ERT test and a fresh `make check`. Changes to native source also require `make native-rust-tests`; changes to docs or the active file set require `make docs-contract-tests`.

37
DESIGN.zh.md Normal file
View File

@ -0,0 +1,37 @@
# Ebox 设计
Ebox 是底层渲染引擎。它负责把声明式节点树转换为经过测量的布局 fragment再发布到 Emacs buffer。它有意小于应用框架。
## 设计原则
- 一棵 source tree、一套样式归一化、一条布局流水线、一个 buffer owner。
- 公共节点是数据;只有显式入口负责渲染与变更。
- 稳定 identity 来自节点 identity 和显式 key不来自可见文本或 selector 字符串。
- 布局变化先测量再发布;候选发布失败时保留上一个 buffer 状态。
- native 模块只是可选加速器,必须有完全等价的 Elisp fallback。
- 新抽象必须消除重复,或让已有的公共工作流更简单。
## 流水线
```text
节点树
-> 归一化样式
-> 经过测量的 formatting context
-> 布局 fragment 与 snapshot
-> render context
-> buffer patch/发布
```
各层 owner 是:`ebox-tree.el` 负责 identity 与遍历,`ebox-style.el` 负责样式语义,`ebox-measure.el` 负责 display 敏感测量,`ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` 负责几何,`ebox-fragment.el` 负责 fragment 事实,`ebox-buffer-backend.el` 负责 text property 与 buffer 编辑,`ebox-incremental.el` 负责 dirty 规划与发布。
## 公共边界
使用 `ebox-create`、`ebox-column`、`ebox-row`、`ebox-flex`、`ebox-grid`、`ebox-build`、`ebox-render`、`ebox-render-to-buffer`、`ebox-commit` 以及 selector 入口。应用和同级包不得调用 `ebox--*` 私有名称。更高层的 Component、响应式、behavior、data 和 control 概念属于 ETAF。
## 布局范围
Ebox 支持字符和像素尺寸、padding、margin、border、颜色、face、overflow、换行、row/column/flex formatting以及固定、分数、隐式、gap、放置、span 和对齐等 Grid 能力。CSS 兼容性有意是部分实现:浏览器 cascade、百分比、绝对定位、z-index、阴影、完整 typography 和浏览器级 bidi 不属于本包。
## 验证规则
每个行为修改都要补充聚焦的 ERT 测试,并运行新的 `make check`。修改 native 源码还要运行 `make native-rust-tests`;修改文档或 active 文件集合还要运行 `make docs-contract-tests`

84
Makefile Normal file
View File

@ -0,0 +1,84 @@
EMACS ?= emacs
CARGO ?= cargo
EMACS_BATCH = $(EMACS) -Q --batch -L . --eval '(setq load-prefer-newer t)'
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 core-tests grid-tests ebox-commit-tests visual-check-tests package-tests selector-tests examples-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install
all: check
check: ci
ci: checkdoc load compile test visual-check diff-check
load:
$(EMACS_BATCH) -l ebox.el --eval '(princ "ebox load OK\n")'
compile:
rm -f *.elc tests/*.elc scripts/*.elc
$(EMACS_BATCH) --eval '(setq byte-compile-error-on-warn t)' -l ebox.el --eval '(ebox-byte-compile)'
test: core-tests grid-tests ebox-commit-tests visual-check-tests package-tests selector-tests examples-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests
core-tests:
$(EMACS_BATCH) -l tests/ebox-core-render-tests.el -f ert-run-tests-batch-and-exit
grid-tests:
$(EMACS_BATCH) -l tests/ebox-grid-tests.el -f ert-run-tests-batch-and-exit
ebox-commit-tests:
$(EMACS_BATCH) -l tests/ebox-commit-tests.el -f ert-run-tests-batch-and-exit
visual-check-tests:
$(EMACS_BATCH) -l tests/ebox-visual-check-tests.el -f ert-run-tests-batch-and-exit
package-tests:
$(EMACS_BATCH) -l tests/ebox-package-tests.el -f ert-run-tests-batch-and-exit
selector-tests:
$(EMACS_BATCH) -l tests/ebox-selector-tests.el -f ert-run-tests-batch-and-exit
examples-tests:
$(EMACS_BATCH) -L examples -l tests/ebox-examples-tests.el -f ert-run-tests-batch-and-exit
dsl-tests:
$(EMACS_BATCH) -l tests/ebox-dsl-tests.el -f ert-run-tests-batch-and-exit
flex-tests:
$(EMACS_BATCH) -l tests/ebox-flex-tests.el -f ert-run-tests-batch-and-exit
docs-contract-tests:
$(EMACS_BATCH) -l tests/ebox-docs-contract-tests.el -f ert-run-tests-batch-and-exit
ci-contract-tests:
$(EMACS_BATCH) -l tests/ebox-ci-contract-tests.el -f ert-run-tests-batch-and-exit
visual-check:
$(EMACS_BATCH) -l scripts/ebox-visual-check.el -f ebox-visual-check-batch
native-rust-tests:
$(CARGO) fmt --manifest-path $(NATIVE_MANIFEST) -- --check
$(CARGO) clippy --locked --manifest-path $(NATIVE_MANIFEST) --all-targets -- -D warnings
$(CARGO) test --locked --manifest-path $(NATIVE_MANIFEST)
native-build:
$(CARGO) build --locked --manifest-path $(NATIVE_MANIFEST) --release --target $(NATIVE_TARGET)
package-lint:
$(EMACS_BATCH) -l scripts/ebox-package-lint.el -f ebox-package-lint-batch
package-lint-install:
EBOX_PACKAGE_LINT_INSTALL=1 $(MAKE) package-lint EMACS="$(EMACS)"
checkdoc:
$(EMACS) -Q --batch --eval '(progn (require (quote checkdoc)) (dolist (file (directory-files "." t)) (when (string-suffix-p ".el" file) (checkdoc-file file))))'
diff-check:
git diff --check
clean:
rm -f *.elc tests/*.elc scripts/*.elc
rm -rf native/target

59
README.md Normal file
View File

@ -0,0 +1,59 @@
# Ebox
Ebox is a standalone, pixel-precise box and layout engine for Emacs. It provides the low-level rendering substrate used by the sibling [ETAF](../etaf/README.md) text-application framework. Ebox can also be used directly without Components, application state, or ETAF.
## Install and use
Clone or place this directory on `load-path`:
```elisp
(add-to-list 'load-path "/path/to/github/ebox")
(require 'ebox)
(ebox-render-to-buffer
"*Ebox Example*"
(ebox-column
(ebox-create :content "Hello Ebox"
:padding '(1 2)
:border '((1) solid "#8A93A6")
:color "#263244"
:bgcolor "#F4F6FB")))
```
The public model is a tree of Ebox nodes. `ebox-create` builds a leaf or wrapper node; `ebox-column`, `ebox-row`, `ebox-flex`, and `ebox-grid` compose nodes; `ebox-render` returns propertized text; `ebox-render-to-buffer` publishes it into a buffer; and `ebox-commit` updates an existing runtime from a fresh root tree.
## What belongs here
- box creation, padding, margins, borders, colors, faces, overflow, and text measurement;
- row, column, flex, and two-dimensional Grid layout;
- selectors, stable node identity, keyed reconciliation, buffer rendering, and incremental publication;
- optional Rust native reflow with an exact Elisp fallback;
- low-level Ebox DSL files and renderer-focused tests.
ETAF belongs in the sibling package. Install it separately when you need the unified View grammar, Components, reactive state, behaviors, Context, Data Controllers, or application lifecycle. The independent example packages are [ebox-playground](../ebox-playground/README.md) and [etaf-playground](../etaf-playground/README.md).
## Repository map
| Path | Responsibility |
| --- | --- |
| `ebox.el` | Public facade and package entry point. |
| `ebox-style.el`, `ebox-tree.el`, `ebox-measure.el` | Style, tree, and measurement models. |
| `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Formatting contexts and layout algorithms. |
| `ebox-buffer-backend.el`, `ebox-incremental.el` | Buffer publication and incremental updates. |
| `ebox-dsl.el`, `ebox-selector.el` | Standalone DSL and tree/runtime queries. |
| `ebox-native-reflow.el`, `native/` | Optional native reflow boundary. |
| `examples/`, `tests/` | Ebox-only examples and regression tests. |
| `docs/user/`, `docs/maintainer/` | Long-lived English and Chinese documentation. |
## Verification
```sh
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make native-rust-tests
```
Run the smallest focused target first (`make grid-tests`, `make dsl-tests`, or `make visual-check-tests`), then run `make check` for shared rendering or public API changes.
Read the [Ebox user guide](docs/user/ebox-user-guide.en.md) for public construction examples and the [current implementation reference](docs/maintainer/ebox-current-implementation-reference.en.md) for ownership boundaries and invariants.

59
README.zh-CN.md Normal file
View File

@ -0,0 +1,59 @@
# Ebox
Ebox 是一个独立的 Emacs 像素级 Box 与布局引擎。它是同级 [ETAF](../etaf/README.zh-CN.md) 文本应用框架使用的底层渲染基座;也可以脱离 Component、应用状态和 ETAF 单独使用。
## 安装与使用
将本目录放到 `load-path`
```elisp
(add-to-list 'load-path "/path/to/github/ebox")
(require 'ebox)
(ebox-render-to-buffer
"*Ebox Example*"
(ebox-column
(ebox-create :content "Hello Ebox"
:padding '(1 2)
:border '((1) solid "#8A93A6")
:color "#263244"
:bgcolor "#F4F6FB")))
```
公共模型是一棵 Ebox 节点树。`ebox-create` 构造叶子或包装节点;`ebox-column`、`ebox-row`、`ebox-flex` 和 `ebox-grid` 组合节点;`ebox-render` 返回带属性文本;`ebox-render-to-buffer` 将结果发布到 buffer`ebox-commit` 使用新的根树更新已有 runtime。
## 本包负责什么
- box 创建、padding、margin、border、颜色、face、overflow 与文本测量;
- row、column、flex 和二维 Grid 布局;
- selector、稳定节点 identity、key reconciliation、buffer 渲染与增量发布;
- 可选 Rust native reflow以及完全等价的 Elisp fallback
- 底层 Ebox DSL 文件与渲染器测试。
ETAF 属于同级独立包。当你需要统一 View 语法、Component、响应式状态、behavior、Context、Data Controller 或应用生命周期时,单独安装 ETAF。两个独立的示例包是 [ebox-playground](../ebox-playground/README.md) 与 [etaf-playground](../etaf-playground/README.md)。
## 仓库结构
| 路径 | 职责 |
| --- | --- |
| `ebox.el` | 公共门面与包入口。 |
| `ebox-style.el`、`ebox-tree.el`、`ebox-measure.el` | 样式、树和测量模型。 |
| `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Formatting Context 与布局算法。 |
| `ebox-buffer-backend.el`、`ebox-incremental.el` | Buffer 发布与增量更新。 |
| `ebox-dsl.el`、`ebox-selector.el` | 独立 DSL 与树/runtime 查询。 |
| `ebox-native-reflow.el`、`native/` | 可选 native reflow 边界。 |
| `examples/`、`tests/` | 只属于 Ebox 的示例和回归测试。 |
| `docs/user/`、`docs/maintainer/` | 长期维护的中英文文档。 |
## 验证
```sh
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make native-rust-tests
```
小范围修改先运行对应目标(例如 `make grid-tests`、`make dsl-tests` 或 `make visual-check-tests`);共享渲染或公共 API 修改后运行完整的 `make check`
公共构造示例见 [Ebox 用户指南](docs/user/ebox-user-guide.zh.md),职责边界与不变量见 [当前实现参考](docs/maintainer/ebox-current-implementation-reference.zh.md)。

View File

@ -0,0 +1,106 @@
# Ebox current implementation reference
This is the maintainer entry point for the standalone Ebox repository. It describes the package boundary, active files, runtime model, invariants, and verification commands. The historical `emacs-box` checkout is a separate legacy source tree; it is not a dependency of this package. ETAF is the sibling higher-level package.
## Reading order
1. Read `AGENTS.md` for repository rules.
2. Read `README.md` for installation and the public boundary.
3. Read `docs/user/ebox-user-guide.en.md` for public construction patterns.
4. Read this document for ownership and verification.
5. Read `docs/maintainer/ebox-incremental-update-contract.en.md` before changing publication or patch planning.
## Active source map
| File | Owns |
| --- | --- |
| `ebox.el` | Public facade, construction helpers, rendering, buffer entry points, scrolling, commit, and byte compilation. |
| `ebox-cache.el` | Measurement/render cache records, invalidation, and cache reports. |
| `ebox-style.el` | Property registration, aliases, shorthand expansion, computed style, colors, borders, and dirty effects. |
| `ebox-tree.el` | Node traversal, child access, identity, parent paths, keys, and tree snapshots. |
| `ebox-measure.el` | Display-sensitive character/face/pixel measurement and measurement caches. |
| `ebox-fragment.el` | Layout fragments, signatures, snapshots, spans, and dirty-kind facts. |
| `ebox-render-context.el` | Render-local context and publication inputs. |
| `ebox-layout.el` | Box, row, column, stack, concatenation, spacer, wrapping, and common formatting context. |
| `ebox-flex.el` | Flex normalization, lines, free-space distribution, and flex rendering. |
| `ebox-grid.el` | Tracks, implicit tracks, fractions, minmax/repeat, gap, placement, span, and alignment. |
| `ebox-buffer-backend.el` | Text properties, display spaces/borders, markers, extents, replacement, and buffer mutation. |
| `ebox-incremental.el` | Runtime state, snapshots, dirty planning, owner escalation, atomic publication, and reports. |
| `ebox-dsl.el` | Data-oriented `.ebox` forms and lowering to public nodes. |
| `ebox-selector.el` | CSS-like queries over trees and runtime handles. |
| `ebox-native-reflow.el` | Optional native module loading/build commands, ABI checks, bounded sessions, and Elisp fallback. |
The package intentionally does not include application Components, UI controls, reactive data, or a playground implementation. Those are sibling-package responsibilities.
The active contract also covers `Makefile`, `.github/workflows/ci.yml`, `examples/ebox-basic-examples.el`, `examples/playground/basic.ebox`, `examples/playground/comprehensive.ebox`, `examples/playground/flex-reference.ebox`, `examples/playground/responsive-reference.ebox`, `tests/ebox-core-render-tests.el`, `tests/ebox-grid-tests.el`, `tests/ebox-commit-tests.el`, `tests/ebox-examples-tests.el`, `tests/ebox-dsl-tests.el`, `tests/ebox-flex-tests.el`, `tests/ebox-selector-tests.el`, `tests/ebox-package-tests.el`, `tests/ebox-visual-check-tests.el`, `tests/ebox-docs-contract-tests.el`, `tests/ebox-ci-contract-tests.el`, `native/Cargo.toml`, `native/Cargo.lock`, `native/build.rs`, `native/vendor/emacs-30/emacs-module.h`, `native/src/lib.rs`, `native/src/layout.rs`, `native/c/ebox_module.c`, `scripts/ebox-package-lint.el`, and `scripts/ebox-visual-check.el`.
## Runtime model
The normal data flow is:
```text
Source Tree
-> Element Tree
-> Computed Style
-> Box/Formatting Context
-> Measurement + Render Context
-> Layout Fragment/Snapshot
-> Dirty/Patch Plan
-> Emacs Buffer Backend
```
| Model | Owner | Must not own |
| --- | --- | --- |
| Source/Element Tree | `ebox-tree.el`, `ebox-dsl.el` | Published buffer mutation. |
| Computed Style | `ebox-style.el` | Layout identity or patch execution. |
| Measurement | `ebox-measure.el` | Application state or dirty policy. |
| Formatting Context | `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Buffer edits. |
| Fragment/Snapshot | `ebox-fragment.el`, `ebox-incremental.el` | Source parsing or identity allocation. |
| Dirty/Patch | `ebox-incremental.el` | Raw measurement or direct buffer edits. |
| Buffer Backend | `ebox-buffer-backend.el` | Style semantics or application state. |
## Invariants
- Public Ebox nodes are data; `ebox--*` names are private.
- A one-element horizontal list such as `'(420)` denotes pixels; ordinary horizontal numbers denote character columns.
- `ebox-render` does not publish to a buffer; `ebox-render-to-buffer` owns initial publication; `ebox-commit` owns declarative replacement.
- A failed candidate leaves the previous buffer, runtime identity, and report intact.
- Keys are local to siblings; visible strings are never used as identity.
- `owner-rerender` is broader than `span-patch`, which is broader than `paint-patch`.
- Buffer coordinates belong to the generation that produced them and must be refreshed after mutation.
- Grid uses the normal measurement and rendering pipeline. Native reflow may reject an ineligible tree and must fall back to Elisp without changing correctness.
- Loading Ebox never builds or installs the optional Rust module.
## 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. CSS cascade, percentages, absolute positioning, z-index, border radius, shadows, full typography, and browser-level bidi are outside the contract.
## Active examples and tests
The maintained examples are `examples/ebox-basic-examples.el` and the `.ebox` fixtures under `examples/playground/`. The regression boundary is the ERT files under `tests/` listed in the repository's documentation contract. No test in this package should load the historical full-stack source tree or an application framework.
## Verification matrix
```sh
make load
make compile
make check
make core-tests
make grid-tests
make ebox-commit-tests
make visual-check-tests
make package-tests
make selector-tests
make examples-tests
make dsl-tests
make flex-tests
make docs-contract-tests
make ci-contract-tests
make visual-check
make native-rust-tests
make native-build
make package-lint
make diff-check
```
Run focused tests first, then `make check` after changes to shared rendering, Grid, public constructors, or documentation. Native changes require the Rust checks and a native build.

View File

@ -0,0 +1,102 @@
# Ebox 当前实现参考
本文是独立 Ebox 仓库的维护者入口描述包边界、active 文件、运行时模型、不变量和验证命令。历史 `emacs-box` 是另一个旧架构源码树不是本包依赖。ETAF 是同级高层包。
## 阅读顺序
1. 先读 `AGENTS.md` 了解仓库规则。
2. 读 `README.md` 了解安装和公共边界。
3. 读 `docs/user/ebox-user-guide.zh.md` 了解公共构造方式。
4. 读本文了解所有权和验证方式。
5. 修改发布或 patch 规划前,读 `docs/maintainer/ebox-incremental-update-contract.zh.md`
## Active 源码清单
| 文件 | 负责内容 |
| --- | --- |
| `ebox.el` | 公共门面、构造辅助函数、渲染、buffer 入口、滚动、commit 与 byte compile。 |
| `ebox-cache.el` | 测量/渲染缓存记录、失效和缓存报告。 |
| `ebox-style.el` | 属性注册、别名、shorthand 展开、computed style、颜色、border 和 dirty effect。 |
| `ebox-tree.el` | 节点遍历、子节点访问、identity、父路径、key 和树 snapshot。 |
| `ebox-measure.el` | display 敏感的字符、face、像素测量与测量缓存。 |
| `ebox-fragment.el` | 布局 fragment、signature、snapshot、span 和 dirty kind 事实。 |
| `ebox-render-context.el` | render-local context 与发布输入。 |
| `ebox-layout.el` | box、row、column、stack、concat、spacer、换行和通用 formatting context。 |
| `ebox-flex.el` | flex 归一化、line、剩余空间分配和 flex 渲染。 |
| `ebox-grid.el` | 轨道、隐式轨道、分数、minmax/repeat、gap、placement、span 和对齐。 |
| `ebox-buffer-backend.el` | text property、display space/border、marker、extent、替换和 buffer 变更。 |
| `ebox-incremental.el` | runtime、snapshot、dirty 规划、owner 提升、原子发布和报告。 |
| `ebox-dsl.el` | 数据型 `.ebox` form以及向公共节点的 lowering。 |
| `ebox-selector.el` | 对树和 runtime handle 的 CSS-like 查询。 |
| `ebox-native-reflow.el` | 可选 native 模块加载/构建、ABI 校验、受限 session 和 Elisp fallback。 |
本包有意不包含应用 Component、UI control、响应式 data 或 playground 实现;它们属于同级包。历史应用性能记录器和 native reflow 评估器也不属于独立 Ebox 的发布边界Ebox 只保留 native 模块本身、Rust 构建输入和可重复的构建检查。
## 运行时模型
正常数据流是:
```text
Source Tree
-> Element Tree
-> Computed Style
-> Box/Formatting Context
-> Measurement + Render Context
-> Layout Fragment/Snapshot
-> Dirty/Patch Plan
-> Emacs Buffer Backend
```
| 模型 | Owner | 不得拥有 |
| --- | --- | --- |
| Source/Element Tree | `ebox-tree.el`、`ebox-dsl.el` | 已发布 buffer 的变更。 |
| Computed Style | `ebox-style.el` | 布局 identity 或 patch 执行。 |
| Measurement | `ebox-measure.el` | 应用状态或 dirty 策略。 |
| Formatting Context | `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Buffer 编辑。 |
| Fragment/Snapshot | `ebox-fragment.el`、`ebox-incremental.el` | Source parsing 或 identity 分配。 |
| Dirty/Patch | `ebox-incremental.el` | 原始测量或直接 buffer 编辑。 |
| Buffer Backend | `ebox-buffer-backend.el` | 样式语义或应用状态。 |
## 不变量
- 公共 Ebox 节点是数据;`ebox--*` 名称是私有实现。
- `'(420)` 这样的单元素横向 list 表示像素;普通横向数字表示字符列。
- `ebox-render` 不发布到 buffer`ebox-render-to-buffer` 负责首次发布;`ebox-commit` 负责声明式替换。
- 候选失败时必须保留之前的 buffer、runtime identity 和报告。
- Key 只在兄弟节点中有效;不能用可见字符串作为 identity。
- `owner-rerender` 范围大于 `span-patch``span-patch` 大于 `paint-patch`
- Buffer 坐标属于生成它的 generation变更后必须重新获取。
- Grid 使用普通测量与渲染流水线native reflow 可以拒绝不适合的树并回退到 Elisp正确性不变。
- 加载 Ebox 不会构建或安装可选 Rust 模块。
## Grid 合同
当前 Grid 支持固定和像素轨道、`auto`、分数轨道、`minmax`、`repeat`、隐式行列、行列 gap、auto-flow、从 1 开始的 placement、正整数 span、item/content 对齐,以及普通 buffer 渲染和更新。CSS cascade、百分比、绝对定位、z-index、圆角、阴影、完整 typography 和浏览器级 bidi 不在合同内。
## Active 示例与测试
维护中的示例是 `examples/ebox-basic-examples.el``examples/playground/` 下的 `.ebox` fixture。回归边界是 `tests/` 下由文档合同列出的 ERT 文件。本包测试不得加载历史全栈源码树或应用框架。
## 验证矩阵
```sh
make check
make core-tests
make grid-tests
make ebox-commit-tests
make visual-check-tests
make package-tests
make selector-tests
make examples-tests
make dsl-tests
make flex-tests
make docs-contract-tests
make ci-contract-tests
make visual-check
make native-rust-tests
make native-build
make package-lint
make diff-check
```
先运行聚焦测试修改共享渲染、Grid、公共构造器或文档后运行 `make check`。修改 native 后必须运行 Rust 检查与 native 构建。

View File

@ -0,0 +1,48 @@
# Ebox incremental update contract
This document defines the low-level publication contract. It is independent of ETAF application state and must remain true for direct Ebox callers and higher-level packages alike.
## Ownership
1. The caller owns the fresh source tree.
2. `ebox-tree.el` owns identity, keys, traversal, and snapshots.
3. `ebox-style.el`, `ebox-measure.el`, and the layout modules own normalization, measurement, and geometry.
4. `ebox-incremental.el` owns dirty classification, patch planning, candidate publication, and update reports.
5. `ebox-buffer-backend.el` is the only owner of text-property and buffer mutation.
No layer may infer application state from visible buffer text. No public caller may mutate a published tree in place.
## Commit lifecycle
```text
fresh root
-> validate and normalize
-> reconcile keys and identity
-> measure/layout candidate
-> classify dirty owners
-> render candidate spans
-> publish atomically
-> refresh snapshots and report
```
The candidate is either fully published or discarded. A failed candidate preserves the previous buffer text, properties, runtime identity, scroll state, and last successful report.
## Patch order
The planner prefers `paint-patch`, then `span-patch`, then `owner-rerender`, and finally `root-rerender` when geometry or identity makes a smaller operation unsafe. A patch must not silently widen its scope; the report records the selected strategy, dirty keys, owners, and patch operations.
## Identity and coordinates
Keys are local to a sibling collection. Region and host-ref positions are generation-bound; after a buffer mutation, callers must obtain fresh positions from the public accessor. Buffer markers, display spans, and text properties are backend facts, not source-tree identity.
## Verification
Every update path must prove:
- exact visible text and properties after publication;
- stable keyed identity when siblings reorder;
- no stale candidate data after rollback;
- bounded patch scope when a local update is possible;
- correct fallback behavior when native reflow is unavailable.
Use `tests/ebox-commit-tests.el`, `tests/ebox-core-render-tests.el`, and `tests/ebox-grid-tests.el` for focused coverage, then run `make check`.

View File

@ -0,0 +1,48 @@
# Ebox 增量更新合同
本文定义底层发布合同。它独立于 ETAF 应用状态,直接使用 Ebox 的调用者和上层包都必须遵守。
## 所有权
1. 调用者拥有新的 source tree。
2. `ebox-tree.el` 拥有 identity、key、遍历和 snapshot。
3. `ebox-style.el`、`ebox-measure.el` 与布局模块拥有归一化、测量和几何。
4. `ebox-incremental.el` 拥有 dirty 分类、patch 规划、候选发布和更新报告。
5. `ebox-buffer-backend.el` 是 text property 与 buffer 变更的唯一 owner。
任何层都不能从可见 buffer 文本推断应用状态;公共调用者不能原地修改已经发布的树。
## Commit 生命周期
```text
新的根树
-> 校验与归一化
-> reconciliation key 与 identity
-> 测量/布局候选
-> 分类 dirty owner
-> 渲染候选 span
-> 原子发布
-> 刷新 snapshot 与报告
```
候选要么完整发布,要么丢弃。候选失败时必须保留之前的 buffer 文本、属性、runtime identity、滚动状态和最近一次成功报告。
## Patch 顺序
规划器优先使用 `paint-patch`,再使用 `span-patch`、`owner-rerender`,最后在几何或 identity 使小操作不安全时使用 `root-rerender`。Patch 不得静默扩大范围报告要记录策略、dirty key、owner 和 patch 操作。
## Identity 与坐标
Key 只在同级兄弟集合内有效。Region 与 host-ref 位置绑定于 generationbuffer 变更后调用者必须通过公共 accessor 重新获得位置。Buffer marker、display span 和 text property 是 backend 事实,不是 source tree identity。
## 验证
每条更新路径都要验证:
- 发布后的可见文本和属性完全正确;
- 兄弟节点重排时 keyed identity 稳定;
- rollback 后没有候选数据残留;
- 可以局部更新时 patch 范围受控;
- native reflow 不可用时 fallback 正确。
聚焦测试使用 `tests/ebox-commit-tests.el`、`tests/ebox-core-render-tests.el` 和 `tests/ebox-grid-tests.el`,然后运行 `make check`

View File

@ -0,0 +1,140 @@
# Ebox user guide
Ebox is the low-level box, layout, and buffer-rendering package. Use it directly when an application needs precise geometry or use it as the rendering substrate below ETAF. This guide intentionally does not introduce Components, reactive state, behaviors, or application data.
## 1. Load the package
```elisp
(add-to-list 'load-path "/path/to/github/ebox")
(require 'ebox)
```
Loading defines the public package and its pure layout modules. It does not create a buffer, install a mode, build Rust code, or change the current editing buffer.
## 2. Build a node tree
`ebox-create` makes a node. Container helpers accept child nodes and return another node:
```elisp
(ebox-column
(ebox-create :content "Title"
:face 'bold
:color "#263244"
:bgcolor "#F4F6FB"
:padding '(1 2))
(ebox-row
(ebox-create :content "Left" :width 12)
(ebox-create :content "Right" :width 12)))
```
The public shape is data, not rendered text. A node may contain `:content`, `:ebox-content-node`, or child nodes supplied to a container. Use `:key` when siblings have stable application identity and `:host-ref` when an application needs a public handle position after rendering.
## 3. Dimensions and surface properties
Ordinary horizontal numbers are character columns. A one-element list denotes a pixel width; vertical numbers are line counts. Padding and margins accept scalar or CSS-like 14-value forms. Borders are width, style, and color:
```elisp
(ebox-create
:content "A readable panel"
:width '(420)
:padding '(1 2)
:margin '(0 1)
:border '((1) solid "#8A93A6")
:color "#263244"
:bgcolor "#FFFFFF")
```
Keep foreground and background explicit on tinted surfaces. `:face` may be a face symbol or a face plist; use `:color` and `:bgcolor` when the surface itself carries semantic colors.
## 4. Row, column, flex, and Grid
Use row and column for simple one-dimensional composition. Use flex when free space is distributed among items. Use Grid when two-dimensional tracks or stable placement matter:
```elisp
(ebox-grid
:width '(640)
:grid-template-columns '((200) 1fr 1fr)
:grid-template-rows '(1 1)
:gap '(1 (12))
:padding '(1 2)
:border '((1) solid "#8A93A6")
(ebox-create :content "Header" :grid-column 1 :grid-column-span 3)
(ebox-create :content "Navigation" :grid-column 1 :grid-row 2)
(ebox-create :content "Main" :grid-column 2 :grid-row 2)
(ebox-create :content "Aside" :grid-column 3 :grid-row 2))
```
Grid tracks can be fixed, fractional, `auto`, `minmax`, or repeated. Explicit placement is one-based. Use positive integer spans and let implicit tracks fill omitted positions.
## 5. Render text or a buffer
`ebox-render` is pure with respect to buffers and returns propertized text. `ebox-render-to-buffer` owns the initial buffer publication:
```elisp
(let ((node (ebox-column
(ebox-create :content "Ready" :width '(240))
(ebox-create :content "Rendered by Ebox"))))
(ebox-render node)
(ebox-render-to-buffer "*Ebox Demo*" node))
```
The returned text carries display, face, region, and identity properties needed by Ebox. Do not edit those properties by hand.
## 6. Update an existing buffer
Build a fresh root tree and commit it to the existing buffer:
```elisp
(ebox-commit
"*Ebox Demo*"
(ebox-column
(ebox-create :content "Updated" :key 'title :width '(240))))
```
Ebox compares stable keys and region identity, chooses the smallest safe patch, and records a report:
```elisp
(ebox-buffer-update-report "*Ebox Demo*")
```
If a candidate cannot be proven safe, Ebox escalates to an owner or root rerender. A failed commit must leave the previously published tree and buffer intact.
## 7. Selectors and handles
Selectors query the rendered tree and return public handles. They do not edit the buffer or own application state. Prefer explicit keys and refs for application identity; use selectors for inspection and bounded updates.
## 8. Standalone `.ebox` files
`ebox-build` reads one data-oriented Ebox form:
```elisp
(ebox-build
'(grid :width (640)
:grid-template-columns ((200) 1fr 1fr)
:gap (1 (12))
(box :content "A")
(box :content "B")
(box :content "C")))
```
The files under `examples/playground/` are executable fixtures for this syntax. They remain low-level layout examples and do not require the ETAF framework.
## 9. Optional native reflow
The Rust module accelerates eligible reflow work; it is not required for correctness. Load Ebox normally, run `make native-build` when you want a local module, and configure `ebox-native-reflow-module-path` if the module is outside its default location. Ebox keeps the Elisp path as the exact fallback and never builds native code while loading.
## 10. Public boundary
Use public `ebox-*` functions and constructors. Names beginning with `ebox--` are private implementation details and may change. ETAF is the sibling package for Components, View trees, state, behavior, Context, data, and application lifecycle; Ebox should stay focused on geometry and publication.
## 11. Verification
From the repository root:
```sh
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
```
Use `make grid-tests`, `make dsl-tests`, or `make visual-check-tests` for focused changes. Use `make native-rust-tests` after changing the native module.

View File

@ -0,0 +1,140 @@
# Ebox 用户指南
Ebox 是底层的 box、布局和 buffer 渲染包。当应用需要精确几何时可以直接使用,也可以作为 ETAF 下方的渲染基座。本指南不引入 Component、响应式状态、behavior 或应用数据。
## 1. 加载包
```elisp
(add-to-list 'load-path "/path/to/github/ebox")
(require 'ebox)
```
加载只定义公共包和纯布局模块,不会创建 buffer、安装 mode、构建 Rust 或修改当前编辑 buffer。
## 2. 构造节点树
`ebox-create` 构造节点;容器辅助函数接收子节点并返回新的节点:
```elisp
(ebox-column
(ebox-create :content "Title"
:face 'bold
:color "#263244"
:bgcolor "#F4F6FB"
:padding '(1 2))
(ebox-row
(ebox-create :content "Left" :width 12)
(ebox-create :content "Right" :width 12)))
```
公共形状是数据,而不是已渲染文本。节点可以有 `:content`、`:ebox-content-node`,或由容器接收的子节点。兄弟节点具有稳定业务 identity 时使用 `:key`;应用需要在渲染后查询公开位置时使用 `:host-ref`
## 3. 尺寸与表面属性
普通横向数字表示字符列;单元素 list 表示像素宽度纵向数字表示行数。padding 和 margin 接受标量或 CSS 风格的 14 值。border 由宽度、样式和颜色组成:
```elisp
(ebox-create
:content "A readable panel"
:width '(420)
:padding '(1 2)
:margin '(0 1)
:border '((1) solid "#8A93A6")
:color "#263244"
:bgcolor "#FFFFFF")
```
带色背景的 surface 要显式设置前景色与背景色。`:face` 可以是 face symbol 或 face plist当 surface 本身承载语义颜色时,优先使用 `:color``:bgcolor`
## 4. row、column、flex 与 Grid
简单的一维组合使用 row 和 column需要分配剩余空间时使用 flex需要二维轨道或稳定放置时使用 Grid
```elisp
(ebox-grid
:width '(640)
:grid-template-columns '((200) 1fr 1fr)
:grid-template-rows '(1 1)
:gap '(1 (12))
:padding '(1 2)
:border '((1) solid "#8A93A6")
(ebox-create :content "Header" :grid-column 1 :grid-column-span 3)
(ebox-create :content "Navigation" :grid-column 1 :grid-row 2)
(ebox-create :content "Main" :grid-column 2 :grid-row 2)
(ebox-create :content "Aside" :grid-column 3 :grid-row 2))
```
Grid 轨道支持固定值、分数、`auto`、`minmax` 和重复轨道。显式放置从 1 开始span 使用正整数;省略的位置由隐式轨道补齐。
## 5. 渲染文本或 buffer
`ebox-render` 相对于 buffer 是纯函数,返回带属性文本;`ebox-render-to-buffer` 负责首次发布:
```elisp
(let ((node (ebox-column
(ebox-create :content "Ready" :width '(240))
(ebox-create :content "Rendered by Ebox"))))
(ebox-render node)
(ebox-render-to-buffer "*Ebox Demo*" node))
```
返回文本带有 Ebox 所需的 display、face、region 和 identity 属性,不要手动修改这些属性。
## 6. 更新已有 buffer
构造新的根树并提交到已有 buffer
```elisp
(ebox-commit
"*Ebox Demo*"
(ebox-column
(ebox-create :content "Updated" :key 'title :width '(240))))
```
Ebox 会比较稳定 key 和 region identity选择最小且安全的 patch并记录报告
```elisp
(ebox-buffer-update-report "*Ebox Demo*")
```
无法证明安全时会提升到 owner 或 root rerender候选提交失败必须保留之前已发布的树和 buffer。
## 7. Selector 与 handle
Selector 查询已渲染的树并返回公共 handle它不编辑 buffer也不拥有应用状态。应用 identity 优先使用显式 key 和 refselector 适合检查和有限更新。
## 8. 独立 `.ebox` 文件
`ebox-build` 读取一个面向数据的 Ebox form
```elisp
(ebox-build
'(grid :width (640)
:grid-template-columns ((200) 1fr 1fr)
:gap (1 (12))
(box :content "A")
(box :content "B")
(box :content "C")))
```
`examples/playground/` 下的文件是该语法的可执行 fixture。它们只演示低层布局不依赖 ETAF。
## 9. 可选 native reflow
Rust 模块用于加速符合条件的 reflow但不是正确性的前提。正常加载 Ebox需要本地模块时运行 `make native-build`,若模块不在默认位置则配置 `ebox-native-reflow-module-path`。Ebox 保留完全等价的 Elisp 路径,并且加载时不会自动构建 native。
## 10. 公共边界
使用公共的 `ebox-*` 函数与构造器。以 `ebox--` 开头的名称是私有实现细节可能随时变化。ETAF 是同级包,负责 Component、View 树、状态、behavior、Context、data 和应用生命周期Ebox 应保持对几何与发布的专注。
## 11. 验证
在仓库根目录运行:
```sh
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
```
局部修改可先运行 `make grid-tests`、`make dsl-tests` 或 `make visual-check-tests`;修改 native 模块后运行 `make native-rust-tests`

5033
ebox-buffer-backend.el Normal file

File diff suppressed because it is too large Load Diff

216
ebox-cache.el Normal file
View File

@ -0,0 +1,216 @@
;;; ebox-cache.el --- Cache ownership metadata for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns shared cache contracts, cache invalidation metadata, and cache
;; reporting helpers. It does not own rendering, layout, or buffer mutation.
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(cl-defstruct (ebox-cache-spec
(:constructor ebox-cache-spec-create))
name owner scope key-deps invalidates lifetime value-owner)
(defvar ebox-cache--registry (make-hash-table :test 'eq)
"Registry of cache specs keyed by cache name.")
(defvar ebox-cache--buffer-report-table (make-hash-table :test 'eq)
"Buffer-keyed cache report facts for the current incremental operation.")
(defvar ebox-cache-report-buffer nil
"Dynamically bound buffer whose cache report should be appended to reports.")
(defconst ebox-cache--default-specs
'((:name measurement
:owner ebox-measure
:scope display
:key-deps (string face text-scale display-signature)
:invalidates (font frame text-scale display-signature)
:lifetime global-or-display
:value-owner ebox-measure)
(:name computed-style
:owner ebox-style
:scope node
:key-deps (specified-style-signature class-version theme-version)
:invalidates (style class theme)
:lifetime buffer-runtime
:value-owner ebox-style)
(:name viewport-dependency
:owner ebox-incremental
:scope buffer-runtime
:key-deps (root-id computed-style-tree-version viewport-width)
:invalidates (width display children style viewport)
:lifetime buffer-runtime
:value-owner ebox-incremental)
(:name layout-fragment
:owner ebox-layout
:scope formatting-context
:key-deps (node-id computed-style-signature child-signatures available-size)
:invalidates (geometry structure viewport)
:lifetime buffer-runtime
:value-owner ebox-layout)
(:name render-body
:owner ebox-buffer-backend
:scope buffer-render
:key-deps (fragment-signature paint-signature backend-display-signature)
:invalidates (paint geometry structure backend-display)
:lifetime buffer-runtime
:value-owner ebox-buffer-backend)
(:name snapshot-signature
:owner ebox-fragment
:scope buffer-runtime
:key-deps (node-id fragment-version style-signature)
:invalidates (style geometry placement structure)
:lifetime buffer-runtime
:value-owner ebox-fragment))
"Default cache contracts for existing Ebox engine caches.")
(defun ebox-cache-clear-registry ()
"Clear the cache spec registry."
(clrhash ebox-cache--registry))
(cl-defun ebox-cache-register
(&key name owner scope key-deps invalidates lifetime value-owner)
"Register and return a cache spec."
(unless name
(error "ebox-cache: cache spec requires :name"))
(let ((spec (ebox-cache-spec-create
:name name
:owner owner
:scope scope
:key-deps key-deps
:invalidates invalidates
:lifetime lifetime
:value-owner value-owner)))
(puthash name spec ebox-cache--registry)
spec))
(defun ebox-cache-spec (name)
"Return registered cache spec NAME."
(gethash name ebox-cache--registry))
(defun ebox-cache-register-default-specs ()
"Register default cache specs and return the registry."
(dolist (spec ebox-cache--default-specs)
(apply #'ebox-cache-register spec))
ebox-cache--registry)
(defun ebox-cache-reset-default-registry ()
"Reset the registry to the default engine cache specs."
(ebox-cache-clear-registry)
(ebox-cache-register-default-specs))
(defun ebox-cache--intersects-p (left right)
"Return non-nil when LEFT and RIGHT share a symbol."
(cl-intersection left right :test #'eq))
(defun ebox-cache-invalidated-spec-names (dirty-kinds)
"Return cache spec names whose invalidation rules intersect DIRTY-KINDS."
(let (names)
(maphash
(lambda (name spec)
(when (ebox-cache--intersects-p
dirty-kinds (ebox-cache-spec-invalidates spec))
(push name names)))
ebox-cache--registry)
names))
(defun ebox-cache--empty-report ()
"Return a new empty cache report plist."
(list :cache-invalidated nil
:cache-hit-count 0
:cache-miss-count 0
:cache-scope nil
:cache-fallback-reason nil))
(defun ebox-cache-clear-report (buffer)
"Clear cache report facts for BUFFER."
(when buffer
(ebox-cache-publish-report buffer (ebox-cache--empty-report))))
(defun ebox-cache-publish-report (buffer report)
"Atomically replace BUFFER's cache REPORT facts."
(when buffer
(puthash buffer (copy-sequence report)
ebox-cache--buffer-report-table)))
(defun ebox-cache-snapshot-report (buffer)
"Return an exact restorable snapshot of BUFFER's cache report entry."
(let* ((missing (make-symbol "missing"))
(report (and buffer
(gethash buffer ebox-cache--buffer-report-table missing))))
(list :present-p (and buffer (not (eq report missing)))
:report (unless (eq report missing) (copy-sequence report)))))
(defun ebox-cache-restore-report (buffer snapshot)
"Restore BUFFER's cache report entry from SNAPSHOT."
(when buffer
(if (plist-get snapshot :present-p)
(puthash buffer (copy-sequence (plist-get snapshot :report))
ebox-cache--buffer-report-table)
(remhash buffer ebox-cache--buffer-report-table))))
(defun ebox-cache-discard-report (buffer)
"Discard cache report facts owned by BUFFER."
(when buffer
(remhash buffer ebox-cache--buffer-report-table)))
(defun ebox-cache--report-state (buffer)
"Return mutable cache report state for BUFFER."
(when buffer
(or (gethash buffer ebox-cache--buffer-report-table)
(let ((state (ebox-cache--empty-report)))
(puthash buffer state ebox-cache--buffer-report-table)
state))))
(defun ebox-cache--push-unique-values (state key values)
"Append VALUES uniquely to STATE at KEY."
(dolist (value values)
(when value
(cl-pushnew value (plist-get state key) :test #'equal))))
(defun ebox-cache--spec-scopes (cache-names)
"Return distinct scopes for CACHE-NAMES."
(let (scopes)
(dolist (name cache-names)
(when-let ((spec (ebox-cache-spec name)))
(cl-pushnew (ebox-cache-spec-scope spec) scopes :test #'equal)))
scopes))
(defun ebox-cache-record-hit (buffer cache-name)
"Record a CACHE-NAME hit for BUFFER's current update report."
(when-let ((state (ebox-cache--report-state buffer)))
(plist-put state :cache-hit-count
(1+ (or (plist-get state :cache-hit-count) 0)))
(ebox-cache--push-unique-values
state :cache-scope (ebox-cache--spec-scopes (list cache-name)))))
(defun ebox-cache-record-miss (buffer cache-name)
"Record a CACHE-NAME miss for BUFFER's current update report."
(when-let ((state (ebox-cache--report-state buffer)))
(plist-put state :cache-miss-count
(1+ (or (plist-get state :cache-miss-count) 0)))
(ebox-cache--push-unique-values
state :cache-scope (ebox-cache--spec-scopes (list cache-name)))))
(defun ebox-cache-record-invalidation (buffer cache-names reason)
"Record invalidated CACHE-NAMES and REASON for BUFFER."
(when-let ((state (ebox-cache--report-state buffer)))
(ebox-cache--push-unique-values state :cache-invalidated cache-names)
(ebox-cache--push-unique-values
state :cache-scope (ebox-cache--spec-scopes cache-names))
(plist-put state :cache-fallback-reason reason)))
(defun ebox-cache-report (buffer)
"Return cache report plist for BUFFER."
(copy-sequence
(or (gethash buffer ebox-cache--buffer-report-table)
(ebox-cache--empty-report))))
(ebox-cache-register-default-specs)
(provide 'ebox-cache)
;;; ebox-cache.el ends here

192
ebox-dsl.el Normal file
View File

@ -0,0 +1,192 @@
;;; ebox-dsl.el --- DSL compiler facade for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns `.ebox` DSL compilation and compatibility sugar over the Ebox element
;; and style models. It does not own core render or buffer patch execution.
;;; Code:
(require 'cl-lib)
(require 'ebox-style)
(require 'ebox-tree)
(require 'ebox-flex)
(require 'ebox-grid)
(defun ebox--build-split-attrs (items)
"Split DSL ITEMS into (PROPS . CHILDREN)."
(let (props children)
(while items
(let ((item (pop items)))
(if (keywordp item)
(if items
(let ((value (pop items)))
(push item props)
(push value props))
(error "ebox-build: missing value for %S" item))
(push item children))))
(cons (nreverse props) (nreverse children))))
(defun ebox--plist-keep-keys (plist keys)
"Return a copy of PLIST containing only entries whose key is in KEYS."
(let (result)
(while plist
(let ((key (pop plist))
(value (pop plist)))
(when (memq key keys)
(setq result (append result (list key value))))))
result))
(defun ebox--plist-remove-keys (plist keys)
"Return a copy of PLIST without entries whose key is in KEYS."
(let (result)
(while plist
(let ((key (pop plist))
(value (pop plist)))
(unless (memq key keys)
(setq result (append result (list key value))))))
result))
(defun ebox--build-children-layout (children)
"Build a vertical default layout from CHILDREN."
(setq children (delq nil children))
(cond
((null children) nil)
((null (cdr children)) (ebox-dsl-build (car children)))
(t (apply #'ebox-column (mapcar #'ebox-dsl-build children)))))
(defun ebox--build-box-content (children &optional _props)
"Return content or child layout represented by DSL CHILDREN.
The first value is string content. The second value is a lazy child node."
(setq children (delq nil children))
(cond
((null children)
(cl-values nil nil))
((cl-every #'stringp children)
(cl-values (string-join children "\n") nil))
(t
(cl-values nil (ebox--build-children-layout children)))))
(defun ebox--build-box (items)
"Build a box node from DSL ITEMS."
(let* ((split (ebox--build-split-attrs items))
(props (car split))
(children (cdr split)))
(cl-multiple-value-bind (content child-node)
(ebox--build-box-content children props)
(when (and (or content child-node) (plist-member props :content))
(error "ebox-build: box cannot combine :content with child nodes"))
(apply #'ebox-create
(append (if child-node
(ebox--preformatted-box-props props)
props)
(when content (list :content content))
(when child-node
(list :ebox-content-node child-node)))))))
(defun ebox--build-wrap-layout (tag props layout)
"Wrap LAYOUT with box PROPS for DSL TAG, or return LAYOUT unchanged."
(if (null props)
layout
(when (plist-member props :content)
(error "ebox-build: %S cannot combine :content with child nodes" tag))
(apply #'ebox-create
(append (ebox--preformatted-box-props props)
(list :display '(block flow)
:ebox-content-node layout)))))
(defun ebox--build-layout (tag constructor items)
"Build a row/column layout TAG with CONSTRUCTOR from DSL ITEMS."
(let* ((split (ebox--build-split-attrs items))
(props (car split))
(children (cdr split))
(layout (apply constructor (mapcar #'ebox-dsl-build (delq nil children)))))
(ebox--build-wrap-layout tag props layout)))
(defun ebox--build-flex (items)
"Build a flex node from DSL ITEMS."
(let* ((split (ebox--build-split-attrs items))
(props (car split))
(children (mapcar #'ebox-dsl-build (delq nil (cdr split)))))
(apply #'ebox-flex (append props children))))
(defun ebox--build-flex-item (items)
"Build a flex item wrapper from DSL ITEMS."
(let* ((split (ebox--build-split-attrs items))
(props (car split))
(children (cdr split))
(item-props (ebox--plist-keep-keys props ebox--flex-item-prop-keys))
(box-props (ebox--plist-remove-keys props ebox--flex-item-prop-keys)))
(unless children
(error "ebox-build: item requires a child node"))
(let ((child (ebox--flex-wrap-node-with-box-props
'item (ebox--build-children-layout children) box-props)))
(dolist (key ebox--flex-item-prop-keys)
(when (plist-member item-props key)
(plist-put child key (plist-get item-props key))))
(apply #'ebox-flex-item child item-props))))
(defun ebox--build-grid-item (items)
"Build a grid-item node from DSL ITEMS."
(let* ((split (ebox-grid--split-attrs items))
(props (car split))
(children (delq nil (cdr split))))
(unless (= (length children) 1)
(error "ebox-build: grid-item requires exactly one child node"))
(apply #'ebox-grid-item
(ebox-dsl-build (car children))
props)))
(defun ebox--build-spacer (items)
"Build a spacer node from DSL ITEMS."
(let* ((split (ebox--build-split-attrs items))
(props (car split))
(children (cdr split)))
(when children
(error "ebox-build: spacer cannot have child nodes"))
(when (plist-member props :content)
(error "ebox-build: spacer cannot set :content"))
(apply #'ebox-spacer props)))
(defun ebox-dsl-build (dsl)
"Build an Ebox node from an ETML-style list DSL.
Supported forms:
(box :content \"Hello\" :width (160))
(box :content \"Fits preview\" :width (viewport))
(box :width (240) (row (box \"A\") (box \"B\")))
(row (box :content \"Left\") (spacer :width (16)) (box :content \"Right\"))
(column (box :content \"Top\") (box :content \"Bottom\"))
(grid :grid-template-columns ((80) (80))
(grid-item :grid-column (1 :span 2) (box \"Header\")))
(flex :width (320) :gap (1 (12)) (item :flex 1 (box \"A\")))
`box' and `ebox' create `ebox-create' nodes. `row', `column',
`flex', and `spacer' compile to existing Ebox layout helpers. `row'
and `column' may also receive box properties; those properties wrap
the child layout in a box using the rendered child layout as content."
(cond
((ebox--node-p dsl) dsl)
((stringp dsl) (ebox-create :content dsl))
((not (and (consp dsl) (symbolp (car dsl))))
(error "ebox-build: invalid DSL node %S" dsl))
(t
(let ((tag (car dsl))
(items (cdr dsl)))
(pcase tag
((or 'box 'ebox) (ebox--build-box items))
('row (ebox--build-layout tag #'ebox-row items))
('column (ebox--build-layout tag #'ebox-column items))
('flex (ebox--build-flex items))
('item (ebox--build-flex-item items))
('grid-item (ebox--build-grid-item items))
('spacer (ebox--build-spacer items))
('grid
(let* ((split (ebox-grid--split-attrs items))
(props (car split))
(children (mapcar #'ebox-dsl-build (delq nil (cdr split)))))
(apply #'ebox-grid (append props children))))
(_ (error "ebox-build: unknown DSL tag %S" tag)))))))
(provide 'ebox-dsl)
;;; ebox-dsl.el ends here

2334
ebox-flex.el Normal file

File diff suppressed because it is too large Load Diff

203
ebox-fragment.el Normal file
View File

@ -0,0 +1,203 @@
;;; ebox-fragment.el --- Layout fragments and snapshots for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns derived layout fragment and snapshot data used for diffing and
;; patchability checks. It does not own source identity or buffer edits.
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(require 'ebox-tree)
(require 'ebox-measure)
(cl-defstruct (ebox-fragment
(:constructor ebox-fragment-create))
node-id type display region-ids child-ids style-signature size
line-signature span-footprint-signature external-footprint-signature
parent-slot-signature role-topology-signature overflow-signature
buffer-span buffer-spans)
(defun ebox-fragment-layout-signature (fragment)
"Return layout-significant fields from FRAGMENT.
Backend marker and buffer span data are intentionally excluded."
(let (signature)
(dolist (field '(:node-id :type :display :region-ids :child-ids
:style-signature :size :line-signature))
(let* ((name (intern (substring (symbol-name field) 1)))
(accessor (intern (format "ebox-fragment-%s" name)))
(value (and (fboundp accessor) (funcall accessor fragment))))
(when value
(setq signature (plist-put signature field value)))))
signature))
(defconst ebox--paint-style-signature-keys
'(:color :bgcolor
:border-top-p :border-bottom-p
:border-left-color :border-right-color
:border-top-color :border-bottom-color)
"Box properties that affect paint without changing layout geometry.")
(defconst ebox--geometry-style-signature-keys
'(:box-sizing
:width :min-width :max-width
:height :min-height :max-height
:padding-left-pixel :padding-right-pixel
:padding-top-height :padding-bottom-height
:margin-left-pixel :margin-right-pixel
:margin-top-height :margin-bottom-height
:border-left-pixel :border-right-pixel
:text-align :vertical-align
:overflow :wrap-mode
:flex-props :flex-participation)
"Style signature keys that require layout or owner rerender work.")
(defconst ebox--structure-style-signature-keys
'(:display)
"Style signature keys that change tree or formatting-context structure.")
(defconst ebox--style-signature-keys
(append ebox--paint-style-signature-keys
ebox--geometry-style-signature-keys)
"Box properties included in layout snapshot style signatures.")
(defun ebox-fragment-style-source-node (node)
"Return the box-like node carrying visual style for NODE."
(pcase (and (listp node) (plist-get node :ebox-type))
('box node)
('flex (plist-get node :box))
('grid (plist-get node :box))
(_ nil)))
(defun ebox-fragment-node-style-signature (node)
"Return NODE's display, layout, flex, and paint style signature."
(when (listp node)
(let ((signature (list :display (ebox--computed-display node))))
(when-let ((style-node (ebox-fragment-style-source-node node)))
(dolist (key ebox--style-signature-keys)
(when (plist-member style-node key)
(setq signature
(plist-put signature key (plist-get style-node key)))))
(when-let ((participation (ebox--flex-participation-props style-node)))
(setq signature
(plist-put signature :flex-participation participation))))
(when (eq (ebox--display-inner node) 'flex)
(setq signature
(plist-put signature :flex-props
(plist-get node :props))))
(when-let ((participation (ebox--flex-participation-props node)))
(setq signature
(plist-put signature :flex-participation participation)))
signature)))
(defun ebox-fragment-plist-keys (plist)
"Return the keyword keys present in PLIST."
(let (keys)
(while plist
(push (pop plist) keys)
(pop plist))
(nreverse keys)))
(defun ebox-fragment-plist-changed-keys (old new)
"Return keys whose plist values differ between OLD and NEW."
(let (changed keys)
(dolist (key (append (ebox-fragment-plist-keys old)
(ebox-fragment-plist-keys new)))
(cl-pushnew key keys))
(dolist (key keys)
(unless (equal (plist-get old key)
(plist-get new key))
(push key changed)))
changed))
(defun ebox-fragment-style-signature-dirty-kind (old new)
"Return dirty kind implied by OLD and NEW style signatures."
(let ((changed (ebox-fragment-plist-changed-keys old new)))
(cond
((null changed) nil)
((cl-some (lambda (key)
(memq key ebox--structure-style-signature-keys))
changed)
'structure)
((cl-some (lambda (key)
(memq key ebox--geometry-style-signature-keys))
changed)
'geometry)
(t 'paint))))
(defun ebox-fragment-layout-snapshot-detailed-p (snapshot)
"Return non-nil when SNAPSHOT already has expensive detail fields."
(and (plist-member snapshot :buffer-spans)
(plist-member snapshot :line-signature)
(plist-member snapshot :span-footprint-signature)
(plist-member snapshot :external-footprint-signature)
(plist-member snapshot :parent-slot-signature)
(plist-member snapshot :role-topology-signature)
(plist-member snapshot :overflow-signature)))
(defun ebox-fragment-layout-snapshot-spans-p (snapshot)
"Return non-nil when SNAPSHOT already has buffer span details."
(plist-member snapshot :buffer-spans))
(defun ebox-fragment-node-layout-snapshot (buffer node &optional details)
"Return a derived layout snapshot for NODE in BUFFER.
The default snapshot is intentionally lightweight; when DETAILS is non-nil,
include buffer spans and line signatures."
(let* ((node-id (ebox--ensure-node-id node))
(region-ids (ebox--node-all-region-ids node))
(snapshot
(list :node-id node-id
:type (plist-get node :ebox-type)
:display (ebox--computed-display node)
:region-ids region-ids
:style-signature (ebox--node-style-signature node)
:child-ids
(mapcar #'ebox--ensure-node-id
(ebox--node-children node)))))
(if details
(ebox--complete-layout-snapshot buffer node snapshot)
snapshot)))
(defun ebox-fragment-layout-snapshot-dirty-kind (old new)
"Return the dirty kind between OLD and NEW layout snapshots."
(cond
((or (null old) (null new)) 'structure)
((not (eq (plist-get old :type)
(plist-get new :type)))
'structure)
((not (equal (plist-get old :display)
(plist-get new :display)))
'structure)
((not (equal (plist-get old :child-ids)
(plist-get new :child-ids)))
'structure)
((not (equal (plist-get old :line-signature)
(plist-get new :line-signature)))
'geometry)
((not (equal (plist-get old :buffer-spans)
(plist-get new :buffer-spans)))
'placement)
((not (equal (plist-get old :style-signature)
(plist-get new :style-signature)))
(ebox-fragment-style-signature-dirty-kind
(plist-get old :style-signature)
(plist-get new :style-signature)))))
(defalias 'ebox--style-source-node #'ebox-fragment-style-source-node)
(defalias 'ebox--node-style-signature #'ebox-fragment-node-style-signature)
(defalias 'ebox--plist-keys #'ebox-fragment-plist-keys)
(defalias 'ebox--plist-changed-keys #'ebox-fragment-plist-changed-keys)
(defalias 'ebox--style-signature-dirty-kind
#'ebox-fragment-style-signature-dirty-kind)
(defalias 'ebox--layout-snapshot-detailed-p
#'ebox-fragment-layout-snapshot-detailed-p)
(defalias 'ebox--layout-snapshot-spans-p
#'ebox-fragment-layout-snapshot-spans-p)
(defalias 'ebox--node-layout-snapshot
#'ebox-fragment-node-layout-snapshot)
(defalias 'ebox--layout-snapshot-dirty-kind
#'ebox-fragment-layout-snapshot-dirty-kind)
(provide 'ebox-fragment)
;;; ebox-fragment.el ends here

648
ebox-grid.el Normal file
View File

@ -0,0 +1,648 @@
;;; ebox-grid.el --- Grid formatting context for Ebox -*- lexical-binding: t; -*-
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Commentary:
;; Grid is a two-dimensional layout node. It deliberately reuses Ebox's
;; existing pixel/line units, box renderer, and retained tree identity rather
;; than introducing a second styling or measurement model.
;;; Code:
(require 'cl-lib)
(require 'seq)
(require 'subr-x)
(require 'ebox-layout)
(require 'ebox-flex)
(declare-function ebox--ensure-node-id "ebox" (node))
(declare-function ebox--ensure-region-id "ebox" (box))
(declare-function ebox-create "ebox" (&rest plist))
(declare-function ebox-render "ebox-layout" (node))
(declare-function ebox--lines-concat-horizontal "ebox" (&rest strings))
(declare-function ebox-string-lines "ebox" (string))
(declare-function ebox-string-height "ebox" (string))
(declare-function ebox-lines-join "ebox" (lines))
(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))
(defvar ebox--region-box-table)
(defconst ebox--grid-layout-prop-keys
'(:grid-template-columns :grid-template-rows
:grid-auto-columns :grid-auto-rows :grid-auto-flow
:grid-column-gap :grid-row-gap :gap
:justify-items :align-items :justify-content :align-content)
"Properties owned by the grid formatting context.")
(defconst ebox--grid-item-prop-keys
'(:grid-column :grid-row :grid-column-span :grid-row-span)
"Properties that describe a child's position in a grid.")
(defun ebox-grid--split-attrs (items)
"Split grid ITEMS into a property plist and child list."
(let (props children)
(while items
(let ((item (pop items)))
(if (keywordp item)
(progn
(unless items
(error "ebox-grid: missing value for %S" item))
(push item props)
(push (pop items) props))
(push item children))))
(cons (nreverse props) (nreverse children))))
(defun ebox-grid--track-form-p (value)
"Return non-nil when VALUE is one composite track expression."
(and (consp value)
(memq (car value) '(fr minmax repeat))))
(defun ebox-grid--track-list (value)
"Return VALUE as a list of track expressions."
(cond
((null value) nil)
((ebox-grid--track-form-p value) (list value))
((or (numberp value) (symbolp value)) (list value))
(t value)))
(defun ebox-grid--expand-repeat (tracks)
"Expand REPEAT expressions in TRACKS."
(cl-mapcan
(lambda (track)
(if (and (consp track) (eq (car track) 'repeat))
(let ((count (cadr track))
(value (caddr track)))
(unless (and (null (cdddr track))
(integerp count) (> count 0) value)
(error "ebox-grid: invalid repeat track %S" track))
(make-list count value))
(list track)))
tracks))
(defun ebox-grid--normalize-track (value axis)
"Normalize one track VALUE for AXIS (`columns' or `rows')."
(setq value (if (and (consp value) (eq (car value) 'quote))
(cadr value)
value))
(cond
((eq value 'auto) '(:kind auto :factor 0))
((and (symbolp value)
(string-match "\\`\\([0-9]+\\(?:\\.[0-9]+\\)?\\)fr\\'"
(symbol-name value)))
(list :kind 'fr :factor
(string-to-number (match-string 1 (symbol-name value)))))
((and (consp value) (eq (car value) 'fr))
(unless (and (numberp (cadr value)) (> (cadr value) 0))
(error "ebox-grid: invalid fractional track %S" value))
(list :kind 'fr :factor (cadr value)))
((and (consp value) (eq (car value) 'minmax))
(unless (and (consp (cdr value))
(consp (cddr value))
(null (cdddr value)))
(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)))
(when (and (eq (plist-get maximum :kind) 'fixed)
(eq (plist-get minimum :kind) 'fixed)
(> (plist-get minimum :size) (plist-get maximum :size)))
(error "ebox-grid: minmax minimum exceeds maximum: %S" value))
(list :kind 'minmax :min minimum :max maximum)))
((eq axis 'columns)
(list :kind 'fixed
:size (ebox--nonnegative-horizontal-size-pixels value 0)))
(t
(let ((lines (ebox--flex-line-value value 0)))
(list :kind 'fixed :size lines)))))
(defun ebox-grid--normalize-tracks (value axis)
"Normalize a track template VALUE along AXIS."
(mapcar (lambda (track) (ebox-grid--normalize-track track axis))
(ebox-grid--expand-repeat (ebox-grid--track-list value))))
(defun ebox-grid--gap-pair (props)
"Return normalized (ROW-GAP . COLUMN-GAP) from grid PROPS."
(let* ((pair (ebox--flex-gap-pair (plist-get props :gap)))
(row (if (plist-member props :grid-row-gap)
(plist-get props :grid-row-gap)
(car pair)))
(column (if (plist-member props :grid-column-gap)
(plist-get props :grid-column-gap)
(cdr pair))))
(cons (or (ebox--flex-line-value row 0) 0)
(or (ebox--nonnegative-horizontal-size-pixels column 0) 0))))
(defun ebox-grid--visual-props-p (props)
"Return non-nil when PROPS contains visible box behavior."
(cl-loop for (key _value) on props by #'cddr
thereis (not (memq key (append ebox--grid-layout-prop-keys
'(:width :height :box-sizing)
ebox--grid-item-prop-keys)))))
(defun ebox-grid--wrapper (props)
"Create a visual wrapper for grid PROPS, or return nil."
(when (ebox-grid--visual-props-p props)
(when (plist-member props :content)
(error "ebox-grid: grid cannot combine :content with child nodes"))
(apply #'ebox-create
(append (ebox--preformatted-box-props props)
(list :content "")))))
;;;###autoload
(defun ebox-grid-fr (factor)
"Return a fractional grid track with FACTOR weight."
(unless (and (numberp factor) (> factor 0))
(error "ebox-grid-fr: FACTOR must be positive: %S" factor))
(list 'fr factor))
;;;###autoload
(defun ebox-grid-item (node &rest props)
"Return NODE with grid placement PROPS attached.
Supported properties are `:grid-column', `:grid-row',
`:grid-column-span', and `:grid-row-span'."
(unless (and (listp node) (plist-member node :ebox-type))
(error "ebox-grid-item: NODE is not an Ebox node: %S" node))
(let ((copy (copy-sequence node)))
(while props
(let* ((key (pop props))
(value (progn
(unless props
(error "ebox-grid-item: missing value for %S" key))
(pop props))))
(unless (memq key ebox--grid-item-prop-keys)
(error "ebox-grid-item: unsupported property %S" key))
(plist-put copy key value)))
copy))
(defun ebox-grid--placement-part (value default)
"Return (START SPAN) parsed from placement VALUE and DEFAULT."
(cond
((null value) (list default 1))
((numberp value)
(if (> value 0)
(list value 1)
(error "ebox-grid: placement starts at one: %S" value)))
((and (consp value) (numberp (car value)))
(let ((start (car value))
(tail (cdr value)))
(unless (> start 0)
(error "ebox-grid: placement starts at one: %S" value))
(cond
((null tail) (list start 1))
((and (eq (car tail) :span) (numberp (cadr tail)))
(list start (cadr tail)))
((numberp (car tail))
(list start (- (car tail) start)))
(t (error "ebox-grid: invalid placement %S" value)))))
(t (error "ebox-grid: invalid placement %S" value))))
(defun ebox-grid--placement (child)
"Return CHILD placement as (ROW COL ROW-SPAN COL-SPAN)."
(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))))
(dolist (part (list row column))
(when (or (not (integerp (car part))) (< (car part) 0)
(not (integerp (cadr part))) (< (cadr part) 1))
(error "ebox-grid: invalid placement %S" part)))
(unless (and (integerp row-span) (> row-span 0)
(integerp column-span) (> column-span 0))
(error "ebox-grid: grid spans must be positive integers"))
(list (car row) (car column) row-span column-span)))
(defun ebox-grid--occupancy (rows columns)
"Return an empty ROWS by COLUMNS occupancy matrix."
(let ((matrix (make-vector rows nil)))
(cl-loop for row from 0 below rows
do (aset matrix row (make-vector columns nil))
finally return matrix)))
(defun ebox-grid--grow (matrix rows columns)
"Grow MATRIX to at least ROWS and COLUMNS and return it."
(let* ((result matrix)
(existing-columns
(if (> (length result) 0)
(length (aref result 0))
0))
(target-columns (max columns existing-columns)))
(when (> columns 0)
(dotimes (row (length result))
(when (< (length (aref result row)) target-columns)
(setf (aref result row)
(vconcat (aref result row)
(make-vector
(- target-columns (length (aref result row)))
nil))))))
(when (> rows (length result))
(setq result
(vconcat result
(make-vector (- rows (length result))
(make-vector target-columns nil)))))
result))
(defun ebox-grid--free-p (matrix row column row-span column-span)
"Return non-nil when a placement rectangle is free in MATRIX."
(and (> row 0) (> column 0)
(cl-loop for r from (1- row) below (+ (1- row) row-span)
always (cl-loop for c from (1- column)
below (+ (1- column) column-span)
always (not (aref (aref matrix r) c))))))
(defun ebox-grid--mark (matrix entry row column row-span column-span)
"Mark ENTRY in placement rectangle MATRIX."
(cl-loop for r from (1- row) below (+ (1- row) row-span) do
(cl-loop for c from (1- column)
below (+ (1- column) column-span) do
(setf (aref (aref matrix r) c) entry))))
(defun ebox-grid--find-position (matrix placement flow)
"Find a free position for PLACEMENT in MATRIX using FLOW."
(let ((row (nth 0 placement))
(column (nth 1 placement))
(row-span (nth 2 placement))
(column-span (nth 3 placement)))
(cl-labels
((free-at-p (r c)
(and (<= (+ r row-span -1) (length matrix))
(<= (+ c column-span -1)
(length (aref matrix 0)))
(ebox-grid--free-p matrix r c row-span column-span)))
(scan-row (r start)
(cl-loop for c from start to (length (aref matrix 0))
when (free-at-p r c) return (list r c)))
(scan-column (c start)
(cl-loop for r from start to (length matrix)
when (free-at-p r c) return (list r c)))
(scan-flow ()
(or (if (eq flow 'column)
(cl-loop for c from 1 to (length (aref matrix 0))
thereis (scan-column c 1))
(cl-loop for r from 1 to (length matrix)
thereis (scan-row r 1)))
(if (eq flow 'column)
(list 1 (1+ (length (aref matrix 0))))
(list (1+ (length matrix)) 1)))))
(cond
((and (> row 0) (> column 0)) (list row column))
((> row 0)
(or (scan-row row (if (> column 0) column 1))
(list (1+ (length matrix)) 1)))
((> column 0)
(or (scan-column column 1)
(list (1+ (length matrix)) 1)))
(t (scan-flow))))))
(defun ebox-grid--place-children (children columns rows flow)
"Place CHILDREN and return (ENTRIES MATRIX ROWS COLUMNS)."
(let ((matrix (ebox-grid--occupancy rows columns))
entries)
(dolist (child children)
(let* ((placement (ebox-grid--placement child))
(row-span (nth 2 placement))
(column-span (nth 3 placement))
(row (nth 0 placement))
(column (nth 1 placement))
(explicit-p (and (> row 0) (> column 0)))
(candidate-row (if (> row 0) row 1))
(candidate-column (if (> column 0) column 1)))
(setq matrix (ebox-grid--grow matrix
(+ candidate-row row-span -1)
(+ candidate-column column-span -1)))
(if (ebox-grid--free-p matrix candidate-row candidate-column
row-span column-span)
(setq row candidate-row column candidate-column)
(when explicit-p
(error "ebox-grid: overlapping explicit placement at row %S column %S"
row column))
(let ((position (ebox-grid--find-position
matrix (list row column row-span column-span) flow)))
(setq row (car position) column (cadr position))
(setq matrix (ebox-grid--grow matrix (+ row row-span -1)
(+ column column-span -1)))))
(let ((entry (list :node child :row row :column column
:row-span row-span :column-span column-span)))
(ebox-grid--mark matrix entry row column row-span column-span)
(push entry entries))))
(list (nreverse entries) matrix (length matrix)
(length (aref matrix 0)))))
(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)
('fixed (plist-get track :size))
('fr 0)
('minmax
(ebox-grid--track-intrinsic (plist-get track :min)
axis entries rendered key slot-index))
(_ (let ((position (1+ slot-index))
(best 0))
(dolist (entry entries best)
(when (and (<= (plist-get entry key) position)
(< position
(+ (plist-get entry key)
(plist-get entry
(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))))
(setq best (max best value)))))))))
(defun ebox-grid--sum (values)
"Return the numeric sum of VALUES."
(apply #'+ (or values '(0))))
(defun ebox-grid--track-max (track)
"Return TRACK's fixed maximum, or nil when it is unbounded."
(pcase (plist-get track :kind)
('fixed (plist-get track :size))
('minmax (ebox-grid--track-max (plist-get track :max)))
(_ nil)))
(defun ebox-grid--clamp-track-size (track size)
"Clamp SIZE to TRACK's fixed maximum when one exists."
(if-let ((maximum (ebox-grid--track-max track)))
(min size maximum)
size))
(defun ebox-grid--resolve-sizes
(tracks count axis entries rendered gap available &optional auto-track)
"Resolve TRACKS to COUNT sizes along AXIS using AUTO-TRACK for implicit tracks."
(let* ((auto-track (or auto-track '(:kind auto :factor 0)))
(tracks (append tracks nil))
(tracks (append tracks
(make-list (max 0 (- count (length tracks)))
auto-track)))
(count (length tracks))
(sizes (cl-loop for track in tracks
for index from 0
collect (ebox-grid--clamp-track-size
track
(ebox-grid--track-intrinsic
track axis entries rendered
(if (eq axis 'columns) :column :row)
index))))
(fr-total (apply #'+
(mapcar (lambda (track)
(if (eq (plist-get track :kind) 'fr)
(plist-get track :factor) 0))
tracks)))
(used (+ (ebox-grid--sum sizes) (* gap (max 0 (1- count))))))
(when (and available (> fr-total 0))
(let ((remaining (max 0 (- available used))))
(cl-loop for track in tracks
for index from 0
when (eq (plist-get track :kind) 'fr)
do (setf (nth index sizes)
(floor (* remaining
(/ (float (plist-get track :factor))
fr-total)))))))
sizes))
(defun ebox-grid--distribute (amount count)
"Return COUNT integer shares that sum to AMOUNT."
(let ((base (if (> count 0) (/ amount count) 0))
(remainder (if (> count 0) (% amount count) 0)))
(cl-loop for index from 0 below count
collect (+ base (if (< index remainder) 1 0)))))
(defun ebox-grid--content-layout (sizes gap available alignment)
"Return track SIZES and content spacing for AVAILABLE space."
(let* ((natural (+ (ebox-grid--sum sizes)
(* gap (max 0 (1- (length sizes))))))
(target (max natural (or available natural)))
(leftover (- target natural)))
(if (and (> leftover 0) (memq alignment '(stretch normal)))
(let ((extra (ebox-grid--distribute leftover (length sizes))))
(list :sizes (cl-mapcar #'+ sizes extra)
:leading 0 :between gap :trailing 0
:target target))
(let ((spacing (ebox--flex-spacing
(or alignment 'start) leftover (length sizes) gap)))
(list :sizes sizes
:leading (nth 0 spacing)
:between (nth 1 spacing)
:trailing (nth 2 spacing)
:target target)))))
(defun ebox-grid--entry-size (entry sizes gap)
"Return the target pixel/line size for ENTRY across SIZES."
(let* ((start (1- (plist-get entry :column)))
(end (min (length sizes)
(+ start (plist-get entry :column-span))))
(base (ebox-grid--sum (seq-subseq sizes start end)))
(extra (* gap (max 0 (1- (plist-get entry :column-span))))))
(+ base extra)))
(defun ebox-grid--row-entry-size (entry sizes gap)
"Return the target line size for ENTRY across row SIZES."
(let* ((start (1- (plist-get entry :row)))
(end (min (length sizes)
(+ start (plist-get entry :row-span))))
(base (ebox-grid--sum (seq-subseq sizes start end)))
(extra (* gap (max 0 (1- (plist-get entry :row-span))))))
(+ base extra)))
(defun ebox-grid--align (string width height justify align)
"Align STRING to WIDTH and HEIGHT using JUSTIFY and ALIGN."
(let ((result (ebox--pixel-reach string width
(pcase justify
((or 'end 'right) 'right)
('center 'center)
(_ 'left)))))
(ebox--lines-align-vertical
result height
(pcase align
((or 'end 'bottom) 'bottom)
('center 'center)
(_ 'top)))))
(defun ebox-grid--entry-string (entry rendered widths heights col-gap row-gap props)
"Render and align ENTRY within its grid rectangle."
(let* ((node (plist-get entry :node))
(source (gethash node rendered))
(width (ebox-grid--entry-size entry widths col-gap))
(height (ebox-grid--row-entry-size entry heights row-gap))
(justify (or (plist-get props :justify-items) 'stretch))
(align (or (plist-get props :align-items) 'stretch)))
(ebox-grid--align source width height justify align)))
(defun ebox-grid--entry-at (matrix row column)
"Return entry occupying ROW and COLUMN in MATRIX."
(and (< row (length matrix))
(< column (length (aref matrix row)))
(aref (aref matrix row) column)))
(defun ebox-grid--entry-start-p (entry row column)
"Return non-nil when ENTRY starts at zero-based ROW and COLUMN."
(and (= row (1- (plist-get entry :row)))
(= column (1- (plist-get entry :column)))))
(defun ebox-grid--blank (width height)
"Return a blank grid area of WIDTH pixels and HEIGHT lines."
(or (ebox--pixel-blank width height)
(ebox-lines-join (make-list (max 1 height) ""))))
(defun ebox-grid--slice (string offset height)
"Return HEIGHT lines from STRING beginning at OFFSET."
(ebox-lines-join
(append (seq-take (nthcdr offset (ebox-string-lines string)) height)
(make-list (max 0 (- height
(length (seq-take
(nthcdr offset (ebox-string-lines string))
height))))
""))))
(defun ebox-grid--render (entries matrix column-layout row-layout props rendered)
"Render placed grid ENTRIES from MATRIX and resolved track layouts."
(let* ((widths (plist-get column-layout :sizes))
(heights (plist-get row-layout :sizes))
(col-gap (plist-get column-layout :between))
(row-gap (plist-get row-layout :between))
(total-width (plist-get column-layout :target))
(strings (make-hash-table :test 'eq))
result)
(dolist (entry entries)
(puthash entry
(ebox-grid--entry-string entry rendered widths heights
col-gap row-gap props)
strings))
(dotimes (row (length heights))
(let ((parts nil)
(column 0))
(while (< column (length widths))
(let ((entry (ebox-grid--entry-at matrix row column)))
(if (and entry (ebox-grid--entry-start-p entry row column))
(let* ((string (gethash entry strings))
(start (1- (plist-get entry :row)))
(offset (+ (ebox-grid--sum
(seq-subseq heights start row))
(* row-gap (- row start))))
(height (nth row heights)))
(push (ebox-grid--slice string offset height) parts)
(setq column (+ column (plist-get entry :column-span))))
(let ((width (nth column widths)))
(push (ebox-grid--blank width (nth row heights)) parts)
(setq column (1+ column))))
(when (< column (length widths))
(push (ebox-pixel-space col-gap) parts))))
(setq result
(append result
(list (apply #'ebox--lines-concat-horizontal
(append
(when (> (plist-get column-layout :leading) 0)
(list (ebox-pixel-space
(plist-get column-layout :leading))))
(nreverse parts)
(when (> (plist-get column-layout :trailing) 0)
(list (ebox-pixel-space
(plist-get column-layout :trailing)))))))))
(when (< row (1- (length heights)))
(setq result
(append result
(list (ebox-grid--blank total-width row-gap)))))
))
(ebox-lines-join
(append
(when (> (plist-get row-layout :leading) 0)
(list (ebox-grid--blank total-width
(plist-get row-layout :leading))))
result
(when (> (plist-get row-layout :trailing) 0)
(list (ebox-grid--blank total-width
(plist-get row-layout :trailing))))))))
(defun ebox--render-grid-box (node rendered)
"Render grid NODE's optional visual wrapper around RENDERED."
(if-let ((box (plist-get node :box)))
(let ((copy (copy-sequence box))
(region-id (ebox--ensure-region-id box)))
(plist-put copy :region-id region-id)
(plist-put copy :content rendered)
(plist-put copy :ebox-content-node nil)
(plist-put copy :ebox-content-width-exact-p t)
(prog1 (ebox-render copy)
(puthash region-id box ebox--region-box-table)))
rendered))
(defun ebox--render-grid (node)
"Render GRID NODE to a propertized string."
(let* ((props (plist-get node :raw-props))
(children (plist-get node :children))
(columns (ebox-grid--normalize-tracks
(plist-get props :grid-template-columns) 'columns))
(rows (ebox-grid--normalize-tracks
(plist-get props :grid-template-rows) 'rows))
(auto-columns (car (ebox-grid--normalize-tracks
(plist-get props :grid-auto-columns) 'columns)))
(auto-rows (car (ebox-grid--normalize-tracks
(plist-get props :grid-auto-rows) 'rows)))
(gaps (ebox-grid--gap-pair props))
(flow (or (plist-get props :grid-auto-flow) 'row))
(placed (ebox-grid--place-children children
(max 1 (length columns))
(max 1 (length rows)) flow))
(entries (nth 0 placed))
(matrix (nth 1 placed))
(row-count (nth 2 placed))
(column-count (nth 3 placed))
(available (ebox--nonnegative-horizontal-size-pixels
(plist-get props :width)
(ebox--viewport-pixel-width nil)))
(height (ebox--flex-line-value (plist-get props :height) nil))
(rendered (make-hash-table :test 'eq)))
(unless (memq flow '(row column))
(error "ebox-grid: :grid-auto-flow must be `row' or `column'"))
(dolist (entry entries)
(let ((child (plist-get entry :node)))
(puthash child (ebox--render-with-cache child) rendered)))
(let* ((widths (ebox-grid--resolve-sizes columns column-count 'columns
entries rendered (cdr gaps)
available auto-columns))
(heights (ebox-grid--resolve-sizes rows row-count 'rows
entries rendered (car gaps)
height auto-rows))
(column-layout
(ebox-grid--content-layout
widths (cdr gaps) available
(or (plist-get props :justify-content) 'start)))
(row-layout
(ebox-grid--content-layout
heights (car gaps) height
(or (plist-get props :align-content) 'start)))
(body (ebox-grid--render entries matrix column-layout row-layout
props rendered)))
(ebox--render-grid-box node body))))
;;;###autoload
(defun ebox-grid (&rest items)
"Return a two-dimensional grid layout node from ITEMS.
Grid properties include `:grid-template-columns', `:grid-template-rows',
`:grid-auto-flow', `:gap', `:grid-row-gap', `:grid-column-gap',
`:justify-items', `:align-items', `:width', and `:height'. Track sizes use
Ebox pixel/line units, `auto', `(fr FACTOR)', or symbols such as `1fr'.
Children may carry `:grid-column' and `:grid-row' placement properties.
`ebox-grid-fr' is a convenient constructor for fractional tracks."
(let* ((split (ebox-grid--split-attrs items))
(raw-props (car split))
(children (delq nil (cdr split)))
(box-props (cl-loop for (key value) on raw-props by #'cddr
unless (memq key ebox--grid-layout-prop-keys)
collect key and collect value))
(wrapper (ebox-grid--wrapper box-props)))
(list :ebox-type 'grid
:display '(block grid)
:props raw-props
:raw-props raw-props
:box wrapper
:children children)))
(provide 'ebox-grid)
;;; ebox-grid.el ends here

8821
ebox-incremental.el Normal file

File diff suppressed because it is too large Load Diff

2803
ebox-layout.el Normal file

File diff suppressed because it is too large Load Diff

349
ebox-measure.el Normal file
View File

@ -0,0 +1,349 @@
;;; ebox-measure.el --- Pixel measurement helpers for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns display-signature-sensitive measurement caches and text/space width
;; helpers. It does not own layout decisions or dirty classification.
;;; Code:
(require 'cl-lib)
(require 'ebox-cache)
;; Measurement Cache Model:
;; These caches only accelerate display-signature-sensitive pixel measurement.
;; They must not carry layout identity, dirty state, or buffer ownership.
(defvar ebox--char-width-cache (make-hash-table :test 'eq)
"Cache for character pixel widths.")
(defvar ebox--space-pixel-cache nil
"Cache for space pixel width.")
(defvar ebox--face-height-width-cache nil
"Cache indicating whether explicit face height affects measured width.")
(defvar ebox--display-signature-cache nil
"Cached display signature used to invalidate pixel measurements.")
(defvar ebox--display-cache-validated nil
"Non-nil while the display signature has already been validated.
Bound to t for the dynamic extent of a single render so that the thousands
of pixel measurements it triggers do not each rebuild the display signature.")
(defvar ebox--render-display-signature nil
"Display signature snapshot for the current render transaction.")
(defvar ebox--render-string-pixel-width-cache nil
"Dynamic render-local cache for reusable string pixel widths.")
(defconst ebox--string-pixel-width-cache-max-entries 4096
"Maximum safe string measurements retained for one display signature.")
(defvar ebox--string-pixel-width-cache (make-hash-table :test 'equal)
"Display-signature-scoped cache for reusable string pixel widths.")
(defvar ebox--string-pixel-width-cache-ring
(make-vector ebox--string-pixel-width-cache-max-entries nil)
"Insertion-order keys for bounded string measurement eviction.")
(defvar ebox--string-pixel-width-cache-ring-index 0
"Next insertion slot in `ebox--string-pixel-width-cache-ring'.")
(defvar ebox--render-string-max-pixel-width-cache nil
"Dynamic render-local cache for rendered multiline string max widths.")
(defvar ebox--render-recached-source-node-cache nil
"Dynamic render-local set of source subtrees recached into region tables.")
(defun ebox--text-scale-factor ()
"Return the active text scale multiplier."
(let ((amount (and (boundp 'text-scale-mode-amount)
(numberp text-scale-mode-amount)
text-scale-mode-amount))
(step (if (and (boundp 'text-scale-mode-step)
(numberp text-scale-mode-step))
text-scale-mode-step
1.2)))
(if amount (expt step amount) 1.0)))
(defun ebox--display-space-width (display)
"Return fixed pixel width for DISPLAY space specs, or nil."
(when (and (consp display) (eq (car display) 'space))
(let ((width (plist-get (cdr display) :width)))
(cond
((numberp width) width)
((and (consp width) (numberp (car width))) (car width))))))
(defun ebox--string-with-text-scale (string factor)
"Return STRING with FACTOR applied as an explicit face height."
(if (= factor 1.0)
string
(let ((copy (copy-sequence string)))
(add-face-text-property 0 (length copy) `(:height ,factor) t copy)
copy)))
(defun ebox--face-height-affects-width-p (factor)
"Return non-nil when `string-pixel-width' honors explicit face FACTOR."
(or (= factor 1.0)
(let ((cached (assoc factor ebox--face-height-width-cache)))
(if cached
(cdr cached)
(let* ((probe "MMMMmmmm")
(plain (string-pixel-width probe))
(scaled (string-pixel-width
(ebox--string-with-text-scale probe factor)))
(works (/= scaled plain)))
(push (cons factor works) ebox--face-height-width-cache)
works)))))
(defun ebox--scaled-string-pixel-width (string factor)
"Return pixel width of STRING under text-scale FACTOR."
(let ((plain (string-pixel-width string)))
(cond
((= factor 1.0) plain)
((ebox--face-height-affects-width-p factor)
(string-pixel-width (ebox--string-with-text-scale string factor)))
(t
(ceiling (* plain factor))))))
(defun ebox--clear-string-pixel-width-cache ()
"Clear display-scoped reusable string measurements and eviction state."
(clrhash ebox--string-pixel-width-cache)
(fillarray ebox--string-pixel-width-cache-ring nil)
(setq ebox--string-pixel-width-cache-ring-index 0))
(defun ebox--string-pixel-width-cache-key (line factor)
"Return a safe display-scoped measurement key for LINE at FACTOR."
(let* ((end (length line))
(properties (and (> end 0) (text-properties-at 0 line)))
(constant-p
(= (or (next-property-change 0 line end) end) end)))
(cond
((and constant-p (null properties))
(list factor line))
((and constant-p
(= (length properties) 2)
(eq (car properties) 'face))
(list factor
line
(cadr properties))))))
(defun ebox--cached-string-pixel-width (line factor key)
"Return LINE width at FACTOR through the render and display caches at KEY."
(let ((cached
(or (and ebox--render-string-pixel-width-cache
(gethash key ebox--render-string-pixel-width-cache))
(gethash key ebox--string-pixel-width-cache))))
(if cached
(progn
(when ebox--render-string-pixel-width-cache
(puthash key cached ebox--render-string-pixel-width-cache))
cached)
(let* ((width (ebox--scaled-string-pixel-width line factor))
(index ebox--string-pixel-width-cache-ring-index)
(evicted (aref ebox--string-pixel-width-cache-ring index)))
(when ebox--render-string-pixel-width-cache
(puthash key width ebox--render-string-pixel-width-cache))
(when evicted
(remhash evicted ebox--string-pixel-width-cache))
(puthash key width ebox--string-pixel-width-cache)
(aset ebox--string-pixel-width-cache-ring index key)
(setq ebox--string-pixel-width-cache-ring-index
(mod (1+ index)
ebox--string-pixel-width-cache-max-entries))
width))))
(defun ebox--string-pixel-width (string)
"Return the display pixel width of STRING's first line.
Text characters follow the current buffer text scale. Fixed display spaces
keep their absolute pixel width."
(ebox--ensure-display-cache-current)
(let* ((line (car (split-string (or string "") "\n")))
(factor (ebox--text-scale-factor))
(pos 0)
(end (length line))
(total 0)
(cache-key (ebox--string-pixel-width-cache-key line factor)))
(if cache-key
(ebox--cached-string-pixel-width line factor cache-key)
(while (< pos end)
(let* ((display (get-text-property pos 'display line))
(next (or (next-single-property-change
pos 'display line end)
end))
(space-width (ebox--display-space-width display)))
(setq total
(+ total
(if space-width
(* space-width (- next pos))
(ebox--scaled-string-pixel-width
(substring line pos next)
factor))))
(setq pos next)))
total)))
(defun ebox--string-max-pixel-width (string)
"Return the maximum display pixel width across STRING's lines."
(if (string-empty-p string)
0
(let* ((cache ebox--render-string-max-pixel-width-cache)
(missing (make-symbol "ebox-string-max-pixel-width-missing"))
(cached (if cache
(gethash string cache missing)
missing)))
(if (not (eq cached missing))
cached
(let ((width (apply #'max
(mapcar #'ebox--string-pixel-width
(ebox-string-lines string)))))
(when cache
(puthash string width cache))
width)))))
;;;###autoload
(defun ebox-string-pixel-width (string)
"Return the display pixel width of STRING's first line.
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)
(and (boundp 'face-remapping-alist) face-remapping-alist)
(and (boundp 'text-scale-mode-amount) text-scale-mode-amount)))
(defsubst ebox--current-display-signature ()
"Return the render snapshot or sample the current display signature."
(or ebox--render-display-signature
(ebox--display-signature)))
;;;###autoload
(defun ebox-display-signature ()
"Return the current display signature used for pixel measurements."
(ebox--display-signature))
(defun ebox--validate-display-cache ()
"Clear pixel caches when the current display signature changed."
(let ((signature (ebox--current-display-signature)))
(unless (equal signature ebox--display-signature-cache)
(clrhash ebox--char-width-cache)
(ebox--clear-string-pixel-width-cache)
(setq ebox--space-pixel-cache nil)
(setq ebox--face-height-width-cache nil)
(setq ebox--display-signature-cache signature))))
(defun ebox--ensure-display-cache-current ()
"Validate the display cache unless it was already validated for this render.
Rebuilding the display signature is comparatively expensive, so a single
render validates it once via `ebox--with-validated-display-cache' and every
inner measurement skips the check."
(unless ebox--display-cache-validated
(ebox--validate-display-cache)))
(defmacro ebox--with-validated-display-cache (&rest body)
"Validate the display cache once, then run BODY without re-validating.
Nested uses reuse the outer validation."
(declare (indent 0) (debug t))
`(if ebox--display-cache-validated
(progn ,@body)
(let ((ebox--display-cache-validated t)
(ebox--render-display-signature (ebox--display-signature)))
(let ((ebox--render-string-pixel-width-cache
(make-hash-table :test 'equal))
(ebox--render-string-max-pixel-width-cache
(make-hash-table :test 'eq))
(ebox--render-recached-source-node-cache
(make-hash-table :test 'eq)))
(ebox--validate-display-cache)
,@body))))
(defsubst ebox--char-pixel-width (char)
"Get the pixel width of CHAR, using cache."
(ebox--ensure-display-cache-current)
(or (gethash char ebox--char-width-cache)
(puthash char (ebox--string-pixel-width (char-to-string char))
ebox--char-width-cache)))
(defsubst ebox--substring-pixel-width (string start end)
"Return STRING width from START to END, preserving text properties."
(ebox--string-pixel-width (substring string start end)))
(defsubst ebox--space-pixel-width ()
"Get the pixel width of a space character, using cache."
(ebox--ensure-display-cache-current)
(or ebox--space-pixel-cache
(setq ebox--space-pixel-cache (ebox--string-pixel-width " "))))
;;;###autoload
(defun ebox-clear-cache ()
"Clear pixel width caches after font or named face metrics change."
(clrhash ebox--char-width-cache)
(ebox--clear-string-pixel-width-cache)
(setq ebox--space-pixel-cache nil)
(setq ebox--face-height-width-cache nil)
(setq ebox--display-signature-cache (ebox--display-signature)))
(defun ebox--init-ascii-cache ()
"Pre-calculate cache for ASCII characters."
(dotimes (i 128)
(ebox--char-pixel-width i))
(ebox--space-pixel-width))
(defsubst ebox-pixel-space (pixel-width)
"Generate a space string with PIXEL-WIDTH."
(if (or (null pixel-width) (<= pixel-width 0))
""
(propertize " " 'display `(space :width (,pixel-width)))))
(defun ebox--pixel-blank (pixel-width height)
"Generate a blank area of PIXEL-WIDTH and HEIGHT lines."
(when (and pixel-width height (> pixel-width 0) (> height 0))
(ebox--string-repeat-lines (ebox-pixel-space pixel-width) height)))
(defun ebox--pixel-pad (string prefix-pixel &optional suffix-pixel)
"Pad STRING with pixel spaces on left (PREFIX-PIXEL) and right (SUFFIX-PIXEL)."
(concat (ebox-pixel-space (or prefix-pixel 0))
string
(ebox-pixel-space (or suffix-pixel 0))))
(defun ebox--pixel-reach (string total-pixel &optional align)
"Extend STRING to TOTAL-PIXEL width.
ALIGN can be `left', `center', or `right'."
(let* ((string-pixel (ebox--string-pixel-width string))
(rest-pixel (max 0 (- total-pixel string-pixel)))
(align (or align 'left))
(left-pixel (pcase align
('left 0)
('right rest-pixel)
('center (/ rest-pixel 2))
(_ 0))))
(ebox--pixel-pad string left-pixel (- rest-pixel left-pixel))))
(defsubst ebox-pixel-left (string total-pixel)
"Align STRING to the left within TOTAL-PIXEL."
(ebox--pixel-reach string total-pixel 'left))
(defsubst ebox-pixel-center (string total-pixel)
"Align STRING to the center within TOTAL-PIXEL."
(ebox--pixel-reach string total-pixel 'center))
(defsubst ebox-pixel-right (string total-pixel)
"Align STRING to the right within TOTAL-PIXEL."
(ebox--pixel-reach string total-pixel 'right))
;; Initialize measurement cache after all measurement helpers are defined.
(ebox--init-ascii-cache)
(provide 'ebox-measure)
;;; ebox-measure.el ends here

3765
ebox-native-reflow.el Normal file

File diff suppressed because it is too large Load Diff

63
ebox-render-context.el Normal file
View File

@ -0,0 +1,63 @@
;;; ebox-render-context.el --- Shared render context binding -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns the compile-safe render-context binding shared by the incremental
;; planner and the buffer backend. Keeping this macro below both modules avoids
;; their runtime dependency cycle becoming an invalid byte-compiled function.
;;; Code:
(defvar ebox-viewport-width nil)
(defvar ebox-viewport-height nil)
(defvar ebox--render-runtime-revision nil)
(defvar ebox--render-cache-table nil)
(defvar ebox--render-cache-signature-cache nil)
(defvar ebox--viewport-dependent-node-ids-cache nil)
(defvar ebox--viewport-dependent-subtree-cache nil)
(defvar ebox--viewport-height-dependent-subtree-cache nil)
(defvar ebox--flex-content-min-width-table nil
"Render-owned box measurements reusable by native scene compilation.")
(declare-function ebox--buffer-render-state
"ebox-incremental" (buffer))
(defmacro ebox--with-buffer-render-context (buffer &rest body)
"Evaluate BODY using BUFFER's stored render context."
(declare (indent 1) (debug t))
(let ((target-buffer (make-symbol "target-buffer"))
(render-state (make-symbol "render-state")))
`(let* ((,target-buffer ,buffer)
(,render-state
(ebox--buffer-render-state ,target-buffer)))
(with-current-buffer ,target-buffer
(let* ((ebox-viewport-width
(plist-get ,render-state :viewport-width))
(ebox-viewport-height
(plist-get ,render-state :viewport-height))
(ebox--render-runtime-revision
(plist-get ,render-state :runtime-revision))
(ebox--render-cache-table
(plist-get ,render-state :render-cache))
(ebox--render-cache-signature-cache
(or ebox--render-cache-signature-cache
(plist-get ,render-state :render-signature-cache)
(make-hash-table :test 'eq)))
(ebox--viewport-dependent-node-ids-cache
(or ebox--viewport-dependent-node-ids-cache
(make-hash-table :test 'eq)))
(ebox--viewport-dependent-subtree-cache
(or ebox--viewport-dependent-subtree-cache
(make-hash-table :test 'eq)))
(ebox--viewport-height-dependent-subtree-cache
(or ebox--viewport-height-dependent-subtree-cache
(plist-get
,render-state
:viewport-height-dependent-subtree-cache)
(make-hash-table :test 'eq)))
(ebox--flex-content-min-width-table
(or ebox--flex-content-min-width-table
(plist-get ,render-state :flex-content-min-widths))))
,@body)))))
(provide 'ebox-render-context)
;;; ebox-render-context.el ends here

517
ebox-selector.el Normal file
View File

@ -0,0 +1,517 @@
;;; ebox-selector.el --- CSS-like runtime selectors for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns selector parsing and tree/runtime matching. Selectors are lookup
;; syntax only; stable identity remains node ids, region ids, and keys.
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(require 'ebox-buffer-backend)
(require 'ebox-tree)
(declare-function ebox--ensure-node-id "ebox" (node))
(declare-function ebox--ensure-region-id "ebox" (box))
(declare-function ebox--buffer-root-node "ebox-incremental" (buffer))
(declare-function ebox--buffer-selector-id-table "ebox-incremental" (buffer))
(declare-function ebox--buffer-selector-class-table
"ebox-incremental" (buffer))
(declare-function ebox--buffer-selector-type-table
"ebox-incremental" (buffer))
(declare-function ebox-incremental-begin-batch "ebox-incremental" (buffer))
(declare-function ebox-incremental-flush "ebox-incremental" (buffer))
(declare-function ebox-region-update "ebox" (region-id &rest props))
(defvar ebox--region-update-buffer-hint)
(defun ebox-selector--identifier-char-p (char)
"Return non-nil when CHAR is accepted in a selector identifier."
(or (and (>= char ?a) (<= char ?z))
(and (>= char ?A) (<= char ?Z))
(and (>= char ?0) (<= char ?9))
(memq char '(?_ ?- ?:))))
(defun ebox-selector--skip-space (selector pos)
"Return the next non-space position in SELECTOR after POS."
(let ((len (length selector)))
(while (and (< pos len)
(memq (aref selector pos) '(?\s ?\t ?\n ?\r)))
(setq pos (1+ pos)))
pos))
(defun ebox-selector--read-identifier (selector pos)
"Read one selector identifier from SELECTOR at POS.
Return a cons of (IDENTIFIER . NEXT-POS)."
(let ((start pos)
(len (length selector)))
(while (and (< pos len)
(ebox-selector--identifier-char-p (aref selector pos)))
(setq pos (1+ pos)))
(when (= pos start)
(user-error "ebox-selector: expected identifier at %d in %S"
pos selector))
(cons (substring selector start pos) pos)))
(defun ebox-selector--read-attr-value (selector pos)
"Read one attribute value from SELECTOR at POS.
Return a cons of (VALUE . NEXT-POS)."
(let ((len (length selector)))
(if (and (< pos len) (= (aref selector pos) ?\"))
(let ((start (1+ pos)))
(setq pos start)
(while (and (< pos len)
(/= (aref selector pos) ?\"))
(setq pos (1+ pos)))
(when (>= pos len)
(user-error "ebox-selector: unterminated attribute value in %S"
selector))
(cons (substring selector start pos) (1+ pos)))
(ebox-selector--read-identifier selector pos))))
(defun ebox-selector--simple-selector (type id classes attrs)
"Return a normalized simple selector plist."
(append
(when type (list :type type))
(when id (list :id id))
(when classes (list :classes (nreverse classes)))
(when attrs (list :attrs (nreverse attrs)))))
(defun ebox-selector--parse-attribute (selector pos attrs)
"Parse an attribute selector in SELECTOR at POS and push into ATTRS."
(let* ((name-read (ebox-selector--read-identifier selector (1+ pos)))
(name (car name-read))
(next (cdr name-read)))
(unless (and (< next (length selector))
(= (aref selector next) ?=))
(user-error "ebox-selector: expected = after attribute at %d in %S"
next selector))
(let* ((value-read (ebox-selector--read-attr-value selector (1+ next)))
(value (car value-read))
(end (cdr value-read)))
(unless (and (< end (length selector))
(= (aref selector end) ?\]))
(user-error "ebox-selector: expected ] at %d in %S" end selector))
(list :attrs (cons (cons (intern (concat ":" name)) value)
attrs)
:pos (1+ end)))))
(defun ebox-selector--parse-simple (selector pos)
"Parse one simple selector from SELECTOR at POS.
Return a cons of (SIMPLE-SELECTOR . NEXT-POS)."
(let ((len (length selector))
type id classes attrs)
(when (and (< pos len)
(ebox-selector--identifier-char-p (aref selector pos)))
(let ((read (ebox-selector--read-identifier selector pos)))
(setq type (intern (car read))
pos (cdr read))))
(while (and (< pos len)
(memq (aref selector pos) '(?. ?# ?\[)))
(pcase (aref selector pos)
(?.
(let ((read (ebox-selector--read-identifier selector (1+ pos))))
(push (car read) classes)
(setq pos (cdr read))))
(?#
(let ((read (ebox-selector--read-identifier selector (1+ pos))))
(setq id (car read)
pos (cdr read))))
(?\[
(let* ((parsed (ebox-selector--parse-attribute selector pos attrs)))
(setq attrs (plist-get parsed :attrs)
pos (plist-get parsed :pos))))))
(unless (or type id classes attrs)
(user-error "ebox-selector: expected selector at %d in %S"
pos selector))
(cons (ebox-selector--simple-selector type id classes attrs) pos)))
;;;###autoload
(defun ebox-selector-parse (selector)
"Parse SELECTOR into a normalized selector AST."
(unless (and (stringp selector)
(> (length (string-trim selector)) 0))
(user-error "ebox-selector: selector must be a non-empty string"))
(let* ((pos (ebox-selector--skip-space selector 0))
(len (length selector))
sequence)
(while (< pos len)
(let ((read (ebox-selector--parse-simple selector pos)))
(push (car read) sequence)
(setq pos (cdr read)))
(let ((before-space pos))
(setq pos (ebox-selector--skip-space selector pos))
(cond
((>= pos len))
((= (aref selector pos) ?>)
(push :child sequence)
(setq pos (ebox-selector--skip-space selector (1+ pos)))
(when (>= pos len)
(user-error "ebox-selector: child combinator has no target in %S"
selector)))
((> pos before-space)
(push :descendant sequence))
(t
(user-error "ebox-selector: expected combinator at %d in %S"
pos selector)))))
(list :sequence (nreverse sequence))))
(defun ebox-selector--metadata-string (value)
"Return VALUE normalized to a selector metadata string."
(cond
((null value) nil)
((symbolp value) (symbol-name value))
((stringp value) value)
(t (format "%s" value))))
(defun ebox-selector--node-attr (node attr)
"Return NODE's selector metadata value for ATTR as a string."
(pcase attr
(:id (ebox-tree-node-id node))
(:key (ebox-tree-node-key node))
(:class nil)
(_ (and (listp node)
(ebox-selector--metadata-string (plist-get node attr))))))
;;;###autoload
(defun ebox-selector-match-node-p (node simple-selector)
"Return non-nil when NODE satisfies SIMPLE-SELECTOR."
(and (listp node)
(or (not (plist-member simple-selector :type))
(eq (plist-get simple-selector :type)
(ebox-tree-node-selector-type node)))
(or (not (plist-member simple-selector :id))
(equal (plist-get simple-selector :id)
(ebox-tree-node-id node)))
(cl-every (lambda (class)
(member class (ebox-tree-node-classes node)))
(plist-get simple-selector :classes))
(cl-every (lambda (attr)
(equal (cdr attr)
(ebox-selector--node-attr node (car attr))))
(plist-get simple-selector :attrs))))
(defun ebox-selector--node-region-id (node)
"Return NODE's editable box region id, or nil."
(pcase (and (listp node) (plist-get node :ebox-type))
('box (ebox--ensure-region-id node))
('flex
(when-let ((box (plist-get node :box)))
(ebox--ensure-region-id box)))
('grid
(when-let ((box (plist-get node :box)))
(ebox--ensure-region-id box)))
('flex-item
(ebox-selector--node-region-id (plist-get node :node)))
(_ nil)))
(defun ebox-selector--match-handle (node path selector)
"Return a selector match handle for NODE on PATH."
(list :node node
:node-id (ebox--ensure-node-id node)
:region-id (ebox-selector--node-region-id node)
:path path
:selector selector))
(defun ebox-selector--walk (node path)
"Return pre-order entries under NODE.
Each entry is a cons of (NODE . PATH), where PATH is root-to-node."
(when (and (listp node) (not (stringp node)))
(let ((current-path (append path (list node))))
(cons (cons node current-path)
(apply #'append
(mapcar (lambda (child)
(ebox-selector--walk child current-path))
(ebox-tree-children node)))))))
(defun ebox-selector--layout-leaves (node)
"Return semantic selector leaves for layout adapter NODE."
(cond
((or (not (listp node)) (stringp node)) nil)
(t
(pcase (plist-get node :ebox-type)
('concat
(apply #'append
(mapcar #'ebox-selector--layout-leaves
(ebox--layout-children node))))
('stack
(apply #'append
(mapcar #'ebox-selector--layout-leaves
(ebox--layout-children node))))
('flex-item
(ebox-selector--layout-leaves (plist-get node :node)))
(_ (list node))))))
(defun ebox-selector--semantic-children (node)
"Return NODE's selector child candidates.
This hides internal concat/stack adapter nodes behind wrapper boxes so child
selectors match the user's logical layout children."
(pcase (and (listp node) (plist-get node :ebox-type))
('box
(if-let ((content-node (plist-get node :ebox-content-node)))
(ebox-selector--layout-leaves content-node)
nil))
('concat
(ebox-selector--layout-leaves node))
('stack
(ebox-selector--layout-leaves node))
('flex
(mapcar #'ebox-tree-flex-item-source-node
(plist-get node :children)))
('grid
(plist-get node :children))
('flex-item
(delq nil (list (plist-get node :node))))
(_ (ebox-tree-children node))))
(defun ebox-selector--child-entries (entry)
"Return semantic child entries for ENTRY."
(let ((path (cdr entry)))
(mapcar (lambda (child)
(cons child (append path (list child))))
(ebox-selector--semantic-children (car entry)))))
(defun ebox-selector--descendant-entries (entry)
"Return descendant entries below ENTRY, excluding ENTRY itself."
(apply #'append
(mapcar (lambda (child)
(ebox-selector--walk child (cdr entry)))
(ebox-tree-children (car entry)))))
(defun ebox-selector--filter-entries (entries simple-selector)
"Return ENTRIES whose nodes match SIMPLE-SELECTOR."
(cl-remove-if-not (lambda (entry)
(ebox-selector-match-node-p
(car entry) simple-selector))
entries))
(defun ebox-selector--run-sequence (root sequence)
"Return matched entries for selector SEQUENCE under ROOT."
(let* ((first (car sequence))
(entries (ebox-selector--filter-entries
(ebox-selector--walk root nil)
first))
(rest (cdr sequence)))
(while rest
(let ((combinator (pop rest))
(simple (pop rest)))
(unless simple
(user-error "ebox-selector: combinator %S has no target"
combinator))
(setq entries
(ebox-selector--filter-entries
(pcase combinator
(:child
(apply #'append
(mapcar #'ebox-selector--child-entries entries)))
(:descendant
(apply #'append
(mapcar #'ebox-selector--descendant-entries
entries)))
(_
(user-error "ebox-selector: unsupported combinator %S"
combinator)))
simple))))
entries))
;;;###autoload
(defun ebox-selector-query-all (root selector)
"Return document-ordered selector match handles under ROOT."
(let* ((ast (ebox-selector-parse selector))
(sequence (plist-get ast :sequence)))
(mapcar (lambda (entry)
(ebox-selector--match-handle (car entry) (cdr entry) selector))
(ebox-selector--run-sequence root sequence))))
(defun ebox-selector--resolve-buffer (buffer)
"Return live buffer object for BUFFER, or signal a user error."
(let ((resolved (get-buffer buffer)))
(unless (and resolved (buffer-live-p resolved))
(user-error "ebox-selector: buffer is not live: %S" buffer))
resolved))
(defun ebox-selector--handle-with-buffer (handle buffer)
"Return selector HANDLE annotated with BUFFER ownership."
(append handle (list :buffer buffer)))
(defun ebox-selector--single-id-simple (sequence)
"Return the simple selector when SEQUENCE is a single #id candidate."
(when (and (= (length sequence) 1)
(plist-member (car sequence) :id))
(car sequence)))
(defun ebox-selector--candidate-entries-for-simple (buffer simple)
"Return indexed candidate entries for SIMPLE in BUFFER, or nil."
(let (candidates)
(when-let* ((id (plist-get simple :id))
(table (ebox--buffer-selector-id-table buffer)))
(push (gethash id table) candidates))
(when-let ((table (ebox--buffer-selector-class-table buffer)))
(dolist (class (plist-get simple :classes))
(push (gethash class table) candidates)))
(when-let* ((type (plist-get simple :type))
(table (ebox--buffer-selector-type-table buffer)))
(push (gethash type table) candidates))
(when candidates
(car (sort (cl-remove-if-not #'identity candidates)
(lambda (left right)
(< (length left) (length right))))))))
(defun ebox-selector--indexed-simple-entries (buffer simple)
"Return document-ordered indexed entries matching SIMPLE in BUFFER."
(when-let ((entries (ebox-selector--candidate-entries-for-simple
buffer simple)))
(cl-remove-if-not
(lambda (entry)
(ebox-selector-match-node-p (car entry) simple))
entries)))
(defun ebox-selector--single-simple-sequence-p (sequence)
"Return non-nil when SEQUENCE is exactly one simple selector."
(and (= (length sequence) 1)
(listp (car sequence))))
(defun ebox-selector--descendant-only-sequence-p (sequence)
"Return non-nil when SEQUENCE only uses descendant combinators."
(let ((rest (cdr sequence))
(ok t))
(while rest
(unless (and (eq (car rest) :descendant)
(listp (cadr rest)))
(setq ok nil
rest nil))
(setq rest (cddr rest)))
ok))
(defun ebox-selector--sequence-simples (sequence)
"Return simple selectors from SEQUENCE."
(cl-remove-if #'keywordp sequence))
(defun ebox-selector--path-matches-descendant-sequence-p (path sequence)
"Return non-nil when PATH matches descendant-only SEQUENCE."
(let ((simples (reverse (ebox-selector--sequence-simples sequence)))
(nodes (reverse path))
ok)
(when (and simples nodes
(ebox-selector-match-node-p (car nodes) (car simples)))
(setq ok t
nodes (cdr nodes)
simples (cdr simples))
(while (and ok simples)
(let (found)
(while (and nodes (not found))
(if (ebox-selector-match-node-p (car nodes) (car simples))
(setq found t)
(setq nodes (cdr nodes))))
(if found
(setq nodes (cdr nodes)
simples (cdr simples))
(setq ok nil)))))
(and ok (null simples))))
(defun ebox-selector--entries-to-buffer-handles (entries selector buffer)
"Return selector match handles for ENTRIES annotated with BUFFER."
(mapcar (lambda (entry)
(ebox-selector--handle-with-buffer
(ebox-selector--match-handle
(car entry) (cdr entry) selector)
buffer))
entries))
(defun ebox-selector--query-buffer-index
(buffer selector sequence)
"Return indexed matches for SEQUENCE in BUFFER, or nil when unsupported."
(cond
((ebox-selector--single-simple-sequence-p sequence)
(when-let ((entries (ebox-selector--indexed-simple-entries
buffer (car sequence))))
(ebox-selector--entries-to-buffer-handles entries selector buffer)))
((ebox-selector--descendant-only-sequence-p sequence)
(let* ((last-simple (car (last (ebox-selector--sequence-simples
sequence))))
(entries (ebox-selector--indexed-simple-entries
buffer last-simple)))
(when entries
(ebox-selector--entries-to-buffer-handles
(cl-remove-if-not
(lambda (entry)
(ebox-selector--path-matches-descendant-sequence-p
(cdr entry) sequence))
entries)
selector buffer))))))
;;;###autoload
(defun ebox-selector-query-buffer (buffer selector)
"Return document-ordered selector match handles from BUFFER runtime state.
Selector lookup reads the stored runtime root, not rendered buffer text
properties. Each returned handle includes `:buffer' in addition to the
tree-query handle fields."
(let* ((resolved-buffer (ebox-selector--resolve-buffer buffer))
(root (ebox--buffer-root-node resolved-buffer)))
(unless root
(user-error "ebox-selector: buffer has no Ebox runtime state: %S"
resolved-buffer))
(let* ((ast (ebox-selector-parse selector))
(sequence (plist-get ast :sequence)))
(or (ebox-selector--query-buffer-index
resolved-buffer selector sequence)
(mapcar (lambda (handle)
(ebox-selector--handle-with-buffer
handle resolved-buffer))
(mapcar (lambda (entry)
(ebox-selector--match-handle
(car entry) (cdr entry) selector))
(ebox-selector--run-sequence
root sequence)))))))
(defun ebox-selector--skip-handle (handle reason)
"Return a structured skip entry for HANDLE and REASON."
(list :node-id (plist-get handle :node-id)
:reason reason))
(defun ebox-selector--update-region (handle props)
"Apply PROPS to HANDLE's region through `ebox-region-update'."
(let* ((region-id (plist-get handle :region-id))
(ebox--region-update-buffer-hint (plist-get handle :buffer)))
(apply #'ebox-region-update region-id props)))
;;;###autoload
(defun ebox-selector-update-buffer (buffer selector &rest props)
"Apply PROPS to editable selector matches in BUFFER.
SELECTOR locates runtime nodes, then every editable match is updated through
`ebox-region-update'. Return a summary plist with match counts, structured
skips, and update reports."
(unless props
(user-error "ebox-selector: update requires at least one property"))
(ebox--with-render-gc
(let* ((resolved-buffer (ebox-selector--resolve-buffer buffer))
(matches (ebox-selector-query-buffer resolved-buffer selector))
editable skipped reports)
(unless matches
(user-error "ebox-selector: no matches for %S" selector))
(dolist (match matches)
(if (plist-get match :region-id)
(push match editable)
(push (ebox-selector--skip-handle match 'no-region) skipped)))
(setq editable (nreverse editable)
skipped (nreverse skipped))
(cond
((null editable)
(setq reports nil))
((= (length editable) 1)
(setq reports
(list (ebox-selector--update-region (car editable) props))))
(t
(ebox-incremental-begin-batch resolved-buffer)
(dolist (match editable)
(ebox-selector--update-region match props))
(setq reports
(list (ebox-incremental-flush resolved-buffer)))))
(list :selector selector
:matched (length matches)
:updated (length editable)
:skipped skipped
:reports reports))))
(provide 'ebox-selector)
;;; ebox-selector.el ends here

493
ebox-style.el Normal file
View File

@ -0,0 +1,493 @@
;;; ebox-style.el --- CSS-like style model for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns style property registration, shorthand expansion, computed style, and
;; dirty-effect classification. It does not own Emacs buffer text properties.
;;; Code:
(require 'cl-lib)
(cl-defstruct (ebox-style-property
(:constructor ebox-style-property-create))
name aliases initial inherited group parser compute dirty-kind signature
backend-mapper)
(defconst ebox-style--border-styles
'(none hidden dotted dashed solid double groove ridge inset outset)
"Valid CSS border-style keywords accepted by Ebox style parsing.")
(defun ebox-style--property-definitions ()
"Return registered CSS-like property definitions."
(list
(ebox-style-property-create
:name :color :group 'paint :dirty-kind 'paint :signature 'paint)
(ebox-style-property-create
:name :background-color :aliases '(:bgcolor)
:group 'paint :dirty-kind 'paint :signature 'paint)
(ebox-style-property-create
:name :border-color :group 'paint :dirty-kind 'paint :signature 'paint)
(ebox-style-property-create
:name :border-width :group 'geometry :dirty-kind 'geometry
:signature 'layout)
(ebox-style-property-create
:name :padding-block-start :aliases '(:padding-top)
:group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :padding-inline-end :aliases '(:padding-right)
:group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :padding-block-end :aliases '(:padding-bottom)
:group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :padding-inline-start :aliases '(:padding-left)
:group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :margin-block-start :aliases '(:margin-top)
:group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :margin-inline-end :aliases '(:margin-right)
:group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :margin-block-end :aliases '(:margin-bottom)
:group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :margin-inline-start :aliases '(:margin-left)
:group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :width :group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :height :group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :min-width :group 'geometry :dirty-kind 'geometry
:signature 'layout)
(ebox-style-property-create
:name :max-width :group 'geometry :dirty-kind 'geometry
:signature 'layout)
(ebox-style-property-create
:name :display :group 'structure :dirty-kind 'structure
:signature 'structure)
(ebox-style-property-create
:name :gap :group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :row-gap :group 'geometry :dirty-kind 'geometry :signature 'layout)
(ebox-style-property-create
:name :column-gap :group 'geometry :dirty-kind 'geometry
:signature 'layout)
(ebox-style-property-create
:name :flex-direction :group 'geometry :dirty-kind 'geometry
:signature 'layout)
(ebox-style-property-create
:name :flex-wrap :group 'geometry :dirty-kind 'geometry
:signature 'layout)
(ebox-style-property-create
:name :justify-content :group 'geometry :dirty-kind 'geometry
:signature 'layout)
(ebox-style-property-create
:name :align-items :group 'geometry :dirty-kind 'geometry
:signature 'layout)
(ebox-style-property-create
:name :align-content :group 'geometry :dirty-kind 'geometry
:signature 'layout)))
(defvar ebox-style--property-table nil
"Hash table from canonical property names and aliases to metadata.")
(defun ebox-style--ensure-property-table ()
"Return the canonical style property registry table."
(or ebox-style--property-table
(let ((table (make-hash-table :test 'eq)))
(dolist (property (ebox-style--property-definitions))
(puthash (ebox-style-property-name property) property table)
(dolist (alias (ebox-style-property-aliases property))
(puthash alias property table)))
(setq ebox-style--property-table table))))
(defun ebox-style-property (name)
"Return registered style metadata for canonical property or alias NAME."
(gethash name (ebox-style--ensure-property-table)))
(defun ebox-style-canonical-name (name)
"Return canonical CSS-like longhand property name for NAME."
(when-let ((property (ebox-style-property name)))
(ebox-style-property-name property)))
(defun ebox-style--put (plist key value)
"Return PLIST with KEY set to VALUE."
(plist-put plist key value))
(defun ebox-style--split-trbl (value)
"Split VALUE using CSS TRBL rules into top, right, bottom, left."
(cond
((null value) (list nil nil nil nil))
((atom value) (list value value value value))
((and (listp value) (keywordp (car value)))
(list (plist-get value :top) (plist-get value :right)
(plist-get value :bottom) (plist-get value :left)))
((listp value)
(pcase (length value)
(1 (list (nth 0 value) (nth 0 value) (nth 0 value) (nth 0 value)))
(2 (list (nth 0 value) (nth 1 value) (nth 0 value) (nth 1 value)))
(3 (list (nth 0 value) (nth 1 value) (nth 2 value) (nth 1 value)))
(_ (list (nth 0 value) (nth 1 value) (nth 2 value) (nth 3 value)))))
(t (list value value value value))))
(defun ebox-style--split-pair (value)
"Split VALUE into a start/end pair."
(cond
((null value) (list nil nil))
((atom value) (list value value))
((and (listp value) (= 1 (length value)))
(list (nth 0 value) (nth 0 value)))
((listp value) (list (nth 0 value) (nth 1 value)))
(t (list value value))))
(defun ebox-style--wsc-classify (item)
"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))))
(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)
(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))))
(defun ebox-style--atom-consp (list)
"Return non-nil when LIST is a strict dotted pair of atoms."
(and (consp list)
(atom (car list))
(cdr list)
(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)))
(defun ebox-style--expand-canonical-property (property value)
"Return canonical CSS-like longhand entries for PROPERTY and VALUE."
(pcase property
(:bgcolor (list :background-color (ebox-style--parse-color value)))
(:background-color
(list :background-color (ebox-style--parse-color value)))
(:color (list :color (ebox-style--parse-color value)))
(:padding
(pcase-let ((`(,top ,right ,bottom ,left)
(ebox-style--split-trbl value)))
(list :padding-block-start top
:padding-inline-end right
:padding-block-end bottom
:padding-inline-start left)))
(:padding-inline
(pcase-let ((`(,start ,end) (ebox-style--split-pair value)))
(list :padding-inline-start start :padding-inline-end end)))
(:padding-block
(pcase-let ((`(,start ,end) (ebox-style--split-pair value)))
(list :padding-block-start start :padding-block-end end)))
(:padding-top (list :padding-block-start value))
(:padding-right (list :padding-inline-end value))
(:padding-bottom (list :padding-block-end value))
(:padding-left (list :padding-inline-start value))
(:margin
(pcase-let ((`(,top ,right ,bottom ,left)
(ebox-style--split-trbl value)))
(list :margin-block-start top
:margin-inline-end right
:margin-block-end bottom
:margin-inline-start left)))
(:margin-inline
(pcase-let ((`(,start ,end) (ebox-style--split-pair value)))
(list :margin-inline-start start :margin-inline-end end)))
(:margin-block
(pcase-let ((`(,start ,end) (ebox-style--split-pair value)))
(list :margin-block-start start :margin-block-end end)))
(:margin-top (list :margin-block-start value))
(:margin-right (list :margin-inline-end value))
(:margin-bottom (list :margin-block-end value))
(:margin-left (list :margin-inline-start value))
(:border
(pcase-let ((`(,width ,style ,color) (ebox-style--split-wsc value)))
(append (when width (list :border-width width))
(when style (list :border-style style))
(when color
(list :border-color (ebox-style--parse-color color))))))
(:border-color
(list :border-color (ebox-style--parse-color value)))
(_
(if-let ((canonical (ebox-style-canonical-name property)))
(list canonical value)
nil))))
(defun ebox-style-expand-shorthands (plist)
"Expand PLIST to CSS-like canonical longhand properties."
(let (expanded)
(while plist
(let ((property (pop plist))
(value (pop plist)))
(setq expanded
(nconc expanded
(ebox-style--expand-canonical-property
property value)))))
expanded))
(defun ebox-style-compute (plist)
"Return computed canonical style for PLIST."
(let ((expanded (ebox-style-expand-shorthands plist))
computed)
(while expanded
(let ((property (pop expanded))
(value (pop expanded)))
(when (ebox-style-property property)
(setq computed
(ebox-style--put computed property value)))))
computed))
(defun ebox-style-dirty-kind (name)
"Return dirty kind for canonical property or alias NAME."
(when-let ((property (ebox-style-property name)))
(ebox-style-property-dirty-kind property)))
(defun ebox-style-signature (computed-style groups)
"Return deterministic signature for COMPUTED-STYLE filtered by GROUPS."
(let (entries)
(while computed-style
(let* ((property-name (pop computed-style))
(value (pop computed-style))
(property (ebox-style-property property-name))
(group (and property (ebox-style-property-signature property))))
(when (and property
(or (null groups)
(memq group groups)
(memq (ebox-style-property-group property) groups)))
(push (cons property-name value) entries))))
(setq entries
(sort entries
(lambda (a b)
(string< (symbol-name (car a))
(symbol-name (car b))))))
(apply #'append
(mapcar (lambda (entry)
(list (car entry) (cdr entry)))
entries))))
(defconst ebox-style-ebox-property-rules
'((:padding :expand (:padding-top :padding-right
:padding-bottom :padding-left)
:mode trbl)
(:padding-inline :expand (:padding-left :padding-right) :mode pair)
(:padding-block :expand (:padding-top :padding-bottom) :mode pair)
(:padding-top :to :padding-top-height :conv line)
(:padding-right :to :padding-right-pixel :conv pixel)
(:padding-bottom :to :padding-bottom-height :conv line)
(:padding-left :to :padding-left-pixel :conv pixel)
(:margin :expand (:margin-top :margin-right
:margin-bottom :margin-left)
:mode trbl)
(:margin-inline :expand (:margin-left :margin-right) :mode pair)
(:margin-block :expand (:margin-top :margin-bottom) :mode pair)
(:margin-top :to :margin-top-height :conv line)
(:margin-right :to :margin-right-pixel :conv pixel)
(:margin-bottom :to :margin-bottom-height :conv line)
(:margin-left :to :margin-left-pixel :conv pixel)
(:border :expand (:border-top :border-right
:border-bottom :border-left)
:mode same)
(:border-top :expand (:border-top-width
:border-top-style
:border-top-color)
:mode wsc)
(:border-right :expand (:border-right-width
:border-right-style
:border-right-color)
:mode wsc)
(:border-bottom :expand (:border-bottom-width
:border-bottom-style
:border-bottom-color)
:mode wsc)
(:border-left :expand (:border-left-width
:border-left-style
:border-left-color)
:mode wsc)
(:border-width :expand (:border-top-width :border-right-width
:border-bottom-width
:border-left-width)
:mode trbl)
(:border-style :expand (:border-top-style :border-right-style
:border-bottom-style
:border-left-style)
:mode trbl)
(:border-color :expand (:border-top-color :border-right-color
:border-bottom-color
:border-left-color)
:mode trbl)
(:border-top-width :to :border-top-p :conv bool)
(:border-top-style :to nil)
(: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-color :to :border-bottom-color :conv color)
(:border-left-width :to :border-left-pixel :conv pixel)
(:border-left-style :to nil)
(: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-color :to :border-right-color :conv color)
(:width :to :width :conv preferred-size)
(:min-width :to :min-width :conv min-size)
(:max-width :to :max-width :conv max-size)
(:height :to :height)
(:min-height :to :min-height)
(:max-height :to :max-height)
(: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-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.")
(defun ebox-style--negative-numeric-size-p (value)
"Return non-nil when VALUE is or starts with a negative number."
(or (and (numberp value) (< value 0))
(and (consp value)
(numberp (car value))
(< (car value) 0))))
(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))
('preferred-size
(ebox--normalize-horizontal-size-value
value ebox--preferred-size-keywords))
('min-size
(ebox--normalize-horizontal-size-value
value ebox--min-size-keywords))
('max-size
(ebox--normalize-horizontal-size-value
value ebox--max-size-keywords))
('line
(if (numberp value)
(progn
(when (< value 0)
(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)))
(defconst ebox-style--nonnegative-edge-longhands
'(: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-p :border-bottom-p)
"Engine edge longhands that cannot represent negative geometry.")
(defun ebox-style--validate-edge-longhands (plist)
"Return PLIST after rejecting negative normalized engine edges."
(let ((tail plist))
(while tail
(let ((property (pop tail))
(value (pop tail)))
(when (and (memq property ebox-style--nonnegative-edge-longhands)
(ebox-style--negative-numeric-size-p value))
(error "ebox: %S cannot be negative: %S" property value))))
plist))
(defun ebox-style--split-value (value mode n)
"Split VALUE according to MODE for N targets."
(pcase mode
('trbl (ebox-style--split-trbl value))
('pair (ebox-style--split-pair value))
('wsc (ebox-style--split-wsc value))
('same (make-list n value))
(_ (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)))
(defun ebox-style-expand-ebox-property (property value)
"Expand public Ebox PROPERTY with VALUE to engine longhands."
(let ((rule (ebox-style--get-ebox-rule property)))
(cond
((null rule) (list property value))
((plist-get rule :expand)
(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)))
((plist-member rule :to)
(let ((target (plist-get rule :to))
(converter (plist-get rule :conv)))
(if target
(list target (ebox-style--convert value converter))
nil)))
(t (list property value)))))
(defun ebox-style-expand-ebox-plist (plist)
"Expand PLIST using Ebox compatibility property rules."
(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)))
(provide 'ebox-style)
;;; ebox-style.el ends here

752
ebox-tree.el Normal file
View File

@ -0,0 +1,752 @@
;;; ebox-tree.el --- Element tree model for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns runtime identity, display accessors, child traversal, parent paths, and
;; flex participation metadata. It does not own layout measurement or buffer
;; editing.
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(require 'ebox-style)
(defconst ebox--default-display '(block flow)
"Default CSS-like display for a plain Ebox box.")
(defvar ebox--node-region-ids-cache nil
"Dynamic snapshot-detail-local node -> region ids cache.
This is an optimization for snapshot capture, not buffer runtime state.")
(defun ebox-tree-computed-display (node)
"Return NODE's canonical CSS-like display pair."
(or (and (listp node) (plist-get node :display))
(pcase (and (listp node) (plist-get node :ebox-type))
('box '(block flow))
('concat '(block row))
('stack '(block column))
('flex '(block flex))
('grid '(block grid))
('flex-item '(block flow))
(_ ebox--default-display))))
(defun ebox-tree-display-outer (node)
"Return NODE's outer display."
(car (ebox-tree-computed-display node)))
(defun ebox-tree-display-inner (node)
"Return NODE's inner display."
(cadr (ebox-tree-computed-display node)))
(defun ebox-tree-formatting-context-p (node)
"Return non-nil when NODE establishes a child layout context."
(memq (ebox-tree-display-inner node) '(row column flex grid)))
(defun ebox-tree-node-selector-type (node)
"Return NODE's CSS-like selector type symbol."
(pcase (and (listp node) (plist-get node :ebox-type))
('box 'box)
('concat 'row)
('stack 'column)
('flex 'flex)
('grid 'grid)
('flex-item 'item)
(_ nil)))
(defun ebox-tree--metadata-string (value)
"Return VALUE normalized to a selector metadata string."
(cond
((null value) nil)
((symbolp value) (symbol-name value))
((stringp value) value)
(t (format "%s" value))))
(defun ebox-tree-node-id (node)
"Return NODE's selector id string from `:id', or nil."
(and (listp node)
(ebox-tree--metadata-string (plist-get node :id))))
(defun ebox-tree-node-classes (node)
"Return NODE's selector class strings from `:class'."
(when (listp node)
(let ((class (plist-get node :class)))
(cond
((null class) nil)
((listp class)
(delq nil (mapcar #'ebox-tree--metadata-string class)))
(t (list (ebox-tree--metadata-string class)))))))
(defun ebox-tree-node-key (node)
"Return NODE's selector key string from `:key', or nil."
(and (listp node)
(ebox-tree--metadata-string (plist-get node :key))))
(defun ebox-tree--children-raw (node)
"Return NODE's direct child nodes without assigning runtime ids."
(pcase (and (listp node) (plist-get node :ebox-type))
('box
(delq nil (list (plist-get node :ebox-content-node))))
('concat
(ebox-tree-layout-children node))
('stack
(ebox-tree-layout-children node))
('flex
(append (when-let ((box (plist-get node :box)))
(list box))
(plist-get node :children)))
('grid
(append (when-let ((box (plist-get node :box)))
(list box))
(plist-get node :children)))
('flex-item
(delq nil (list (plist-get node :node))))
(_ nil)))
(defun ebox-tree-node-children (node)
"Return NODE's direct declarative children without mutating them.
The returned nodes remain owned by their tree. Callers may inspect them but
must not modify a published runtime tree through this accessor."
(ebox-tree--children-raw node))
(defun ebox-tree-layout-children (node)
"Return layout container children for NODE in render order.
New row/column containers store flat `:children'. Legacy `ebox-concat' and
`ebox-stack' nodes may still carry `:left'/`:right' or `:top'/`:bottom'."
(or (plist-get node :children)
(pcase (and (listp node) (plist-get node :ebox-type))
('concat
(delq nil (list (plist-get node :left)
(plist-get node :right))))
('stack
(delq nil (list (plist-get node :top)
(plist-get node :bottom))))
(_ nil))))
(defun ebox-tree--replace-direct-child (child replacements)
"Return CHILD's identity replacement from REPLACEMENTS, when present.
REPLACEMENTS is an alist whose keys are compared with `eq'. Nil is not a
child and is therefore never used as a replacement key."
(if-let ((replacement (and child (assq child replacements))))
(cdr replacement)
child))
(defun ebox-tree--replace-direct-child-list (children replacements)
"Replace CHILDREN found by identity in REPLACEMENTS.
Return CHILDREN itself when no child changes. Otherwise return one new list
whose untouched child objects remain shared. A nil replacement removes the
matched child from the returned sequence."
(if (null replacements)
children
(let (changed replaced)
(dolist (child children)
(let ((replacement
(ebox-tree--replace-direct-child child replacements)))
(unless (eq replacement child)
(setq changed t))
(when replacement
(push replacement replaced))))
(if changed
(nreverse replaced)
children))))
(defun ebox-tree--replace-direct-child-property
(node property replacements)
"Replace NODE's direct child PROPERTY from REPLACEMENTS.
NODE must already be a shallow copy. Return the possibly updated plist."
(if (plist-member node property)
(let* ((child (plist-get node property))
(replacement
(ebox-tree--replace-direct-child child replacements)))
(if (eq replacement child)
node
(plist-put node property replacement)))
node))
(defun ebox-tree-copy-with-direct-child-replacements (node replacements)
"Return a shallow copy of NODE with direct child REPLACEMENTS applied.
REPLACEMENTS is an alist of (OLD-CHILD . NEW-CHILD) pairs. OLD-CHILD is
matched by object identity, not structural equality. Every supported child
slot is updated, including the legacy aliases retained by binary concat and
stack nodes. Multiple replacements for one parent are applied to the same
single parent copy. Untouched child objects remain `eq' to their originals.
A nil NEW-CHILD removes a matched child from a flat `:children' list and sets
a matched scalar child slot to nil. With no replacements, the node plist is
still copied while every nested value remains shared."
(unless (and (listp node) (not (stringp node)))
(error "Ebox can only path-copy a node plist, got %S" node))
(let* ((type (plist-get node :ebox-type))
(copy (copy-sequence node))
(scalar-properties
(pcase type
('box '(:ebox-content-node))
('concat '(:left :right))
('stack '(:top :bottom))
('flex '(:box))
('grid '(:box))
('flex-item '(:node)))))
(dolist (property scalar-properties)
(setq copy
(ebox-tree--replace-direct-child-property
copy property replacements)))
(when (and (memq type '(concat stack flex grid))
(plist-member copy :children))
(let* ((children (plist-get copy :children))
(replaced
(ebox-tree--replace-direct-child-list children replacements)))
(unless (eq replaced children)
(setq copy (plist-put copy :children replaced)))))
copy))
(defun ebox-tree-copy-node-structure (root)
"Copy every Ebox node below ROOT while preserving opaque leaf values.
The returned tree owns distinct node plists and child lists. Values such as
keymaps, callbacks, Host references, and surface-property payloads retain their
original object identity."
(cl-labels
((copy-node
(node)
(if (or (not (listp node)) (stringp node))
node
(ebox-tree-copy-with-direct-child-replacements
node
(mapcar (lambda (child)
(cons child (copy-node child)))
(ebox-tree--children-raw node))))))
(copy-node root)))
(defun ebox-tree--runtime-identity-node-shell
(node node-id-set region-id-set)
"Return NODE's identity-only shell.
Record its identities in NODE-ID-SET and REGION-ID-SET."
(let ((node-id (plist-get node :node-id))
(type (plist-get node :ebox-type))
shell)
(unless node-id
(error "Ebox runtime identity snapshot found a node without identity"))
(puthash node-id t node-id-set)
(setq shell (list :ebox-type type :node-id node-id))
(when (plist-member node :key)
(setq shell (plist-put shell :key (plist-get node :key))))
(when (eq type 'box)
(let ((region-id (plist-get node :region-id)))
(unless region-id
(error "Ebox runtime identity snapshot found a box without region"))
(puthash region-id t region-id-set)
(setq shell (plist-put shell :region-id region-id))))
shell))
(defun ebox-tree--runtime-identity-skeleton
(node node-id-set region-id-set)
"Return identity-only NODE shape.
Record its identities in NODE-ID-SET and REGION-ID-SET."
(let ((shell
(ebox-tree--runtime-identity-node-shell
node node-id-set region-id-set)))
(pcase (plist-get node :ebox-type)
('box
(when-let ((child (plist-get node :ebox-content-node)))
(plist-put shell :ebox-content-node
(ebox-tree--runtime-identity-skeleton
child node-id-set region-id-set))))
((or 'concat 'stack)
(plist-put shell :children
(mapcar
(lambda (child)
(ebox-tree--runtime-identity-skeleton
child node-id-set region-id-set))
(ebox-tree-layout-children node))))
((or 'flex 'grid)
(when-let ((box (plist-get node :box)))
(plist-put shell :box
(ebox-tree--runtime-identity-skeleton
box node-id-set region-id-set)))
(plist-put shell :children
(mapcar
(lambda (child)
(ebox-tree--runtime-identity-skeleton
child node-id-set region-id-set))
(plist-get node :children))))
('flex-item
(when-let ((child (plist-get node :node)))
(plist-put shell :node
(ebox-tree--runtime-identity-skeleton
child node-id-set region-id-set)))))
shell))
(defun ebox-tree-runtime-identity-snapshot (root)
"Return ROOT's payload-free runtime identity snapshot.
The result owns only Ebox type, key, child shape, node identity, and box
region identity. It never retains content, callbacks, Host references,
surface payloads, render caches, or scroll state."
(let ((node-id-set (make-hash-table :test 'equal))
(region-id-set (make-hash-table :test 'equal)))
(let ((skeleton
(ebox-tree--runtime-identity-skeleton
root node-id-set region-id-set)))
(list :root skeleton
:node-id-set node-id-set
:region-id-set region-id-set
:node-count (hash-table-count node-id-set)))))
(defun ebox-tree-children (node)
"Return NODE's runtime child nodes.
Renderable child nodes are assigned stable runtime ids before returning."
(let ((children (ebox-tree--children-raw node)))
(dolist (child children)
(when (and (listp child) (not (stringp child)))
(ebox--ensure-node-id child)))
children))
(defun ebox-tree-validate-host-refs (root)
"Reject duplicate non-nil `:host-ref' values below ROOT and return ROOT.
Host references are root-global opaque metadata and are compared with `equal'."
(let ((host-refs (make-hash-table :test 'equal))
(seen (make-hash-table :test 'eq))
(active (make-hash-table :test 'eq)))
(cl-labels
((visit (node)
(when (and (listp node) (not (stringp node)))
;; Let the complete declarative validator report cycles. This
;; focused preflight is also used by initial buffer mounting,
;; where it must remain finite for malformed graphs.
(unless (gethash node active)
(when-let ((host-ref (plist-get node :host-ref)))
(let ((count (hash-table-count host-refs)))
(puthash host-ref t host-refs)
(when (= count (hash-table-count host-refs))
(error
"Ebox declarative tree uses duplicate host ref %S"
host-ref))))
(unless (gethash node seen)
(puthash node t seen)
(puthash node t active)
(dolist (child (ebox-tree--children-raw node))
(visit child))
(remhash node active))))))
(visit root))
root))
(defun ebox-tree-validate-declarative-root (root)
"Validate ROOT for one declarative runtime commit.
The runtime tree must be a proper tree: a node object cannot appear in two
locations, cycles are rejected, explicit sibling keys must be unique, and
non-nil host references must be unique across the root. Keys are sibling-local
and all explicit identities are compared with `equal'. Return ROOT on success."
(ebox-tree-validate-host-refs root)
(let ((seen (make-hash-table :test 'eq))
(active (make-hash-table :test 'eq)))
(cl-labels
((visit (node)
(when (and (listp node) (not (stringp node)))
(when (gethash node active)
(error "Ebox declarative tree contains a cycle"))
(when (gethash node seen)
(error "Ebox declarative tree reuses one node object"))
(puthash node t seen)
(puthash node t active)
(let ((keys (make-hash-table :test 'equal)))
(dolist (child (ebox-tree--children-raw node))
(when-let ((key (and (listp child)
(not (stringp child))
(plist-get child :key))))
(when (gethash key keys)
(error "Ebox declarative siblings use duplicate key %S"
key))
(puthash key t keys))
(visit child)))
(remhash node active))))
(visit root))
root))
(defun ebox-tree-clear-runtime-identities (root)
"Clear Ebox-owned runtime identity fields below ROOT and return ROOT."
(cl-labels
((visit (node)
(when (and (listp node) (not (stringp node)))
(when (plist-member node :node-id)
(plist-put node :node-id nil))
(when (eq (plist-get node :ebox-type) 'box)
(when (plist-member node :region-id)
(plist-put node :region-id nil)))
(dolist (child (ebox-tree--children-raw node))
(visit child)))))
(visit root))
root)
(defconst ebox-tree--runtime-source-keys
'(:node-id :region-id :render-cache)
"Runtime-owned plist keys excluded from declarative source signatures.")
(defconst ebox-tree--child-source-keys
'(:children :left :right :top :bottom :node :box :ebox-content-node)
"Child references excluded from node-local declarative signatures.")
(defconst ebox-tree--excluded-source-keys
(append ebox-tree--runtime-source-keys ebox-tree--child-source-keys)
"Runtime and child keys excluded from node-local source comparison.")
(defconst ebox-tree-metadata-source-keys
'(:key :id :class :host-ref)
"Declarative metadata keys that do not directly change rendered text.")
(defun ebox-tree--plist-keys (plist)
"Return keys from PLIST in source order."
(cl-loop for (key _value) on plist by #'cddr collect key))
(defun ebox-tree-node-local-source-signature (node)
"Return NODE's non-recursive declarative source signature.
Runtime identities, caches, and child pointers are excluded. Child order is
compared independently through stable runtime ids, so a leaf change does not
mark every ancestor dirty."
(let ((plist node)
signature)
(while plist
(let ((key (pop plist))
(value (pop plist)))
(unless (memq key ebox-tree--excluded-source-keys)
(push key signature)
(push value signature))))
(nreverse signature)))
(defun ebox-tree--next-local-source-entry (plist)
"Return PLIST's next node-local source entry, skipping excluded keys."
(while (and plist
(memq (car plist) ebox-tree--excluded-source-keys))
(setq plist (cddr plist)))
plist)
(defun ebox-tree--local-source-sequence-equal-p (old new)
"Return non-nil when filtered OLD and NEW source sequences are equal."
(let ((old (ebox-tree--next-local-source-entry old))
(new (ebox-tree--next-local-source-entry new))
equal-p)
(setq equal-p t)
(while (and equal-p old new)
(setq equal-p
(and (eq (car old) (car new))
(equal-including-properties (cadr old) (cadr new))))
(setq old (ebox-tree--next-local-source-entry (cddr old))
new (ebox-tree--next-local-source-entry (cddr new))))
(and equal-p (null old) (null new))))
(defun ebox-tree-node-local-changed-keys (old new)
"Return ordered declarative keys whose node-local values differ.
OLD and NEW are source nodes with the same retained runtime identity."
(unless (ebox-tree--local-source-sequence-equal-p old new)
(let (seen changed)
(dolist (source (list old new))
(let ((plist source))
(while plist
(let ((key (pop plist)))
(pop plist)
(unless (or (memq key ebox-tree--excluded-source-keys)
(memq key seen))
(push key seen)
(let ((old-entry (plist-member old key))
(new-entry (plist-member new key)))
(unless (and (eq (not (null old-entry))
(not (null new-entry)))
(equal-including-properties
(cadr old-entry) (cadr new-entry)))
(push key changed))))))))
(nreverse changed))))
(defun ebox-tree-transfer-runtime-identity (old new)
"Transfer retained runtime identity from matching OLD to NEW.
Callers are responsible for deciding whether OLD and NEW are the same keyed
or positional node. A retained box keeps its node id, public region id, and
host-owned scroll position so buffer properties, interaction state, and the
candidate runtime index agree."
(if (and old
(listp old)
(listp new)
(eq (plist-get old :ebox-type)
(plist-get new :ebox-type)))
(progn
(plist-put new :node-id (ebox--ensure-node-id old))
(when (eq (plist-get new :ebox-type) 'box)
(plist-put new :region-id (ebox--ensure-region-id old))
;; Omitted scroll state is uncontrolled host state. `ebox-create'
;; records explicit :scroll-offset input, which gives declarative
;; callers a controlled reset/jump without a second region-update
;; transaction.
(when (and (not (plist-get new
:ebox-scroll-offset-controlled-p))
(plist-member old :scroll-offset))
(plist-put new :scroll-offset (plist-get old :scroll-offset)))))
(plist-put new :node-id (ebox--next-runtime-node-id)))
new)
(defun ebox-tree-reconcile-runtime (old new)
"Transfer stable keyed and positional runtime identity from OLD to NEW."
(cl-labels
((node-key
(node)
(when (and (listp node) (plist-member node :key))
(plist-get node :key)))
(same-type-p
(old-node new-node)
(and (listp old-node)
(listp new-node)
(eq (plist-get old-node :ebox-type)
(plist-get new-node :ebox-type))))
(keyed-children
(children)
(let ((table (make-hash-table :test 'equal)))
(dolist (child children table)
(when-let ((key (node-key child)))
(puthash key child table)))))
(match-child
(old-children old-keyed new-child index)
(if-let ((key (node-key new-child)))
(let ((old-child (gethash key old-keyed)))
(and (same-type-p old-child new-child) old-child))
(let ((old-child (nth index old-children)))
(and (not (node-key old-child))
(same-type-p old-child new-child)
old-child))))
(reconcile
(old-node new-node)
(when (and (listp new-node) (not (stringp new-node)))
(ebox-tree-transfer-runtime-identity old-node new-node)
(let* ((old-children
(and old-node (ebox-tree--children-raw old-node)))
(new-children (ebox-tree--children-raw new-node))
(old-keyed (keyed-children old-children)))
(cl-loop for new-child in new-children
for index from 0
do (reconcile
(match-child
old-children old-keyed new-child index)
new-child))))))
(reconcile old new))
new)
(defun ebox-tree-flex-direct-participation-props (node)
"Return raw flex participation properties stored directly on NODE."
(let (props)
(when (listp node)
(dolist (key ebox--flex-item-prop-keys)
(when (plist-member node key)
(setq props (append props (list key (plist-get node key)))))))
props))
(defun ebox-tree-flex-item-source-node (node)
"Return the renderable source node for a flex item candidate."
(if (and (listp node) (eq (plist-get node :ebox-type) 'flex-item))
(plist-get node :node)
node))
(defun ebox-tree-flex-participation-props (node)
"Return raw flex item participation props attached to NODE."
(cond
((and (listp node) (eq (plist-get node :ebox-type) 'flex-item))
(append (plist-get node :props)
(ebox-tree-flex-direct-participation-props
(plist-get node :node))))
((listp node)
(ebox-tree-flex-direct-participation-props node))
(t nil)))
(defun ebox-tree-node-all-region-ids (node)
"Return every box region id contained by NODE.
Unlike `ebox-region-ids', this internal helper includes flex wrapper boxes
and their children because incremental replacement owns the rendered subtree,
not only the public update ids."
(cond
((stringp node) (ebox--string-region-ids node))
((not (listp node)) nil)
(t
(let* ((missing (make-symbol "missing"))
(cached (and ebox--node-region-ids-cache
(gethash node ebox--node-region-ids-cache missing))))
(if (and ebox--node-region-ids-cache
(not (eq cached missing)))
cached
(let ((region-ids
(pcase (plist-get node :ebox-type)
('box
(cons (ebox--ensure-region-id node)
(ebox-tree-node-all-region-ids
(plist-get node :ebox-content-node))))
('concat
(apply #'append
(mapcar #'ebox-tree-node-all-region-ids
(ebox-tree-layout-children node))))
('stack
(apply #'append
(mapcar #'ebox-tree-node-all-region-ids
(ebox-tree-layout-children node))))
('flex
(append
(when-let ((box (plist-get node :box)))
(list (ebox--ensure-region-id box)))
(apply #'append
(mapcar #'ebox-tree-node-all-region-ids
(plist-get node :children)))))
('grid
(append
(when-let ((box (plist-get node :box)))
(list (ebox--ensure-region-id box)))
(apply #'append
(mapcar #'ebox-tree-node-all-region-ids
(plist-get node :children)))))
('flex-item
(ebox-tree-node-all-region-ids (plist-get node :node)))
(_ nil))))
(when ebox--node-region-ids-cache
(puthash node region-ids ebox--node-region-ids-cache))
region-ids))))))
(defun ebox-tree-node-paint-conflict-p (node paint-key &optional inherited)
"Return non-nil when a box below NODE declares PAINT-KEY itself.
Such a descendant is the nearest paint source for its own subtree, so
a cheap ancestor face patch over NODE's spans would clobber it.
INHERITED is non-nil while walking below the owning node; the owner's
own declaration is never a conflict. Pre-rendered string content
cannot be inspected and reports no conflict."
(cond
((not (listp node)) nil)
(t
(pcase (plist-get node :ebox-type)
('box
(or (and inherited (plist-get node paint-key) t)
(ebox-tree-node-paint-conflict-p
(plist-get node :ebox-content-node) paint-key t)))
('concat
(cl-some (lambda (child)
(ebox-tree-node-paint-conflict-p child paint-key t))
(ebox-tree-layout-children node)))
('stack
(cl-some (lambda (child)
(ebox-tree-node-paint-conflict-p child paint-key t))
(ebox-tree-layout-children node)))
('flex
(or (and inherited
(when-let ((box (plist-get node :box)))
(and (plist-get box paint-key) t)))
(cl-some (lambda (child)
(ebox-tree-node-paint-conflict-p child paint-key t))
(plist-get node :children))))
('grid
(or (and inherited
(when-let ((box (plist-get node :box)))
(and (plist-get box paint-key) t)))
(cl-some (lambda (child)
(ebox-tree-node-paint-conflict-p child paint-key t))
(plist-get node :children))))
('flex-item
(ebox-tree-node-paint-conflict-p
(plist-get node :node) paint-key inherited))
(_ nil)))))
(defun ebox-tree-node-path-to-region (node region-id)
"Return renderable dirty path from REGION-ID's node up to NODE.
The returned list is ordered from the smallest renderable owner to the root
candidate. Flex wrapper boxes are represented by their flex node because the
wrapper's visible content is produced by the flex renderer."
(cond
((or (stringp node) (not (listp node))) nil)
(t
(pcase (plist-get node :ebox-type)
('box
(or (when (equal (ebox-get node :region-id) region-id)
(list node))
(when-let ((path (ebox-tree-node-path-to-region
(plist-get node :ebox-content-node)
region-id)))
(append path (list node)))))
('concat
(catch 'found
(dolist (child (ebox-tree-layout-children node))
(when-let ((path (ebox-tree-node-path-to-region child region-id)))
(throw 'found (append path (list node)))))
nil))
('stack
(catch 'found
(dolist (child (ebox-tree-layout-children node))
(when-let ((path (ebox-tree-node-path-to-region child region-id)))
(throw 'found (append path (list node)))))
nil))
('flex
(cond
((and (plist-get node :box)
(equal (ebox-get (plist-get node :box) :region-id) region-id))
(list node))
(t
(catch 'found
(dolist (child (plist-get node :children))
(when-let ((path (ebox-tree-node-path-to-region child region-id)))
(throw 'found (append path (list node)))))
nil))))
('grid
(cond
((and (plist-get node :box)
(equal (ebox-get (plist-get node :box) :region-id) region-id))
(list node))
(t
(catch 'found
(dolist (child (plist-get node :children))
(when-let ((path (ebox-tree-node-path-to-region child region-id)))
(throw 'found (append path (list node)))))
nil))))
('flex-item
(ebox-tree-node-path-to-region (plist-get node :node) region-id))
(_ nil)))))
(defun ebox-tree-node-visible-overflow-p (node)
"Return non-nil when NODE contains visible overflow that cannot be span patched."
(cond
((or (stringp node) (not (listp node))) nil)
(t
(pcase (plist-get node :ebox-type)
('box
(or (ebox--box-visible-overflow-p node)
(ebox-tree-node-visible-overflow-p
(plist-get node :ebox-content-node))))
('concat
(cl-some #'ebox-tree-node-visible-overflow-p
(ebox-tree-layout-children node)))
('stack
(cl-some #'ebox-tree-node-visible-overflow-p
(ebox-tree-layout-children node)))
('flex
(or (and (plist-get node :box)
(ebox--box-visible-overflow-p (plist-get node :box)))
(cl-some #'ebox-tree-node-visible-overflow-p
(plist-get node :children))))
('grid
(or (and (plist-get node :box)
(ebox--box-visible-overflow-p (plist-get node :box)))
(cl-some #'ebox-tree-node-visible-overflow-p
(plist-get node :children))))
('flex-item
(ebox-tree-node-visible-overflow-p (plist-get node :node)))
(_ nil)))))
(defalias 'ebox--computed-display #'ebox-tree-computed-display)
(defalias 'ebox--display-outer #'ebox-tree-display-outer)
(defalias 'ebox--display-inner #'ebox-tree-display-inner)
(defalias 'ebox--formatting-context-p #'ebox-tree-formatting-context-p)
(defalias 'ebox--node-selector-type #'ebox-tree-node-selector-type)
(defalias 'ebox--layout-children #'ebox-tree-layout-children)
(defalias 'ebox--node-children #'ebox-tree-children)
(defalias 'ebox--flex-direct-participation-props
#'ebox-tree-flex-direct-participation-props)
(defalias 'ebox--flex-item-source-node #'ebox-tree-flex-item-source-node)
(defalias 'ebox--flex-participation-props
#'ebox-tree-flex-participation-props)
(defalias 'ebox--node-all-region-ids #'ebox-tree-node-all-region-ids)
(defalias 'ebox--node-paint-conflict-p #'ebox-tree-node-paint-conflict-p)
(defalias 'ebox--node-path-to-region #'ebox-tree-node-path-to-region)
(defalias 'ebox--node-visible-overflow-p #'ebox-tree-node-visible-overflow-p)
(provide 'ebox-tree)
;;; ebox-tree.el ends here

7911
ebox.el Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
;;; ebox-basic-examples.el --- Basic Ebox examples -*- lexical-binding: t; -*-
;;; Commentary:
;; Load this file after adding the repository root to `load-path', then run
;; `M-x ebox-basic-examples-open'. The example intentionally uses `:wrap-mode
;; kp' to exercise the optional ekp-backed wrapping path while still loading
;; cleanly when ekp is unavailable.
;;; Code:
(require 'ebox)
(defconst ebox-basic-examples-long-text
"作为可嵌入 Emacs buffer 的文本 UI 组件Ebox 负责把普通字符串渲染成稳定的像素宽度盒子。这个示例混合中文、English words 和标点,用来验证 CJK 换行、kp wrap、padding、margin 与 border 在同一个盒子里的组合效果。")
(defun ebox-basic-examples-build ()
"Return the basic example layout node."
(ebox-build
`(box :content ,ebox-basic-examples-long-text
:margin 2
:margin-top 1
:padding (0 2)
:width (410)
:border "gray"
:wrap-mode kp)))
(defun ebox-basic-examples-render ()
"Render the basic example to a propertized string."
(ebox-render (ebox-basic-examples-build)))
;;;###autoload
(defun ebox-basic-examples-open ()
"Open the basic Ebox example buffer."
(interactive)
(ebox-display-buffer "*ebox-basic-examples*"
(ebox-basic-examples-build)))
(provide 'ebox-basic-examples)
;;; ebox-basic-examples.el ends here

View File

@ -0,0 +1,7 @@
(box :content "Playground file"
:box-sizing border-box
:padding (1 (12))
:border t
:border-color "#2563EB"
:bgcolor "#EFF6FF"
:color "#1E3A8A")

View File

@ -0,0 +1,45 @@
(column
(box :content "Ebox comprehensive DSL showcase\nThe outer sections omit :width so Playground viewport sizing drives the layout."
:padding (1 (16)) :border "#334155"
:bgcolor "#F8FAFC" :color "#0F172A" :text-align center)
(spacer :height 1)
(box :content "Width rule in this file\nContainer sections use auto width. Fixed widths remain only where the example demonstrates exact item sizing, basis, min-width, or max-width."
:padding (0 (14)) :border "#64748B"
:bgcolor "#F8FAFC" :color "#111827" :wrap-mode word)
(spacer :height 1)
(flex
:margin (1 0 0 1) :padding (1 (8))
:border "#475569" :bgcolor "#F8FAFC"
:flex-flow (row wrap)
:justify-content space-between :align-items center
:align-content flex-start :row-gap 1 :column-gap (12)
(item
:order -1 :flex-basis (270) :align-self flex-start
(box :content "item order -1\n:flex-basis 270\nalign-self start"
:width (270) :padding (0 (8))
:border "#BE185D" :bgcolor "#FCE7F3" :color "#831843"
:wrap-mode word))
(item
:flex (1 1 (220))
(box :content "item :flex (1 1 220)\ngrow shrink basis"
:width (220) :height 4
:padding (0 (8)) :border "#1D4ED8"
:bgcolor "#DBEAFE" :color "#111827" :wrap-mode word))
(item
:flex-grow 2 :flex-shrink 1 :flex-basis (240)
:align-self center
(box :content "item grow 2\nshrink 1\nbasis 240"
:width (240) :height 4 :padding (0 (8))
:border "#B45309" :bgcolor "#FEF3C7" :color "#78350F"
:wrap-mode word))
(item :flex-shrink 3
:flex-basis (190)
:align-self flex-end
(box :content "item shrink 3\nbasis 190\nalign-self flex-end"
:width (190) :height 4 :padding (0 (8))
:border "#15803D" :bgcolor "#DCFCE7" :color "#14532D"
:wrap-mode word))))

View File

@ -0,0 +1,463 @@
(column ;; :height (viewport-height)
:width (viewport)
(box :content "Ebox CSS Flex Reference\nA modern, self-explaining DSL gallery for CSS flex container and item properties."
:padding (1 (24)) :border "#2F3437"
:bgcolor "#FAF7F0" :color "#1F2328" :text-align center)
(spacer :height 1)
(box :content "Container property: :flex-direction\nMain axis selection. Reverse values swap main-start and main-end while keeping source order intact."
:padding (0 (18)) :border "#D97757"
:bgcolor "#FFF5ED" :color "#3D2B1F")
(flex :flex-flow (row wrap) :row-gap 1 :column-gap (12) :padding (0 (12))
:border "#D97757" :border-top-p nil
:bgcolor "#FFF8F2"
(column
(box :content "row" :width (220) :padding (0 (10)) :border "#D97757"
:bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :width (220) :height 4 :padding (0 (8)) :border "#D97757"
:border-top-p nil
:bgcolor "#FFF8F2" :flex-direction row :column-gap (10) :align-items center
(item (box :content "A" :width (42) :height 1 :bgcolor "#F7C8B3" :text-align center))
(item (box :content "B" :width (42) :height 1 :bgcolor "#F0A88F" :text-align center))
(item (box :content "C" :width (42) :height 1 :bgcolor "#D97757" :color "#FFFFFF" :text-align center))))
(column
(box :content "row-reverse" :width (220) :padding (0 (10)) :border "#D97757"
:bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :width (220) :height 4 :padding (0 (8)) :border "#D97757"
:border-top-p nil
:bgcolor "#FFF8F2" :flex-direction row-reverse :column-gap (10) :align-items center
(item (box :content "A" :width (42) :height 1 :bgcolor "#F7C8B3" :text-align center))
(item (box :content "B" :width (42) :height 1 :bgcolor "#F0A88F" :text-align center))
(item (box :content "C" :width (42) :height 1 :bgcolor "#D97757" :color "#FFFFFF" :text-align center))))
(column
(box :content "column" :width (220) :padding (0 (10)) :border "#D97757"
:bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :width (220) :height 6 :padding (0 (8)) :border "#D97757"
:border-top-p nil
:bgcolor "#FFF8F2" :flex-direction column :row-gap 1 :align-items stretch
(item (box :content "A" :height 1 :bgcolor "#F7C8B3" :text-align center))
(item (box :content "B" :height 1 :bgcolor "#F0A88F" :text-align center))
(item (box :content "C" :height 1 :bgcolor "#D97757" :color "#FFFFFF" :text-align center))))
(column
(box :content "column-reverse" :width (220) :padding (0 (10)) :border "#D97757"
:bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :width (220) :height 6 :padding (0 (8)) :border "#D97757"
:border-top-p nil
:bgcolor "#FFF8F2" :flex-direction column-reverse :row-gap 1 :align-items stretch
(item (box :content "A" :height 1 :bgcolor "#F7C8B3" :text-align center))
(item (box :content "B" :height 1 :bgcolor "#F0A88F" :text-align center))
(item (box :content "C" :height 1 :bgcolor "#D97757" :color "#FFFFFF" :text-align center)))))
(spacer :height 1)
(box :content "Container property: :flex-wrap\nLine creation. nowrap keeps one flex line; wrap creates new lines; wrap-reverse stacks lines from the opposite cross side."
:padding (0 (18)) :border "#5E7F6A"
:bgcolor "#F3FAF4" :color "#24352A")
(flex :flex-flow (row wrap) :row-gap 1 :column-gap (12) :padding (0 (12))
:border "#5E7F6A" :border-top-p nil
:bgcolor "#F8FCF8"
(column
(box :content "nowrap" :max-width (293) :padding (0 (10)) :border "#5E7F6A"
:bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :max-width (293) :padding (0 (8)) :border "#5E7F6A"
:border-top-p nil
:bgcolor "#F8FCF8" :flex-wrap nowrap :column-gap (8) :align-items center
(item :flex-shrink 1 (box :content "A" :width (92) :height 1 :bgcolor "#DCEBDD" :text-align center))
(item :flex-shrink 1 (box :content "B" :width (92) :height 1 :bgcolor "#BFD6C2" :text-align center))
(item :flex-shrink 1 (box :content "C" :width (92) :height 1 :bgcolor "#8FAF95" :text-align center))))
(column
(box :content "wrap" :max-width (293) :padding (0 (10)) :border "#5E7F6A"
:bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :max-width (293) :padding (0 (8)) :border "#5E7F6A"
:border-top-p nil
:bgcolor "#F8FCF8" :flex-wrap wrap :column-gap (8) :row-gap 1
(item :flex-shrink 0 (box :content "A 118" :width (118) :height 1 :bgcolor "#DCEBDD" :text-align center))
(item :flex-shrink 0 (box :content "B 118" :width (118) :height 1 :bgcolor "#BFD6C2" :text-align center))
(item :flex-shrink 0 (box :content "C 118" :width (118) :height 1 :bgcolor "#8FAF95" :text-align center))))
(column
(box :content "wrap-reverse" :max-width (293) :padding (0 (10)) :border "#5E7F6A"
:bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :max-width (293) :padding (0 (8)) :border "#5E7F6A"
:border-top-p nil
:bgcolor "#F8FCF8" :flex-wrap wrap-reverse :column-gap (8) :row-gap 1
(item :flex-shrink 0 (box :content "A 118" :width (118) :height 1 :bgcolor "#DCEBDD" :text-align center))
(item :flex-shrink 0 (box :content "B 118" :width (118) :height 1 :bgcolor "#BFD6C2" :text-align center))
(item :flex-shrink 0 (box :content "C 118" :width (118) :height 1 :bgcolor "#8FAF95" :text-align center)))))
(spacer :height 1)
(box :content "Container property: :flex-flow\nShorthand for direction and wrapping. These examples use one property to set both behaviors."
:padding (0 (18)) :border "#6F6A95"
:bgcolor "#F7F4FC" :color "#2F2B45")
(flex :flex-flow (row wrap) :row-gap 1 :column-gap (12) :padding (0 (12))
:border "#6F6A95" :border-top-p nil
:bgcolor "#FBF9FF"
(column
(box :content ":flex-flow (row wrap)" :max-width (440) :padding (0 (10))
:border "#6F6A95" :bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :max-width (440) :padding (0 (8)) :border "#6F6A95"
:border-top-p nil
:bgcolor "#FBF9FF" :flex-flow (row wrap) :gap (1 (12))
(item (box :content "A" :width (170) :height 1 :bgcolor "#E4DEF4" :text-align center))
(item (box :content "B" :width (170) :height 1 :bgcolor "#CBC2E3" :text-align center))
(item (box :content "C wraps" :width (170) :height 1 :bgcolor "#AFA3D1" :text-align center))))
(column
(box :content ":flex-flow (column wrap)" :max-width (440) :padding (0 (10))
:border "#6F6A95" :bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :max-width (440) :padding (0 (8)) :border "#6F6A95"
:border-top-p nil
:bgcolor "#FBF9FF" :flex-flow (column wrap) :gap (1 (12))
(item (box :content "A" :width (140) :height 1 :bgcolor "#E4DEF4" :text-align center))
(item (box :content "B" :width (140) :height 1 :bgcolor "#CBC2E3" :text-align center))
(item (box :content "C new column" :width (140) :height 1 :bgcolor "#AFA3D1" :text-align center)))))
(spacer :height 1)
(box :content "Container property: :justify-content\nMain-axis packing after sizing. Distributed values change the space before, between, and after items."
:padding (0 (18)) :border "#3F6D88"
:bgcolor "#F1F8FB" :color "#20313A")
(flex :flex-flow (row wrap) :row-gap 1 :column-gap (12) :padding (0 (12))
:border "#3F6D88" :border-top-p nil
:bgcolor "#F6FBFD"
(column
(box :content "flex-start" :width (146) :padding (0 (4)) :border "#3F6D88" :bgcolor "#FFFDF8" :text-align center)
(flex :width (146) :border "#3F6D88" :border-top-p nil :bgcolor "#F6FBFD" :justify-content flex-start
(item (box :content "A" :width (28) :height 1 :bgcolor "#CAE6F0" :text-align center))
(item (box :content "B" :width (28) :height 1 :bgcolor "#A8D2E2" :text-align center))))
(column
(box :content "center" :width (146) :padding (0 (4)) :border "#3F6D88" :bgcolor "#FFFDF8" :text-align center)
(flex :width (146) :border "#3F6D88" :border-top-p nil :bgcolor "#F6FBFD" :justify-content center
(item (box :content "A" :width (28) :height 1 :bgcolor "#CAE6F0" :text-align center))
(item (box :content "B" :width (28) :height 1 :bgcolor "#A8D2E2" :text-align center))))
(column
(box :content "flex-end" :width (146) :padding (0 (4)) :border "#3F6D88" :bgcolor "#FFFDF8" :text-align center)
(flex :width (146) :border "#3F6D88" :border-top-p nil :bgcolor "#F6FBFD" :justify-content flex-end
(item (box :content "A" :width (28) :height 1 :bgcolor "#CAE6F0" :text-align center))
(item (box :content "B" :width (28) :height 1 :bgcolor "#A8D2E2" :text-align center))))
(column
(box :content "space-between" :width (146) :padding (0 (4)) :border "#3F6D88" :bgcolor "#FFFDF8" :text-align center)
(flex :width (146) :border "#3F6D88" :border-top-p nil :bgcolor "#F6FBFD" :justify-content space-between
(item (box :content "A" :width (28) :height 1 :bgcolor "#CAE6F0" :text-align center))
(item (box :content "B" :width (28) :height 1 :bgcolor "#A8D2E2" :text-align center))))
(column
(box :content "space-around" :width (146) :padding (0 (4)) :border "#3F6D88" :bgcolor "#FFFDF8" :text-align center)
(flex :width (146) :border "#3F6D88" :border-top-p nil :bgcolor "#F6FBFD" :justify-content space-around
(item (box :content "A" :width (28) :height 1 :bgcolor "#CAE6F0" :text-align center))
(item (box :content "B" :width (28) :height 1 :bgcolor "#A8D2E2" :text-align center))))
(column
(box :content "space-evenly" :width (146) :padding (0 (4)) :border "#3F6D88" :bgcolor "#FFFDF8" :text-align center)
(flex :width (146) :border "#3F6D88" :border-top-p nil :bgcolor "#F6FBFD" :justify-content space-evenly
(item (box :content "A" :width (28) :height 1 :bgcolor "#CAE6F0" :text-align center))
(item (box :content "B" :width (28) :height 1 :bgcolor "#A8D2E2" :text-align center))))
(column
(box :content "start" :width (176) :padding (0 (4)) :border "#3F6D88" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :border "#3F6D88" :border-top-p nil :bgcolor "#F6FBFD" :justify-content start
(item (box :content "start" :width (70) :height 1 :bgcolor "#CAE6F0" :text-align center))))
(column
(box :content "end" :width (176) :padding (0 (4)) :border "#3F6D88" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :border "#3F6D88" :border-top-p nil :bgcolor "#F6FBFD" :justify-content end
(item (box :content "end" :width (70) :height 1 :bgcolor "#A8D2E2" :text-align center))))
(column
(box :content "left" :width (176) :padding (0 (4)) :border "#3F6D88" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :border "#3F6D88" :border-top-p nil :bgcolor "#F6FBFD" :justify-content left
(item (box :content "left" :width (70) :height 1 :bgcolor "#7DB8CE" :text-align center))))
(column
(box :content "right" :width (176) :padding (0 (4)) :border "#3F6D88" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :border "#3F6D88" :border-top-p nil :bgcolor "#F6FBFD" :justify-content right
(item (box :content "right" :width (70) :height 1 :bgcolor "#5F9CB5" :text-align center))))
(column
(box :content "normal" :width (176) :padding (0 (4)) :border "#3F6D88" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :border "#3F6D88" :border-top-p nil :bgcolor "#F6FBFD" :justify-content normal
(item (box :content "normal" :width (70) :height 1 :bgcolor "#3F6D88" :color "#FFFFFF" :text-align center)))))
(spacer :height 1)
(box :content "Container property: :align-items\nCross-axis alignment inside each flex line. Stretch/normal enlarge auto-sized items; start/end/center move shorter ones."
:padding (0 (18)) :border "#9A6B43"
:bgcolor "#FFF7EE" :color "#3A2A1D")
(flex :flex-flow (row wrap) :row-gap 1 :column-gap (12) :padding (0 (12))
:border "#9A6B43" :border-top-p nil
:bgcolor "#FFFAF5"
(column
(box :content "flex-start" :width (176) :padding (0 (4)) :border "#9A6B43" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 5 :border "#9A6B43" :border-top-p nil :bgcolor "#FFFAF5" :align-items flex-start
(item (box :content "short" :width (68) :height 1 :bgcolor "#F1D0AF" :text-align center))
(item (box :content "tall\nitem\nsets line" :width (78) :height 3 :bgcolor "#D9AD83" :text-align center))))
(column
(box :content "center" :width (176) :padding (0 (4)) :border "#9A6B43" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 5 :border "#9A6B43" :border-top-p nil :bgcolor "#FFFAF5" :align-items center
(item (box :content "short" :width (68) :height 1 :bgcolor "#F1D0AF" :text-align center))
(item (box :content "tall\nitem\nsets line" :width (78) :height 3 :bgcolor "#D9AD83" :text-align center))))
(column
(box :content "flex-end" :width (176) :padding (0 (4)) :border "#9A6B43" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 5 :border "#9A6B43" :border-top-p nil :bgcolor "#FFFAF5" :align-items flex-end
(item (box :content "short" :width (68) :height 1 :bgcolor "#F1D0AF" :text-align center))
(item (box :content "tall\nitem\nsets line" :width (78) :height 3 :bgcolor "#D9AD83" :text-align center))))
(column
(box :content "stretch" :width (176) :padding (0 (4)) :border "#9A6B43" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 5 :border "#9A6B43" :border-top-p nil :bgcolor "#FFFAF5" :align-items stretch
(item (box :content "auto height stretches" :width (72) :bgcolor "#F1D0AF" :text-align center))
(item (box :content "line height" :width (78) :height 3 :bgcolor "#D9AD83" :text-align center))))
(column
(box :content "baseline" :width (176) :padding (0 (4)) :border "#9A6B43" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 5 :border "#9A6B43" :border-top-p nil :bgcolor "#FFFAF5" :align-items baseline
(item (box :content "baseline" :width (90) :height 1 :bgcolor "#BA8456" :text-align center))))
(column
(box :content "start" :width (146) :padding (0 (4)) :border "#9A6B43" :bgcolor "#FFFDF8" :text-align center)
(flex :width (146) :height 4 :border "#9A6B43" :border-top-p nil :bgcolor "#FFFAF5" :align-items start
(item (box :content "start" :width (62) :height 1 :bgcolor "#F1D0AF" :text-align center))))
(column
(box :content "end" :width (146) :padding (0 (4)) :border "#9A6B43" :bgcolor "#FFFDF8" :text-align center)
(flex :width (146) :height 4 :border "#9A6B43" :border-top-p nil :bgcolor "#FFFAF5" :align-items end
(item (box :content "end" :width (62) :height 1 :bgcolor "#D9AD83" :text-align center))))
(column
(box :content "self-start" :width (146) :padding (0 (4)) :border "#9A6B43" :bgcolor "#FFFDF8" :text-align center)
(flex :width (146) :height 4 :border "#9A6B43" :border-top-p nil :bgcolor "#FFFAF5" :align-items self-start
(item (box :content "self-start" :width (78) :height 1 :bgcolor "#BA8456" :text-align center))))
(column
(box :content "self-end" :width (146) :padding (0 (4)) :border "#9A6B43" :bgcolor "#FFFDF8" :text-align center)
(flex :width (146) :height 4 :border "#9A6B43" :border-top-p nil :bgcolor "#FFFAF5" :align-items self-end
(item (box :content "self-end" :width (70) :height 1 :bgcolor "#9A6B43" :color "#FFFFFF" :text-align center))))
(column
(box :content "normal" :width (146) :padding (0 (4)) :border "#9A6B43" :bgcolor "#FFFDF8" :text-align center)
(flex :width (146) :height 4 :border "#9A6B43" :border-top-p nil :bgcolor "#FFFAF5" :align-items normal
(item (box :content "normal" :width (62) :bgcolor "#7A5132" :color "#FFFFFF" :text-align center))))
(column
(box :content "stretch note" :width (146) :padding (0 (4)) :border "#9A6B43" :bgcolor "#FFFDF8" :text-align center)
(box :content "normal behaves stretch-like for auto cross size in this DSL." :width (146)
:height 4 :padding (0 (8)) :border "#9A6B43" :border-top-p nil
:bgcolor "#FFFAF5" :color "#3A2A1D")))
(spacer :height 1)
(box :content "Container property: :align-content\nCross-axis packing between multiple flex lines. It has no visible effect on a nowrap single-line container."
:padding (0 (18)) :border "#7C6A2F"
:bgcolor "#FBF8E8" :color "#362F16")
(flex :flex-flow (row wrap) :row-gap 1 :column-gap (12) :padding (0 (12))
:border "#7C6A2F" :border-top-p nil
:bgcolor "#FFFDF0"
(column
(box :content "flex-start" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap wrap :align-content flex-start :row-gap 1 :column-gap (8)
(item (box :content "A" :width (78) :height 1 :bgcolor "#EEE1A8" :text-align center))
(item (box :content "B" :width (78) :height 1 :bgcolor "#D9C46D" :text-align center))
(item (box :content "C" :width (78) :height 1 :bgcolor "#B89B3D" :text-align center))))
(column
(box :content "center" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap wrap :align-content center :row-gap 1 :column-gap (8)
(item (box :content "A" :width (78) :height 1 :bgcolor "#EEE1A8" :text-align center))
(item (box :content "B" :width (78) :height 1 :bgcolor "#D9C46D" :text-align center))
(item (box :content "C" :width (78) :height 1 :bgcolor "#B89B3D" :text-align center))))
(column
(box :content "flex-end" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap wrap :align-content flex-end :row-gap 1 :column-gap (8)
(item (box :content "A" :width (78) :height 1 :bgcolor "#EEE1A8" :text-align center))
(item (box :content "B" :width (78) :height 1 :bgcolor "#D9C46D" :text-align center))
(item (box :content "C" :width (78) :height 1 :bgcolor "#B89B3D" :text-align center))))
(column
(box :content "space-between" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap wrap :align-content space-between :row-gap 1 :column-gap (8)
(item (box :content "A" :width (78) :height 1 :bgcolor "#EEE1A8" :text-align center))
(item (box :content "B" :width (78) :height 1 :bgcolor "#D9C46D" :text-align center))
(item (box :content "C" :width (78) :height 1 :bgcolor "#B89B3D" :text-align center))))
(column
(box :content "space-around" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap wrap :align-content space-around :row-gap 1 :column-gap (8)
(item (box :content "A" :width (78) :height 1 :bgcolor "#EEE1A8" :text-align center))
(item (box :content "B" :width (78) :height 1 :bgcolor "#D9C46D" :text-align center))
(item (box :content "C" :width (78) :height 1 :bgcolor "#B89B3D" :text-align center))))
(column
(box :content "space-evenly" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap wrap :align-content space-evenly :row-gap 1 :column-gap (8)
(item (box :content "A" :width (78) :height 1 :bgcolor "#EEE1A8" :text-align center))
(item (box :content "B" :width (78) :height 1 :bgcolor "#D9C46D" :text-align center))
(item (box :content "C" :width (78) :height 1 :bgcolor "#B89B3D" :text-align center))))
(column
(box :content "stretch" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap wrap :align-content stretch :row-gap 1 :column-gap (8)
(item (box :content "A" :width (78) :height 1 :bgcolor "#EEE1A8" :text-align center))
(item (box :content "B" :width (78) :height 1 :bgcolor "#D9C46D" :text-align center))
(item (box :content "C" :width (78) :height 1 :bgcolor "#B89B3D" :text-align center))))
(column
(box :content "start" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap wrap :align-content start :row-gap 1 :column-gap (8)
(item (box :content "A" :width (78) :height 1 :bgcolor "#EEE1A8" :text-align center))
(item (box :content "B" :width (78) :height 1 :bgcolor "#D9C46D" :text-align center))
(item (box :content "C" :width (78) :height 1 :bgcolor "#B89B3D" :text-align center))))
(column
(box :content "end" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap wrap :align-content end :row-gap 1 :column-gap (8)
(item (box :content "A" :width (78) :height 1 :bgcolor "#EEE1A8" :text-align center))
(item (box :content "B" :width (78) :height 1 :bgcolor "#D9C46D" :text-align center))
(item (box :content "C" :width (78) :height 1 :bgcolor "#B89B3D" :text-align center))))
(column
(box :content "baseline" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap wrap :align-content baseline :row-gap 1 :column-gap (8)
(item (box :content "A" :width (78) :height 1 :bgcolor "#EEE1A8" :text-align center))
(item (box :content "B" :width (78) :height 1 :bgcolor "#D9C46D" :text-align center))
(item (box :content "C" :width (78) :height 1 :bgcolor "#B89B3D" :text-align center))))
(column
(box :content "normal" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap wrap :align-content normal :row-gap 1 :column-gap (8)
(item (box :content "A" :width (78) :height 1 :bgcolor "#EEE1A8" :text-align center))
(item (box :content "B" :width (78) :height 1 :bgcolor "#D9C46D" :text-align center))
(item (box :content "C" :width (78) :height 1 :bgcolor "#B89B3D" :text-align center))))
(column
(box :content "nowrap note" :width (176) :padding (0 (4)) :border "#7C6A2F" :bgcolor "#FFFDF8" :text-align center)
(flex :width (176) :height 7 :border "#7C6A2F" :border-top-p nil :bgcolor "#FFFDF0"
:flex-wrap nowrap :align-content normal :row-gap 1 :column-gap (8)
(item (box :content "single line" :width (96) :height 1 :bgcolor "#EEE1A8" :text-align center)))))
(spacer :height 1)
(box :content "Container properties: :gap, :row-gap, :column-gap\n:gaps define spacing between flex items and flex lines. Explicit row/column values override matching sides."
:padding (0 (18)) :border "#3D7068"
:bgcolor "#F1FAF8" :color "#1D3935")
(flex :flex-flow (row wrap) :row-gap 1 :column-gap (12) :padding (0 (12))
:border "#3D7068" :border-top-p nil
:bgcolor "#F7FCFB"
(column
(box :content ":gap (1 (14))" :max-width (293) :padding (0 (10)) :border "#3D7068"
:bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :max-width (293)
:border "#3D7068" :border-top-p nil :bgcolor "#F7FCFB"
:flex-wrap wrap :gap (1 (14))
(item (box :content "A" :width (104) :height 1 :bgcolor "#CDEBE5" :text-align center))
(item (box :content "B" :width (104) :height 1 :bgcolor "#9FD5CB" :text-align center))
(item (box :content "C" :width (104) :height 1 :bgcolor "#71BDB0" :text-align center))))
(column
(box :content ":row-gap 2" :max-width (293) :padding (0 (10)) :border "#3D7068"
:bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :max-width (293) :border "#3D7068" :border-top-p nil :bgcolor "#F7FCFB"
:flex-wrap wrap :row-gap 2 :column-gap (8)
(item (box :content "A" :width (104) :height 1 :bgcolor "#CDEBE5" :text-align center))
(item (box :content "B" :width (104) :height 1 :bgcolor "#9FD5CB" :text-align center))
(item (box :content "C" :width (104) :height 1 :bgcolor "#71BDB0" :text-align center))))
(column
(box :content ":column-gap (32)" :max-width (293) :padding (0 (10)) :border "#3D7068"
:bgcolor "#FFFDF8" :color "#3D3A35" :text-align center)
(flex :max-width (293) :border "#3D7068" :border-top-p nil :bgcolor "#F7FCFB"
:flex-wrap wrap :row-gap 1 :column-gap (32)
(item (box :content "A" :width (104) :height 1 :bgcolor "#CDEBE5" :text-align center))
(item (box :content "B" :width (104) :height 1 :bgcolor "#9FD5CB" :text-align center))
(item (box :content "C" :width (104) :height 1 :bgcolor "#71BDB0" :text-align center)))))
(spacer :height 1)
(box :content "Item property: :order\nVisual ordering sorts by :order, then source order. The labels expose both values."
:padding (0 (18)) :border "#8E4A63"
:bgcolor "#FFF4F7" :color "#3A1F2A")
(flex :height 5 :padding (0 (12)) :border "#8E4A63"
:border-top-p nil
:bgcolor "#FFF8FA" :column-gap (12) :align-items center
(item :order 3 (box :content "source 1\n:order 3" :width (150) :height 2 :bgcolor "#F2C4D2" :text-align center))
(item :order 1 (box :content "source 2\n:order 1" :width (150) :height 2 :bgcolor "#DEA4B7" :text-align center))
(item :order 2 (box :content "source 3\n:order 2" :width (150) :height 2 :bgcolor "#C87F9A" :text-align center))
(item :order 1 (box :content "source 4\nsame order" :width (150) :height 2 :bgcolor "#A95F7C" :color "#FFFFFF" :text-align center)))
(spacer :height 1)
(box :content "Item sizing: :flex-grow, :flex-shrink, :flex-basis, and child :width\nFlex starts from basis or width/content, then distributes positive or negative space."
:padding (0 (18)) :border "#2F5F7C"
:bgcolor "#F1F8FC" :color "#1C303D")
(column
(box :content "Grow factors: same basis, different growth weights."
:padding (0 (12)) :border "#2F5F7C" :border-top-p nil
:bgcolor "#FFFDF8" :color "#3D3A35")
(flex :padding (0 (12)) :border "#2F5F7C"
:border-top-p nil
:bgcolor "#F8FCFE" :column-gap (10)
(item :flex-grow 1 :flex-basis (120) (box :content "grow 1\nbasis 120" :height 2 :bgcolor "#C7E3F0" :text-align center))
(item :flex-grow 2 :flex-basis (120) (box :content "grow 2\nbasis 120" :height 2 :bgcolor "#96C7DD" :text-align center))
(item :flex-grow 3 :flex-basis (120) (box :content "grow 3\nbasis 120" :height 2 :bgcolor "#5FA4C2" :text-align center))))
(column
(box :content "Shrink factors: overflowing bases lose space by weighted shrink."
:padding (0 (12)) :border "#2F5F7C" :border-top-p nil
:bgcolor "#FFFDF8" :color "#3D3A35")
(flex :padding (0 (12)) :border "#2F5F7C"
:border-top-p nil
:bgcolor "#F8FCFE" :column-gap (10)
(item :flex-shrink 1 :flex-basis (320) (box :content "shrink 1\nbasis 320" :height 2 :bgcolor "#C7E3F0" :text-align center))
(item :flex-shrink 3 :flex-basis (320) (box :content "shrink 3\nbasis 320" :height 2 :bgcolor "#96C7DD" :text-align center))
(item :flex-shrink 5 :flex-basis (320) (box :content "shrink 5\nbasis 320" :height 2 :bgcolor "#5FA4C2" :text-align center))))
(column
(box :content "Basis versus width: explicit basis overrides width; auto basis uses width/content."
:padding (0 (12)) :border "#2F5F7C" :border-top-p nil
:bgcolor "#FFFDF8" :color "#3D3A35")
(flex :padding (0 (12)) :border "#2F5F7C"
:border-top-p nil
:bgcolor "#F8FCFE" :column-gap (10)
(item :flex-basis (120) (box :content "basis 120\nwidth 240" :width (240) :height 2 :bgcolor "#C7E3F0" :text-align center))
(item :flex-basis auto (box :content "basis auto\nwidth 240" :width (240) :height 2 :bgcolor "#96C7DD" :text-align center))
(item :flex (1 1 (160)) (box :content "flex tuple\nbasis 160" :width (240) :height 2 :bgcolor "#5FA4C2" :text-align center))))
(column
(box :content "Intrinsic basis keywords: content, min-content, and max-content derive size from text."
:padding (0 (12)) :border "#2F5F7C" :border-top-p nil
:bgcolor "#FFFDF8" :color "#3D3A35")
(flex :padding (0 (12)) :border "#2F5F7C"
:border-top-p nil
:bgcolor "#F8FCFE" :flex-wrap wrap :row-gap 1 :column-gap (10)
(item :flex-shrink 0 :flex-basis content
(box :content "content\nnatural text" :width (180) :height 2 :bgcolor "#C7E3F0" :text-align center))
(item :flex-shrink 0 :flex-basis min-content
(box :content "min-content\nwrap choices" :width (180) :height 2 :bgcolor "#96C7DD" :text-align center))
(item :flex-shrink 0 :flex-basis max-content
(box :content "max-content\nkeeps phrase width" :width (180) :height 2 :bgcolor "#5FA4C2" :text-align center))))
(spacer :height 1)
(box :content "Item property: :flex shorthand\nSupported forms: number, none, auto, initial, and (grow shrink basis). Longhands can override shorthand parts."
:padding (0 (18)) :border "#52612F"
:bgcolor "#F8FAEF" :color "#293116")
(flex :height 7 :padding (0 (12)) :border "#52612F"
:border-top-p nil
:bgcolor "#FCFDF6" :flex-wrap wrap :row-gap 1 :column-gap (8) :align-items center
(item :flex 1 (box :content ":flex 1\n= 1 1 0" :bgcolor "#DDE8B2" :text-align center))
(item :flex none (box :content ":flex none\n= 0 0 auto" :width (140) :bgcolor "#C5D681" :text-align center))
(item :flex auto (box :content ":flex auto\n= 1 1 auto" :width (140) :bgcolor "#AABE59" :text-align center))
(item :flex initial (box :content ":flex initial\n= 0 1 auto" :width (140) :bgcolor "#8D9E3C" :text-align center))
(item :flex (2 1 (120)) :flex-basis (160)
(box :content ":flex (2 1 120)\nthen basis 160" :bgcolor "#66762D" :color "#FFFFFF" :text-align center)))
(spacer :height 1)
(box :content "Item property: :align-self\nPer-item cross-axis override. auto inherits :align-items; other values replace the container setting."
:padding (0 (18)) :border "#7B4D38"
:bgcolor "#FFF6F0" :color "#392319")
(flex :height 7 :padding (0 (12)) :border "#7B4D38"
:border-top-p nil
:bgcolor "#FFFAF7" :align-items flex-start
:flex-wrap wrap :row-gap 1 :column-gap (10)
(item :align-self auto (box :content "auto\ninherits start" :width (118) :height 2 :bgcolor "#EFC7B6" :text-align center))
(item :align-self flex-start (box :content "flex-start" :width (118) :height 2 :bgcolor "#DBA58D" :text-align center))
(item :align-self center (box :content "center" :width (118) :height 2 :bgcolor "#BF7F63" :text-align center))
(item :align-self flex-end (box :content "flex-end" :width (118) :height 2 :bgcolor "#9D6048" :color "#FFFFFF" :text-align center))
(item :align-self stretch (box :content "stretch\nauto cross" :width (118) :bgcolor "#7B4D38" :color "#FFFFFF" :text-align center))
(item :align-self normal (box :content "normal\nstretch-like" :width (118) :bgcolor "#5D3929" :color "#FFFFFF" :text-align center)))
(flex :height 6 :padding (0 (12)) :border "#7B4D38"
:border-top-p nil
:bgcolor "#FFFAF7" :align-items flex-start
:flex-wrap wrap :row-gap 1 :column-gap (10)
(item :align-self start (box :content "start" :width (118) :height 2 :bgcolor "#EFC7B6" :text-align center))
(item :align-self end (box :content "end" :width (118) :height 2 :bgcolor "#DBA58D" :text-align center))
(item :align-self self-start (box :content "self-start" :width (118) :height 2 :bgcolor "#BF7F63" :text-align center))
(item :align-self self-end (box :content "self-end" :width (118) :height 2 :bgcolor "#9D6048" :color "#FFFFFF" :text-align center))
(item :align-self baseline (box :content "baseline" :width (118) :height 2 :bgcolor "#7B4D38" :color "#FFFFFF" :text-align center)))
(spacer :height 1)
(box :content "Summary\nUse direction and wrap to create lines, basis/width to choose starting sizes, grow/shrink to distribute space, then justify/align properties to pack items and lines."
:padding (1 (24)) :border "#2F3437"
:bgcolor "#FAF7F0" :color "#1F2328" :text-align center))

View File

@ -0,0 +1,65 @@
(column
:margin-left 1 :margin-top 1 :padding (1 2 1 2) :bgcolor "linen"
(box :content "Ebox Responsive Layout\nDrag the preview window: sections stretch to the current viewport, cards reflow, and text wraps without changing the window layout."
:padding (1 (18)) :border "#334155" :text-align center
:color "#000" :bgcolor "ghost white")
(spacer :height 1)
(box :content "No explicit viewport width here\nThis page mostly omits :width. The Playground binds the current preview width while rendering, so auto width behaves like a responsive container."
:padding (0 (16)) :border "#0F766E"
:bgcolor "#F0FDFA" :color "#134E4A" :wrap-mode word)
(spacer :height 1)
(box :content "Adaptive toolbar\nButtons below keep stable sizes. The row packs as many as fit and then wraps."
:padding (0 (16)) :border "#EA580C" :border-bottom-p nil
:bgcolor "#FFF7ED" :color "#431407" :wrap-mode word)
(flex :flex-flow (row wrap) :column-gap (8) :row-gap 1
:padding (1 (8)) :border "#FDBA74" :bgcolor "#FFFBF5"
(box :content "Render" :width (96) :padding (0 (8))
:border "#EA580C" :bgcolor "#FED7AA" :color "#111827"
:text-align center)
(box :content "Save" :width (88) :padding (0 (8))
:border "#D97706" :bgcolor "#FEF3C7" :color "#111827"
:text-align center)
(box :content "Format" :width (96) :padding (0 (8))
:border "#16A34A" :bgcolor "#DCFCE7" :color "#14532D"
:text-align center)
(box :content "Sample" :width (104) :padding (0 (8))
:border "#0284C7" :bgcolor "#E0F2FE" :color "#0C4A6E"
:text-align center)
(box :content "Write" :width (88) :padding (0 (8))
:border "#9333EA" :bgcolor "#F3E8FF" :color "#3B0764"
:text-align center))
(spacer :height 1)
(box :content "Responsive cards\nEach card starts from a flex-basis, grows when space is available, and wraps to new rows when the preview becomes narrow."
:padding (0 (16)) :border "#2563EB" :border-bottom-p nil
:bgcolor "#EFF6FF" :color "#1E3A8A" :wrap-mode word)
(flex :flex-flow (row wrap) :column-gap (12) :row-gap 1
:padding (1 (8)) :border "#93C5FD" :bgcolor "#F8FBFF"
:justify-content center
(item :flex (1 1 (210))
(box :content "Overview\nThe row uses the current preview width automatically."
:min-width (180) :max-width (280)
:padding (0 (10)) ;; :border "#2563EB"
:bgcolor "#DBEAFE" :color "#111827" :wrap-mode word))
(item :flex (1 1 (210))
(box :content "Editor\nDrag slowly or quickly; resize rendering is debounced."
:min-width (180) :max-width (300)
:padding (0 (10)) ;; :border "#4F46E5"
:bgcolor "#E0E7FF" :color "#111827" :wrap-mode word))
(item :flex (1 1 (210))
(box :content "Preview\nThe preview buffer rerenders after the viewport settles."
:min-width (180) :max-width (320)
:padding (0 (10)) ;; :border "#7C3AED"
:bgcolor "#EDE9FE" :color "#111827" :wrap-mode word))
(item :flex (1 1 (210))
(box :content "Flow\nCards keep readable widths instead of overflowing."
:min-width (180) :max-width (280)
:padding (0 (10)) ;; :border "#0891B2"
:bgcolor "#CFFAFE" :color "#164E63" :wrap-mode word)))
(spacer :height 1)
(box :content "What to watch\nNarrow preview: cards stack and text wraps. Wide preview: cards share a row and expand up to max-width. The source/preview split is preserved."
:padding (0 (16)) :border "#475569"
:bgcolor "#F8FAFC" :color "#0F172A" :wrap-mode word))

130
native/Cargo.lock generated Normal file
View File

@ -0,0 +1,130 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "cc"
version = "1.2.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "ebox-native-reflow"
version = "0.1.0"
dependencies = [
"cc",
"serde",
"serde_json",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

22
native/Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "ebox-native-reflow"
version = "0.1.0"
edition = "2021"
rust-version = "1.82"
publish = false
[lib]
name = "ebox_native_reflow"
crate-type = ["cdylib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[build-dependencies]
cc = "1"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "debuginfo"

33
native/build.rs Normal file
View File

@ -0,0 +1,33 @@
use std::env;
use std::path::PathBuf;
fn main() {
println!("cargo:rerun-if-changed=c/ebox_module.c");
println!("cargo:rerun-if-changed=vendor/emacs-30/emacs-module.h");
println!("cargo:rerun-if-env-changed=EMACS_MODULE_INCLUDE");
let include = match env::var_os("EMACS_MODULE_INCLUDE") {
Some(value) => {
let path = PathBuf::from(value);
assert!(
path.join("emacs-module.h").is_file(),
"EMACS_MODULE_INCLUDE must name a directory containing emacs-module.h"
);
path
}
None => PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("vendor/emacs-30"),
};
let mut build = cc::Build::new();
build.file("c/ebox_module.c").include(include);
if env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
build.flag_if_supported("/W4");
} else {
build
.flag_if_supported("-std=c11")
.flag_if_supported("-Wall")
.flag_if_supported("-Wextra")
.flag_if_supported("-Werror=implicit-function-declaration");
}
build.compile("ebox_module_shim");
}

492
native/c/ebox_module.c Normal file
View File

@ -0,0 +1,492 @@
#include <emacs-module.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <errno.h>
#include <io.h>
#else
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#endif
typedef struct {
unsigned char *data;
size_t len;
} ebox_native_bytes;
typedef bool (*ebox_readiness_signal_fn)(int32_t fd);
typedef void (*ebox_readiness_close_fn)(int32_t fd);
extern void *ebox_native_session_create(uint32_t workers,
uint32_t max_jobs,
uint32_t max_results,
uint64_t max_result_bytes,
ebox_native_bytes *error);
extern int64_t ebox_native_session_submit(void *session,
uint64_t generation,
const unsigned char *payload,
size_t payload_len,
ebox_native_bytes *error);
extern bool ebox_native_session_ready(void *session,
uint64_t generation,
int64_t key);
extern ebox_native_bytes ebox_native_session_take(void *session,
uint64_t generation,
int64_t key);
extern bool ebox_native_session_confirm_frame(void *session,
uint64_t generation,
int64_t key,
uint64_t confirmed_revision,
ebox_native_bytes *error);
extern bool ebox_native_session_attach_readiness_channel(
void *session, int32_t fd, ebox_readiness_signal_fn signal,
ebox_readiness_close_fn close);
extern void ebox_native_session_detach_readiness_channel(void *session);
extern void ebox_native_session_cancel(void *session, uint64_t generation);
extern ebox_native_bytes ebox_native_session_stats(void *session);
extern void ebox_native_session_release(void *session);
extern void ebox_native_session_finalize(void *session);
extern void ebox_native_bytes_free(ebox_native_bytes bytes);
extern bool ebox_native_layout_ready(void);
extern bool ebox_native_render_proof(const unsigned char *payload,
size_t payload_len,
ebox_native_bytes *output);
static emacs_value
ebox_nil(emacs_env *env)
{
return env->intern(env, "nil");
}
static emacs_value
ebox_true(emacs_env *env)
{
return env->intern(env, "t");
}
static emacs_value
ebox_signal_error(emacs_env *env, const char *message, size_t length)
{
emacs_value string = env->make_string(env, message, (ptrdiff_t) length);
emacs_value data = env->funcall(env, env->intern(env, "list"), 1, &string);
env->non_local_exit_signal(env, env->intern(env, "error"), data);
return ebox_nil(env);
}
static emacs_value
ebox_signal_native_error(emacs_env *env, ebox_native_bytes error,
const char *fallback)
{
emacs_value result;
if (error.data != NULL && error.len > 0)
result = ebox_signal_error(env, (const char *) error.data, error.len);
else
result = ebox_signal_error(env, fallback, strlen(fallback));
ebox_native_bytes_free(error);
return result;
}
static bool
ebox_copy_string(emacs_env *env, emacs_value value,
unsigned char **output, size_t *output_len)
{
ptrdiff_t required = 0;
if (!env->copy_string_contents(env, value, NULL, &required) || required <= 0)
return false;
unsigned char *buffer = malloc((size_t) required);
if (buffer == NULL) {
static const char message[] = "Native reflow input allocation failed";
ebox_signal_error(env, message, sizeof message - 1);
return false;
}
ptrdiff_t available = required;
if (!env->copy_string_contents(env, value, (char *) buffer, &available)) {
free(buffer);
return false;
}
*output = buffer;
*output_len = (size_t) (available - 1);
return true;
}
static void
ebox_session_finalizer(void *pointer)
{
if (pointer != NULL)
ebox_native_session_finalize(pointer);
}
static void *
ebox_session_pointer(emacs_env *env, emacs_value value)
{
void *session = env->get_user_ptr(env, value);
if (session == NULL) {
static const char message[] = "Native reflow session has been released";
ebox_signal_error(env, message, sizeof message - 1);
}
return session;
}
static emacs_value
ebox_module_version(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
void *data)
{
(void) nargs;
(void) args;
(void) data;
static const char version[] = "7:2:9";
return env->make_string(env, version, (ptrdiff_t) (sizeof version - 1));
}
static bool
ebox_signal_readiness_fd(int32_t fd)
{
unsigned char byte = 1;
#ifdef _WIN32
int written = _write(fd, &byte, 1);
#else
ssize_t written = write(fd, &byte, 1);
#endif
if (written == 1)
return true;
return errno == EAGAIN
#ifdef EWOULDBLOCK
|| errno == EWOULDBLOCK
#endif
;
}
static void
ebox_close_readiness_fd(int32_t fd)
{
#ifdef _WIN32
(void) _close(fd);
#else
(void) close(fd);
#endif
}
static bool
ebox_make_nonblocking(int32_t fd)
{
#ifdef _WIN32
(void) fd;
return false;
#else
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0)
return false;
if ((flags & O_NONBLOCK) != 0)
return true;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0;
#endif
}
static emacs_value
ebox_module_layout_ready(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
void *data)
{
(void) nargs;
(void) args;
(void) data;
return ebox_native_layout_ready() ? ebox_true(env) : ebox_nil(env);
}
static emacs_value
ebox_module_create_session(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
void *data)
{
(void) nargs;
(void) data;
uint32_t workers = (uint32_t) env->extract_integer(env, args[0]);
uint32_t max_jobs = (uint32_t) env->extract_integer(env, args[1]);
uint32_t max_results = (uint32_t) env->extract_integer(env, args[2]);
uint64_t max_result_bytes = (uint64_t) env->extract_integer(env, args[3]);
if (env->non_local_exit_check(env) != emacs_funcall_exit_return)
return ebox_nil(env);
ebox_native_bytes error = {0};
void *session = ebox_native_session_create(workers, max_jobs, max_results,
max_result_bytes, &error);
if (session == NULL)
return ebox_signal_native_error(env, error,
"Native reflow session creation failed");
return env->make_user_ptr(env, ebox_session_finalizer, session);
}
static emacs_value
ebox_module_submit(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
void *data)
{
(void) nargs;
(void) data;
void *session = ebox_session_pointer(env, args[0]);
uint64_t generation = (uint64_t) env->extract_integer(env, args[1]);
if (session == NULL ||
env->non_local_exit_check(env) != emacs_funcall_exit_return)
return ebox_nil(env);
unsigned char *payload = NULL;
size_t payload_len = 0;
if (!ebox_copy_string(env, args[2], &payload, &payload_len))
return ebox_nil(env);
ebox_native_bytes error = {0};
int64_t accepted = ebox_native_session_submit(
session, generation, payload, payload_len, &error);
free(payload);
if (accepted < 0)
return ebox_signal_native_error(env, error,
"Native reflow submission failed");
return env->make_integer(env, accepted);
}
static emacs_value
ebox_module_ready(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
void *data)
{
(void) nargs;
(void) data;
void *session = ebox_session_pointer(env, args[0]);
uint64_t generation = (uint64_t) env->extract_integer(env, args[1]);
int64_t key = (int64_t) env->extract_integer(env, args[2]);
if (session == NULL ||
env->non_local_exit_check(env) != emacs_funcall_exit_return)
return ebox_nil(env);
return ebox_native_session_ready(session, generation, key)
? ebox_true(env) : ebox_nil(env);
}
static emacs_value
ebox_module_take(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
void *data)
{
(void) nargs;
(void) data;
void *session = ebox_session_pointer(env, args[0]);
uint64_t generation = (uint64_t) env->extract_integer(env, args[1]);
int64_t key = (int64_t) env->extract_integer(env, args[2]);
if (session == NULL ||
env->non_local_exit_check(env) != emacs_funcall_exit_return)
return ebox_nil(env);
ebox_native_bytes bytes = ebox_native_session_take(session, generation, key);
if (bytes.data == NULL)
return ebox_nil(env);
emacs_value result = env->make_unibyte_string(
env, (const char *) bytes.data, (ptrdiff_t) bytes.len);
ebox_native_bytes_free(bytes);
return result;
}
static emacs_value
ebox_module_render_proof(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
void *data)
{
(void) nargs;
(void) data;
unsigned char *payload = NULL;
size_t payload_len = 0;
if (!ebox_copy_string(env, args[0], &payload, &payload_len))
return ebox_nil(env);
ebox_native_bytes output = {0};
bool ok = ebox_native_render_proof(payload, payload_len, &output);
free(payload);
if (!ok)
return ebox_signal_native_error(env, output,
"Native proof render failed");
emacs_value result = env->make_unibyte_string(
env, (const char *) output.data, (ptrdiff_t) output.len);
ebox_native_bytes_free(output);
return result;
}
static emacs_value
ebox_module_confirm_frame(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
void *data)
{
(void) nargs;
(void) data;
void *session = ebox_session_pointer(env, args[0]);
uint64_t generation = (uint64_t) env->extract_integer(env, args[1]);
int64_t key = (int64_t) env->extract_integer(env, args[2]);
uint64_t confirmed_revision = (uint64_t) env->extract_integer(env, args[3]);
if (session == NULL ||
env->non_local_exit_check(env) != emacs_funcall_exit_return)
return ebox_nil(env);
ebox_native_bytes error = {0};
bool confirmed = ebox_native_session_confirm_frame(
session, generation, key, confirmed_revision, &error);
if (!confirmed && error.data != NULL)
return ebox_signal_native_error(env, error,
"Native reflow confirmation failed");
ebox_native_bytes_free(error);
return confirmed ? ebox_true(env) : ebox_nil(env);
}
static emacs_value
ebox_module_attach_readiness_channel(emacs_env *env, ptrdiff_t nargs,
emacs_value *args, void *data)
{
(void) nargs;
(void) data;
void *session = ebox_session_pointer(env, args[0]);
if (session == NULL ||
env->non_local_exit_check(env) != emacs_funcall_exit_return)
return ebox_nil(env);
if (env->size < (ptrdiff_t) (offsetof(emacs_env, open_channel)
+ sizeof env->open_channel) ||
env->open_channel == NULL) {
static const char message[] = "Native readiness channels are unsupported";
return ebox_signal_error(env, message, sizeof message - 1);
}
int fd = env->open_channel(env, args[1]);
if (fd < 0 || env->non_local_exit_check(env) != emacs_funcall_exit_return)
return ebox_nil(env);
if (!ebox_make_nonblocking(fd)) {
static const char message[] = "Native readiness channel setup failed";
ebox_close_readiness_fd((int32_t) fd);
return ebox_signal_error(env, message, sizeof message - 1);
}
if (ebox_native_session_attach_readiness_channel(
session, (int32_t) fd, ebox_signal_readiness_fd,
ebox_close_readiness_fd))
return ebox_true(env);
ebox_close_readiness_fd((int32_t) fd);
return ebox_nil(env);
}
static emacs_value
ebox_module_detach_readiness_channel(emacs_env *env, ptrdiff_t nargs,
emacs_value *args, void *data)
{
(void) nargs;
(void) data;
void *session = ebox_session_pointer(env, args[0]);
if (session == NULL ||
env->non_local_exit_check(env) != emacs_funcall_exit_return)
return ebox_nil(env);
ebox_native_session_detach_readiness_channel(session);
return ebox_true(env);
}
static emacs_value
ebox_module_cancel(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
void *data)
{
(void) nargs;
(void) data;
void *session = ebox_session_pointer(env, args[0]);
uint64_t generation = (uint64_t) env->extract_integer(env, args[1]);
if (session == NULL ||
env->non_local_exit_check(env) != emacs_funcall_exit_return)
return ebox_nil(env);
ebox_native_session_cancel(session, generation);
return ebox_true(env);
}
static emacs_value
ebox_module_stats(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
void *data)
{
(void) nargs;
(void) data;
void *session = ebox_session_pointer(env, args[0]);
if (session == NULL)
return ebox_nil(env);
ebox_native_bytes bytes = ebox_native_session_stats(session);
if (bytes.data == NULL) {
static const char message[] = "Native reflow stats failed";
return ebox_signal_error(env, message, sizeof message - 1);
}
emacs_value result = env->make_string(
env, (const char *) bytes.data, (ptrdiff_t) bytes.len);
ebox_native_bytes_free(bytes);
return result;
}
static emacs_value
ebox_module_release(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
void *data)
{
(void) nargs;
(void) data;
void *session = env->get_user_ptr(env, args[0]);
if (session != NULL) {
env->set_user_ptr(env, args[0], NULL);
ebox_native_session_release(session);
}
return ebox_true(env);
}
static int
ebox_bind_function(emacs_env *env, const char *name, ptrdiff_t min_arity,
ptrdiff_t max_arity, emacs_function function,
const char *docstring)
{
emacs_value symbol = env->intern(env, name);
emacs_value callable = env->make_function(
env, min_arity, max_arity, function, docstring, NULL);
emacs_value arguments[] = {symbol, callable};
env->funcall(env, env->intern(env, "defalias"), 2, arguments);
return env->non_local_exit_check(env) == emacs_funcall_exit_return ? 0 : 1;
}
int
ebox_module_init_impl(struct emacs_runtime *runtime)
{
if (runtime == NULL || runtime->get_environment == NULL)
return 1;
emacs_env *env = runtime->get_environment(runtime);
if (env == NULL ||
env->size < (ptrdiff_t) (offsetof(emacs_env, make_unibyte_string)
+ sizeof env->make_unibyte_string))
return 2;
if (ebox_bind_function(env, "ebox-native--module-version", 0, 0,
ebox_module_version,
"Return the private Ebox native ABI version.") ||
ebox_bind_function(env, "ebox-native--module-layout-ready-p", 0, 0,
ebox_module_layout_ready,
"Return non-nil when native layout is implemented.") ||
ebox_bind_function(env, "ebox-native--module-create-session", 4, 4,
ebox_module_create_session,
"Create one bounded private native session.") ||
ebox_bind_function(env, "ebox-native--module-submit", 3, 3,
ebox_module_submit,
"Submit one private native control batch.") ||
ebox_bind_function(env, "ebox-native--module-ready-p", 3, 3,
ebox_module_ready,
"Return non-nil when a private result is ready.") ||
ebox_bind_function(env, "ebox-native--module-take", 3, 3,
ebox_module_take,
"Take one private native result as bytes.") ||
ebox_bind_function(env, "ebox-native--module-render-proof", 1, 1,
ebox_module_render_proof,
"Render one private native proof synchronously.") ||
ebox_bind_function(env, "ebox-native--module-confirm-frame", 4, 4,
ebox_module_confirm_frame,
"Confirm one published native layout frame.") ||
ebox_bind_function(env, "ebox-native--module-attach-readiness-channel",
2, 2, ebox_module_attach_readiness_channel,
"Attach one native readiness pipe process.") ||
ebox_bind_function(env, "ebox-native--module-detach-readiness-channel",
1, 1, ebox_module_detach_readiness_channel,
"Detach one native readiness pipe process.") ||
ebox_bind_function(env, "ebox-native--module-cancel", 2, 2,
ebox_module_cancel,
"Cancel one private native generation.") ||
ebox_bind_function(env, "ebox-native--module-stats", 1, 1,
ebox_module_stats,
"Return private native session stats as JSON.") ||
ebox_bind_function(env, "ebox-native--module-release", 1, 1,
ebox_module_release,
"Release one private native session."))
return 3;
emacs_value feature = env->intern(env, "ebox-native-module");
env->funcall(env, env->intern(env, "provide"), 1, &feature);
return env->non_local_exit_check(env) == emacs_funcall_exit_return ? 0 : 4;
}

6782
native/src/layout.rs Normal file

File diff suppressed because it is too large Load Diff

1676
native/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

1122
native/vendor/emacs-30/emacs-module.h vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
;;; ebox-package-lint.el --- Batch package-lint entry point -*- lexical-binding: t; -*-
(require 'package)
(defconst ebox-package-lint--root
(expand-file-name ".." (file-name-directory (or load-file-name buffer-file-name)))
"Repository root for package lint.")
(defun ebox-package-lint--install-requested-p ()
"Return non-nil when package-lint should be installed if missing."
(getenv "EBOX_PACKAGE_LINT_INSTALL"))
(defun ebox-package-lint--ensure-package ()
"Ensure package-lint is available.
When `EBOX_PACKAGE_LINT_INSTALL' is set, install it through package archives.
Otherwise, skip cleanly if package-lint is not already available."
(unless (require 'package-lint nil t)
(if (not (ebox-package-lint--install-requested-p))
(progn
(message "package-lint is not on load-path; set EBOX_PACKAGE_LINT_INSTALL=1 to install it.")
(kill-emacs 0))
(setq package-user-dir
(or (getenv "EBOX_PACKAGE_LINT_DIR")
(expand-file-name "ebox-package-lint" temporary-file-directory)))
(setq package-archives
'(("gnu" . "https://elpa.gnu.org/packages/")
("melpa" . "https://melpa.org/packages/")))
(package-initialize)
(unless package-archive-contents
(package-refresh-contents))
(unless (package-installed-p 'package-lint)
(package-install 'package-lint))
(require 'package-lint))))
;;;###autoload
(defun ebox-package-lint-batch ()
"Run package-lint against ebox.el in batch mode."
(ebox-package-lint--ensure-package)
(let ((command-line-args-left
(list (expand-file-name "ebox.el" ebox-package-lint--root))))
(package-lint-batch-and-exit)))
(provide 'ebox-package-lint)
;;; ebox-package-lint.el ends here

View File

@ -0,0 +1,376 @@
;;; ebox-visual-check.el --- Visual verification helper for ebox -*- lexical-binding: t; -*-
;; Usage:
;; Batch/property report:
;; emacs -Q -L . -l scripts/ebox-visual-check.el -f ebox-visual-check-batch
;;
;; GUI inspection:
;; emacs -Q -L . -l scripts/ebox-visual-check.el \
;; --eval '(ebox-visual-check-run t)'
(require 'cl-lib)
(let ((root (file-name-directory
(directory-file-name
(file-name-directory (or load-file-name buffer-file-name))))))
(add-to-list 'load-path root))
(require 'ebox)
(defconst ebox-visual-check-buffer "*ebox-visual-check*"
"Buffer used for visual inspection.")
(defcustom ebox-visual-check-output-dir
(expand-file-name "ebox-visual-check" temporary-file-directory)
"Directory used for optional screenshots and reports."
:type 'directory
:group 'ebox)
(defun ebox-visual-check-layout ()
"Return the fixed layout used by visual verification."
(let* ((title (ebox-create
:content "Ebox Visual Check"
:box-sizing 'border-box
:width 74
:padding '(0 1)
:border t
:border-color "#4A90D9"
:color "#EAF6FF"
:bgcolor "#203040"))
(hidden (ebox-create
:content "Hidden: visible line\nHIDDEN-CLIPPED-LINE"
:box-sizing 'border-box
:width 24
:height 3
:padding '(1 1)
:border t
:border-color "#27AE60"
:color "#F0FFF4"
:bgcolor "#17351F"
:overflow 'hidden))
(scroll (ebox-create
:content "Scroll: first\nSCROLL-SECOND\nSCROLL-THIRD"
:box-sizing 'border-box
:width 24
:height 3
:padding '(1 1)
:border t
:border-color "#E67E22"
:color "#FFF4E6"
:bgcolor "#3A2410"
:overflow 'scroll))
(sized (ebox-create
:content "border-box max-height\nline 2\nSIZED-CLIPPED"
:box-sizing 'border-box
:width 26
:max-height 3
:padding '(1 1)
:border t
:border-color "#9B59B6"
:color "#F8EEFF"
:bgcolor "#2F1B3A"
:overflow 'hidden))
(footer (ebox-create
:content "Checks: text properties + display specs + GUI screenshot"
:box-sizing 'border-box
:width 74
:padding '(0 1)
:border t
:border-color "#7F8C8D"
:color "#F8F9F9"
:bgcolor "#2C3E50")))
(ebox-column title (ebox-row hidden scroll sized) footer)))
(defun ebox-visual-check--max-line-pixel-width (string)
"Return maximum pixel width among STRING lines."
(apply #'max (mapcar #'string-pixel-width (ebox-string-lines string))))
(defun ebox-visual-check--some-property-p (string property)
"Return non-nil when STRING has PROPERTY anywhere."
(let ((pos 0)
(len (length string))
found)
(while (and (< pos len) (not found))
(when (get-text-property pos property string)
(setq found t))
(setq pos (or (next-single-property-change pos property string)
(1+ pos))))
found))
(defun ebox-visual-check--face-has-key-p (face key)
"Return non-nil when FACE contains plist KEY."
(cond
((null face) nil)
((and (listp face) (keywordp (car face)))
(plist-member face key))
((listp face)
(cl-some (lambda (item)
(ebox-visual-check--face-has-key-p item key))
face))
(t nil)))
(defun ebox-visual-check--some-face-key-p (string key)
"Return non-nil when any face property in STRING contains KEY."
(let ((pos 0)
(len (length string))
found)
(while (and (< pos len) (not found))
(when (ebox-visual-check--face-has-key-p
(get-text-property pos 'face string)
key)
(setq found t))
(setq pos (or (next-single-property-change pos 'face string)
(1+ pos))))
found))
(defun ebox-visual-check--check (name ok detail)
"Return a check plist with NAME, OK, and DETAIL."
(list :name name :ok (and ok t) :detail detail))
(defun ebox-visual-check-report ()
"Return visual verification report based on rendered text properties."
(let* ((layout (ebox-visual-check-layout))
(ids-before (ebox-region-ids layout))
(rendered (ebox-render layout))
(plain (substring-no-properties rendered))
(ids-after (ebox-region-ids rendered))
(metrics (list :height (ebox-string-height rendered)
:pixel-width
(ebox-visual-check--max-line-pixel-width rendered)
:region-count (length ids-before))))
(list
(list
:name "dashboard-contract"
:metrics metrics
:checks
(list
(ebox-visual-check--check
"region ids are stable"
(equal ids-before ids-after)
(format "before=%S after=%S" ids-before ids-after))
(ebox-visual-check--check
"all expected boxes rendered"
(= (length ids-before) 5)
(format "region-count=%d" (length ids-before)))
(ebox-visual-check--check
"display specs exist"
(ebox-visual-check--some-property-p rendered 'display)
"pixel spaces are represented with display properties")
(ebox-visual-check--check
"foreground faces exist"
(ebox-visual-check--some-face-key-p rendered :foreground)
"checks :color render path")
(ebox-visual-check--check
"background faces exist"
(ebox-visual-check--some-face-key-p rendered :background)
"checks :bgcolor render path")
(ebox-visual-check--check
"hidden overflow is clipped"
(not (string-match-p "HIDDEN-CLIPPED-LINE" plain))
"hidden box should not expose clipped line")
(ebox-visual-check--check
"scroll overflow keeps initial window"
(and (string-match-p "Scroll: first" plain)
(not (string-match-p "SCROLL-SECOND" plain)))
"scroll box should show first window only")
(ebox-visual-check--check
"border-box max-height clips outer box"
(not (string-match-p "SIZED-CLIPPED" plain))
"max-height includes vertical padding"))))))
(defun ebox-visual-check--format-report (report)
"Format REPORT as human-readable text."
(mapconcat
(lambda (scenario)
(let ((name (plist-get scenario :name))
(metrics (plist-get scenario :metrics))
(checks (plist-get scenario :checks)))
(concat
(format "Scenario: %s\nMetrics: %S\n" name metrics)
(mapconcat
(lambda (check)
(format "%s %s -- %s"
(if (plist-get check :ok) "PASS" "FAIL")
(plist-get check :name)
(plist-get check :detail)))
checks
"\n"))))
report
"\n\n"))
(defun ebox-visual-check-all-passed-p (report)
"Return non-nil when every check in REPORT passed."
(cl-every
(lambda (scenario)
(cl-every (lambda (check) (plist-get check :ok))
(plist-get scenario :checks)))
report))
(defun ebox-visual-check-render-buffer ()
"Render the visual check layout into `ebox-visual-check-buffer'."
(ebox-render-to-buffer ebox-visual-check-buffer
(ebox-visual-check-layout)))
(defun ebox-visual-check-capture-screenshot ()
"Capture a macOS screenshot for the current GUI Emacs frame.
Return the screenshot path, or nil when screenshot capture is unavailable."
(when (and (display-graphic-p)
(executable-find "screencapture"))
(make-directory ebox-visual-check-output-dir t)
(raise-frame)
(select-frame-set-input-focus (selected-frame))
(when (and (eq system-type 'darwin)
(executable-find "osascript"))
(call-process "osascript" nil nil nil
"-e" "tell application id \"org.gnu.Emacs\" to activate"))
(let ((file (expand-file-name
(format-time-string "ebox-visual-check-%Y%m%d-%H%M%S.png")
ebox-visual-check-output-dir)))
(redisplay t)
(sit-for 1)
(when (zerop (call-process "screencapture" nil nil nil "-x" file))
file))))
(defun ebox-visual-check--tokenize-ppm (file)
"Return PPM FILE tokens, ignoring comments."
(with-temp-buffer
(insert-file-contents file)
(goto-char (point-min))
(let (tokens)
(while (not (eobp))
(cond
((looking-at "[ \t\r\n]+")
(goto-char (match-end 0)))
((looking-at "#.*$")
(forward-line 1))
((looking-at "[^ \t\r\n#]+")
(push (match-string 0) tokens)
(goto-char (match-end 0)))
(t
(forward-char 1))))
(nreverse tokens))))
(defun ebox-visual-check--read-ppm (file)
"Read an ASCII P3 PPM FILE into a plist."
(let* ((tokens (ebox-visual-check--tokenize-ppm file))
(magic (pop tokens)))
(unless (equal magic "P3")
(error "Only ASCII P3 PPM files are supported directly: %s" file))
(let* ((width (string-to-number (pop tokens)))
(height (string-to-number (pop tokens)))
(max-value (string-to-number (pop tokens)))
pixels)
(dotimes (_ (* width height))
(let ((r (string-to-number (pop tokens)))
(g (string-to-number (pop tokens)))
(b (string-to-number (pop tokens))))
(push (vector r g b) pixels)))
(list :width width
:height height
:max-value max-value
:pixels (vconcat (nreverse pixels))))))
(defun ebox-visual-check--maybe-convert-to-ppm (file)
"Return FILE as a PPM path, converting image formats with sips when needed."
(if (string-match-p "\\.ppm\\'" file)
file
(unless (executable-find "sips")
(error "Cannot convert image without sips: %s" file))
(let ((out (make-temp-file "ebox-visual-check-" nil ".ppm")))
(unless (zerop (call-process "sips" nil nil nil
"-s" "format" "ppm"
file "--out" out))
(error "Failed to convert image to PPM: %s" file))
out)))
(defun ebox-visual-check--pixel-at (image x y)
"Return IMAGE pixel at X,Y."
(let ((width (plist-get image :width))
(pixels (plist-get image :pixels)))
(aref pixels (+ (* y width) x))))
(defun ebox-visual-check--pixel-delta (p1 p2)
"Return the maximum RGB channel delta between P1 and P2."
(max (abs (- (aref p1 0) (aref p2 0)))
(abs (- (aref p1 1) (aref p2 1)))
(abs (- (aref p1 2) (aref p2 2)))))
(defun ebox-visual-check-compare-region (expected actual region &optional tolerance)
"Compare EXPECTED and ACTUAL images inside REGION.
REGION is a plist with :x, :y, :width, and :height. TOLERANCE is the
allowed maximum per-channel RGB delta, defaulting to 0.
The direct parser supports ASCII P3 PPM. Other image files are converted to
PPM with macOS `sips' when available."
(let* ((tolerance (or tolerance 0))
(expected-ppm (ebox-visual-check--maybe-convert-to-ppm expected))
(actual-ppm (ebox-visual-check--maybe-convert-to-ppm actual))
(expected-image (ebox-visual-check--read-ppm expected-ppm))
(actual-image (ebox-visual-check--read-ppm actual-ppm))
(x (plist-get region :x))
(y (plist-get region :y))
(width (plist-get region :width))
(height (plist-get region :height))
(samples 0)
(mismatches 0)
(max-delta 0))
(unless (and (= (plist-get expected-image :width)
(plist-get actual-image :width))
(= (plist-get expected-image :height)
(plist-get actual-image :height)))
(error "Image dimensions differ: %S vs %S"
(list (plist-get expected-image :width)
(plist-get expected-image :height))
(list (plist-get actual-image :width)
(plist-get actual-image :height))))
(dotimes (dy height)
(dotimes (dx width)
(let* ((px (+ x dx))
(py (+ y dy))
(delta (ebox-visual-check--pixel-delta
(ebox-visual-check--pixel-at expected-image px py)
(ebox-visual-check--pixel-at actual-image px py))))
(setq samples (1+ samples))
(setq max-delta (max max-delta delta))
(when (> delta tolerance)
(setq mismatches (1+ mismatches))))))
(list :ok (= mismatches 0)
:samples samples
:mismatches mismatches
:max-delta max-delta
:tolerance tolerance)))
(defun ebox-visual-check-run (&optional capture)
"Render visual check buffer for GUI inspection.
With CAPTURE non-nil, also save a screenshot when possible."
(interactive "P")
(let* ((buffer (ebox-visual-check-render-buffer))
(report (ebox-visual-check-report))
(passed (ebox-visual-check-all-passed-p report))
screenshot)
(switch-to-buffer buffer)
(delete-other-windows)
(goto-char (point-min))
(when capture
(setq screenshot (ebox-visual-check-capture-screenshot)))
(message "ebox visual check: %s%s"
(if passed "PASS" "FAIL")
(if screenshot
(format " screenshot=%s" screenshot)
""))
(list :passed passed :screenshot screenshot :report report)))
(defun ebox-visual-check-batch ()
"Run property-based visual checks and print a report."
(interactive)
(let* ((report (ebox-visual-check-report))
(passed (ebox-visual-check-all-passed-p report)))
(princ (ebox-visual-check--format-report report))
(princ (format "\n\n%s\n" (if passed "ALL CHECKS PASSED"
"CHECKS FAILED")))
(when noninteractive
(kill-emacs (if passed 0 1)))))
(provide 'ebox-visual-check)
;;; ebox-visual-check.el ends here

View File

@ -0,0 +1,53 @@
;;; ebox-ci-contract-tests.el --- CI and Makefile contracts -*- lexical-binding: t; -*-
(require 'ert)
(defconst ebox-ci-test--root
(expand-file-name ".." (file-name-directory (or load-file-name buffer-file-name)))
"Repository root used by CI contract tests.")
(defun ebox-ci-test--read (relative-path)
"Return RELATIVE-PATH contents from the repository root."
(with-temp-buffer
(insert-file-contents (expand-file-name relative-path ebox-ci-test--root))
(buffer-string)))
(ert-deftest ebox-ci-exposes-one-low-level-package-boundary ()
"The Makefile should expose Ebox targets without bundling ETAF."
(let ((source (ebox-ci-test--read "Makefile")))
(dolist (target '("check:" "ci:" "load:" "compile:" "test:"
"core-tests:" "grid-tests:" "ebox-commit-tests:"
"examples-tests:" "docs-contract-tests:"
"ci-contract-tests:" "native-rust-tests:"
"native-build:" "diff-check:"))
(should (string-match-p (concat "^" (regexp-quote target)) source)))
(should-not (string-match-p "ETAF" source))
(should-not (string-match-p "etaf" source))))
(ert-deftest ebox-ci-compiles-the-active-source-list ()
"The public compile command should use Ebox's source manifest."
(let ((source (ebox-ci-test--read "Makefile"))
(ebox (ebox-ci-test--read "ebox.el")))
(should (string-match-p "byte-compile-error-on-warn" source))
(should (string-match-p "ebox-byte-compile" source))
(should (string-match-p "ebox-native-reflow.el" ebox))
(should-not (string-match-p "ebox-playground.el" ebox))))
(ert-deftest ebox-ci-prefers-newer-source-and-cleans-local-products ()
"Batch entry points should avoid stale bytecode and global cleanup."
(let ((source (ebox-ci-test--read "Makefile")))
(should (string-match-p "load-prefer-newer t" source))
(should (string-match-p "rm -rf native/target" source))
(should-not (string-match-p "user-emacs-directory" source))))
(ert-deftest ebox-ci-workflow-uses-local-entry-points ()
"GitHub Actions should call the same local checks as maintainers."
(let ((source (ebox-ci-test--read ".github/workflows/ci.yml")))
(should (string-match-p "emacs-nox" source))
(should (string-match-p "make ci" source))
(should (string-match-p (regexp-quote "cargo +stable test") source))
(should (string-match-p (regexp-quote "cargo +1.82.0 test") source))))
(provide 'ebox-ci-contract-tests)
;;; ebox-ci-contract-tests.el ends here

View File

@ -0,0 +1,78 @@
;;; ebox-commit-tests.el --- Declarative commit smoke tests -*- lexical-binding: t; -*-
(require 'cl-lib)
(require 'ert)
(require 'ebox)
(defun ebox-commit-test--buffer-string (buffer)
"Return BUFFER's complete propertized contents."
(with-current-buffer buffer
(save-restriction
(widen)
(buffer-substring (point-min) (point-max)))))
(ert-deftest ebox-commit-publishes-content-change ()
"A declarative commit should publish changed content."
(let* ((buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-commit-content*")
(ebox-create :key 'root :content "Before" :width '(80))))
(report (ebox-commit
buffer
(ebox-create :key 'root :content "After" :width '(80)))))
(unwind-protect
(progn
(should (string-prefix-p
"After"
(string-trim-right
(substring-no-properties
(ebox-commit-test--buffer-string buffer)))))
(should (plist-get report :runtime-published))
(should (> (plist-get report :patch-count) 0)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-commit-preserves-keyed-sibling-identity ()
"Keyed siblings should remain addressable after a reorder commit."
(let* ((buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-commit-keyed*")
(ebox-column
(ebox-create :key 'a :host-ref 'a :content "A" :width '(40))
(ebox-create :key 'b :host-ref 'b :content "B" :width '(40)))))
(old-b (ebox-host-ref-position buffer 'b))
(report (ebox-commit
buffer
(ebox-column
(ebox-create :key 'b :host-ref 'b :content "B2" :width '(40))
(ebox-create :key 'a :host-ref 'a :content "A" :width '(40))))))
(unwind-protect
(progn
(should old-b)
(should (ebox-host-ref-position buffer 'b))
(should (plist-get report :runtime-published))
(should (string-match-p "B2"
(substring-no-properties
(ebox-commit-test--buffer-string buffer)))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-commit-rolls-back-on-invalid-root ()
"A failed candidate must leave the previously published buffer intact."
(let ((buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-commit-rollback*")
(ebox-create :key 'root :content "Stable" :width '(80)))))
(unwind-protect
(progn
(should-error
(ebox-commit buffer (ebox-create :key 'root :content nil
:width 'invalid)))
(should (string-prefix-p
"Stable"
(string-trim-right
(substring-no-properties
(ebox-commit-test--buffer-string buffer))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(provide 'ebox-commit-tests)
;;; ebox-commit-tests.el ends here

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,94 @@
;;; ebox-docs-contract-tests.el --- Documentation contracts -*- lexical-binding: t; -*-
(require 'ert)
(defconst ebox-docs-test--root
(expand-file-name ".." (file-name-directory (or load-file-name buffer-file-name)))
"Repository root used by documentation contract tests.")
(defconst ebox-docs-test--current-docs
'("AGENTS.md" "README.md" "README.zh-CN.md" "DESIGN.md" "DESIGN.zh.md"
"docs/user/ebox-user-guide.en.md" "docs/user/ebox-user-guide.zh.md"
"docs/maintainer/ebox-current-implementation-reference.en.md"
"docs/maintainer/ebox-current-implementation-reference.zh.md"
"docs/maintainer/ebox-incremental-update-contract.en.md"
"docs/maintainer/ebox-incremental-update-contract.zh.md")
"Long-lived documentation files checked for stale path references.")
(defconst ebox-docs-test--active-files
'("Makefile" ".github/workflows/ci.yml"
"ebox.el" "ebox-cache.el" "ebox-style.el" "ebox-tree.el"
"ebox-measure.el" "ebox-fragment.el" "ebox-render-context.el"
"ebox-layout.el" "ebox-flex.el" "ebox-grid.el"
"ebox-buffer-backend.el" "ebox-incremental.el" "ebox-dsl.el"
"ebox-selector.el" "ebox-native-reflow.el"
"examples/ebox-basic-examples.el"
"examples/playground/basic.ebox"
"examples/playground/comprehensive.ebox"
"examples/playground/flex-reference.ebox"
"examples/playground/responsive-reference.ebox"
"tests/ebox-core-render-tests.el" "tests/ebox-grid-tests.el"
"tests/ebox-commit-tests.el" "tests/ebox-examples-tests.el"
"tests/ebox-dsl-tests.el" "tests/ebox-flex-tests.el"
"tests/ebox-selector-tests.el" "tests/ebox-package-tests.el"
"tests/ebox-visual-check-tests.el" "tests/ebox-docs-contract-tests.el"
"tests/ebox-ci-contract-tests.el"
"native/Cargo.toml" "native/Cargo.lock" "native/build.rs"
"native/vendor/emacs-30/emacs-module.h" "native/src/lib.rs"
"native/src/layout.rs" "native/c/ebox_module.c"
"scripts/ebox-package-lint.el" "scripts/ebox-visual-check.el")
"Active Ebox files that the maintainer map must cover.")
(defconst ebox-docs-test--targets
'("check" "ci" "load" "compile" "core-tests" "grid-tests"
"ebox-commit-tests" "visual-check-tests" "package-tests"
"selector-tests" "examples-tests" "dsl-tests" "flex-tests"
"docs-contract-tests" "ci-contract-tests" "visual-check"
"native-rust-tests" "native-build" "package-lint" "diff-check")
"Make targets that must be documented and executable.")
(defun ebox-docs-test--read (relative-path)
"Return RELATIVE-PATH contents from the repository root."
(with-temp-buffer
(insert-file-contents (expand-file-name relative-path ebox-docs-test--root))
(buffer-string)))
(ert-deftest ebox-docs-active-files-exist-and-are-listed ()
"The maintainer reference should describe every active package file."
(let ((reference (ebox-docs-test--read
"docs/maintainer/ebox-current-implementation-reference.en.md")))
(dolist (file ebox-docs-test--active-files)
(should (file-exists-p (expand-file-name file ebox-docs-test--root)))
(should (string-match-p (regexp-quote file) reference)))))
(ert-deftest ebox-docs-verification-targets-match-makefile ()
"Documented verification targets should exist in the Makefile."
(let ((makefile (ebox-docs-test--read "Makefile"))
(reference (ebox-docs-test--read
"docs/maintainer/ebox-current-implementation-reference.en.md")))
(dolist (target ebox-docs-test--targets)
(should (string-match-p (concat "^" (regexp-quote target) ":") makefile))
(should (string-match-p (regexp-quote (concat "make " target))
reference)))))
(ert-deftest ebox-docs-have-paired-language-files ()
"Every long-lived user and maintainer document should have both languages."
(dolist (pair '(("docs/user/ebox-user-guide.en.md"
"docs/user/ebox-user-guide.zh.md")
("docs/maintainer/ebox-current-implementation-reference.en.md"
"docs/maintainer/ebox-current-implementation-reference.zh.md")
("docs/maintainer/ebox-incremental-update-contract.en.md"
"docs/maintainer/ebox-incremental-update-contract.zh.md")))
(dolist (file pair)
(should (file-exists-p (expand-file-name file ebox-docs-test--root))))))
(ert-deftest ebox-docs-do-not-present-legacy-source-as-active ()
"The standalone package documentation must not claim ETAF source ownership."
(dolist (file ebox-docs-test--current-docs)
(let ((source (ebox-docs-test--read file)))
(should-not (string-match-p "`etaf-[[:alnum:]-]+\\.el`" source))
(should-not (string-match-p "`etaf\\.el`" source)))))
(provide 'ebox-docs-contract-tests)
;;; ebox-docs-contract-tests.el ends here

368
tests/ebox-dsl-tests.el Normal file
View File

@ -0,0 +1,368 @@
;;; ebox-dsl-tests.el --- Ebox DSL tests -*- lexical-binding: t; -*-
(require 'ert)
(load-file (expand-file-name "../ebox.el" (file-name-directory (or load-file-name buffer-file-name))))
(defun ebox-dsl-test--plain (node)
"Render NODE and strip text properties."
(substring-no-properties (ebox-render node)))
(defun ebox-dsl-test--line-widths (node)
"Return rendered pixel widths for NODE line by line."
(mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node))))
(defun ebox-dsl-test--display-line-widths (node)
"Return rendered display pixel widths for NODE line by line."
(mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node))))
(ert-deftest ebox-style-expands-ebox-aliases-to-canonical-longhands ()
"Existing Ebox aliases should normalize to CSS-like longhand properties."
(let ((style (ebox-style-compute
(list :bgcolor "#ffffff"
:padding '(1 2)
:margin-left 3
:border "#333333"))))
(should (equal (plist-get style :background-color) "#ffffff"))
(should (= (plist-get style :padding-block-start) 1))
(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 (equal (plist-get style :border-color) "#333333"))))
(ert-deftest ebox-style-classifies-incremental-effects ()
"Style metadata should classify paint and layout effects."
(should (eq (ebox-style-dirty-kind :background-color) 'paint))
(should (eq (ebox-style-dirty-kind :border-color) 'paint))
(should (eq (ebox-style-dirty-kind :padding-inline-start) 'geometry))
(should (eq (ebox-style-dirty-kind :width) 'geometry))
(should (eq (ebox-style-dirty-kind :display) 'structure)))
(ert-deftest ebox-dsl-module-preserves-public-build-syntax ()
"Extracted DSL compiler should preserve existing ebox-build syntax."
(let ((node (ebox-build
'(column :padding 1 :bgcolor "linen"
(row
(box :content "A" :width (40))
(box :content "B" :width (40)))))))
(should (eq (plist-get node :ebox-type) 'box))
(should (string-match-p "A" (ebox-render node)))
(should (string-match-p "B" (ebox-render node)))))
(ert-deftest ebox-build-creates-box-from-keyword-plist ()
"A `(box ...)' form should compile to a normal Ebox box node."
(let ((node (ebox-build '(box :content "Hello" :width (120) :border t))))
(should (eq (plist-get node :ebox-type) 'box))
(should (string-match-p "Hello" (ebox-dsl-test--plain node)))))
(ert-deftest ebox-build-uses-string-child-as-box-content ()
"A simple string child should be shorthand for box content."
(let ((node (ebox-build '(box :width (120) "Hello"))))
(should (eq (plist-get node :ebox-type) 'box))
(should (string-match-p "Hello" (ebox-dsl-test--plain node)))))
(ert-deftest ebox-build-resolves-viewport-width-from-dynamic-binding ()
"The DSL should allow box widths to follow the current render viewport."
(let* ((ebox-viewport-width 320)
(node (ebox-build
'(box :content "Viewport"
:width (viewport)
:box-sizing border-box
:border t))))
(should (= (car (ebox-dsl-test--line-widths node)) 320))))
(ert-deftest ebox-build-resolves-viewport-height-from-dynamic-binding ()
"The DSL should allow box heights to follow the current render viewport."
(let* ((ebox-viewport-height 3)
(node (ebox-build
'(box :content "One\nTwo\nThree\nFour"
:height (viewport-height)
:overflow scroll))))
(should (= (length (ebox-string-lines (ebox-render node))) 3))))
(ert-deftest ebox-build-resolves-viewport-height-arithmetic-expression ()
"The DSL should allow viewport-relative height expressions."
(let* ((ebox-viewport-height 6)
(node (ebox-build
'(box :content "One\nTwo\nThree\nFour"
:height (- (viewport-height) 2)
:overflow scroll))))
(should (= (length (ebox-string-lines (ebox-render node))) 4))))
(ert-deftest ebox-build-composes-fixed-viewport-shell-around-scrollbox ()
"A fixed viewport shell can reserve chrome while an inner box scrolls."
(let* ((ebox-viewport-height 6)
(node (ebox-build
'(column :height (viewport-height)
(box :content "top" :height 1)
(box :id "root" :height (- (viewport-height) 2)
:overflow scroll
(column
(box :content "A" :height 1)
(box :content "B" :height 1)
(box :content "C" :height 1)
(box :content "D" :height 1)
(box :content "E" :height 1)))
(box :content "bottom" :height 1)))))
(should (= (length (ebox-string-lines (ebox-render node))) 6))))
(ert-deftest ebox-build-composes-row-and-column-layouts ()
"Row and column forms should compose existing lazy layout nodes."
(let* ((node (ebox-build
'(column
(box :content "Header" :width (180))
(row
(box :content "Left" :width (80))
(spacer :width (20))
(box :content "Right" :width (80))))))
(plain (ebox-dsl-test--plain node)))
(should (eq (plist-get node :ebox-type) 'stack))
(should (string-match-p "Header" plain))
(should (string-match-p "Left.*Right" plain))
(should (= (length (ebox-region-ids node)) 4))))
(ert-deftest ebox-build-wraps-child-layout-with-box-properties ()
"A node with child layout may still apply box properties to the wrapper."
(let* ((node (ebox-build
'(box :width (240) :box-sizing border-box
:padding (1 20) :border t :bgcolor "#222222"
(row
(box :content "A" :width (80))
(box :content "B" :width (80))))))
(plain (ebox-dsl-test--plain node)))
(should (eq (plist-get node :ebox-type) 'box))
(should (equal (ebox-get node :bgcolor) "#222222"))
(should (string-match-p "A.*B" plain))
(dolist (width (ebox-dsl-test--line-widths node))
(should (= width 240)))))
(ert-deftest ebox-build-box-wrapper-preserves-preformatted-child-layout ()
"A box wrapping child layout should not rewrap rendered children."
(cl-letf (((symbol-function 'require)
(lambda (feature &optional _filename _noerror)
(when (eq feature 'ekp) t)))
((symbol-function 'ekp-pixel-justify)
(lambda (text _width)
(if (string-match-p "\n" text)
"REWRAPPED"
text))))
(let* ((node (ebox-build
'(box :width (220) :box-sizing border-box
(row
(box :content "Left" :width (80) :height 2)
(box :content "Right" :width (80) :height 2)))))
(plain (ebox-dsl-test--plain node)))
(should-not (string-match-p "REWRAPPED" plain))
(should (string-match-p "Left.*Right" plain))
(dolist (width (ebox-dsl-test--line-widths node))
(should (= width 220))))))
(ert-deftest ebox-build-row-wrapper-preserves-preformatted-layout ()
"Row box properties should not rewrap the rendered child layout."
(cl-letf (((symbol-function 'require)
(lambda (feature &optional _filename _noerror)
(when (eq feature 'ekp) t)))
((symbol-function 'ekp-pixel-justify)
(lambda (text _width)
(if (string-match-p "\n" text)
"REWRAPPED"
text))))
(let* ((node (ebox-build
'(row :width (220) :box-sizing border-box
(box :content "Left" :width (80) :height 2)
(box :content "Right" :width (80) :height 2))))
(plain (ebox-dsl-test--plain node)))
(should-not (string-match-p "REWRAPPED" plain))
(should (string-match-p "Left.*Right" plain))
(dolist (width (ebox-dsl-test--line-widths node))
(should (= width 220))))))
(ert-deftest ebox-build-column-wrapper-preserves-preformatted-layout ()
"Column box properties should not rewrap the rendered child layout."
(cl-letf (((symbol-function 'require)
(lambda (feature &optional _filename _noerror)
(when (eq feature 'ekp) t)))
((symbol-function 'ekp-pixel-justify)
(lambda (text _width)
(if (string-match-p "\n" text)
"REWRAPPED"
text))))
(let* ((node (ebox-build
'(column :width (180) :box-sizing border-box
(box :content "Top" :width (120))
(box :content "Bottom" :width (120)))))
(plain (ebox-dsl-test--plain node)))
(should-not (string-match-p "REWRAPPED" plain))
(should (string-match-p "Top" plain))
(should (string-match-p "Bottom" plain))
(dolist (width (ebox-dsl-test--line-widths node))
(should (= width 180))))))
(ert-deftest ebox-build-padded-column-wrapper-stays-inside-viewport ()
"A padded layout wrapper should not let preformatted children overflow."
(let* ((ebox-viewport-width 320)
(node (ebox-build
'(column :padding 1
(box :content "Child width follows the viewport"
:wrap-mode word))))
(widths (ebox-dsl-test--display-line-widths node)))
(should (cl-every (lambda (width)
(<= width ebox-viewport-width))
widths))))
(ert-deftest ebox-build-wrapper-explicit-width-bounds-child-layout-viewport ()
"A wrapper's explicit width should be the containing block for child layouts."
(let* ((ebox-viewport-width 360)
(node (ebox-build
'(column :width (180)
(box :content "alpha beta gamma delta epsilon zeta eta theta"
:wrap-mode word))))
(widths (ebox-dsl-test--line-widths node)))
(dolist (width widths)
(should (<= width 180)))))
(ert-deftest ebox-build-wrapper-viewport-width-bounds-child-layout-viewport ()
"A wrapper's `(viewport)' width should pass its content box to child layouts."
(let ((cases
'((box :width (viewport)
:padding-inline (20)
:border-left (10)
:border-right (10)
(box :content "alpha beta gamma delta epsilon zeta eta theta"
:wrap-mode word))
(column :width (viewport)
:padding-inline (20)
:border-left (10)
:border-right (10)
(box :content "alpha beta gamma delta epsilon zeta eta theta"
:wrap-mode word))
(row :width (viewport)
:padding-inline (20)
:border-left (10)
:border-right (10)
(box :content "alpha beta gamma delta epsilon zeta eta theta"
:wrap-mode word)
(spacer :width (0) :height 1)))))
(dolist (dsl cases)
(let* ((ebox-viewport-width 240)
(node (ebox-build dsl))
(widths (ebox-dsl-test--line-widths node)))
(dolist (width widths)
(should (<= width ebox-viewport-width)))))))
(ert-deftest ebox-build-lazy-scroll-prefix-bounds-wrapper-child-viewport ()
"Lazy scroll prefix rendering should preserve wrapper content viewport."
(let* ((ebox-viewport-width 320)
(ebox-viewport-height 5)
(node (ebox-build
'(box :id "root"
:width (viewport)
:height (- (viewport-height) 1)
:box-sizing border-box
:overflow scroll
(column :width (viewport)
:box-sizing border-box
:padding (1 (18))
(box :content "One"
:width (viewport)
:box-sizing border-box
:border t)
(box :content "Two"
:width (viewport)
:box-sizing border-box
:border t)
(box :content "Three"
:width (viewport)
:box-sizing border-box
:border t)
(box :content "Four"
:width (viewport)
:box-sizing border-box
:border t)))))
(widths (ebox-dsl-test--line-widths node)))
(dolist (width widths)
(should (<= width ebox-viewport-width)))))
(ert-deftest ebox-build-tags-map-to-css-like-display ()
"DSL container tags should expose CSS-like outer/inner display values."
(let ((box-node (ebox-build '(box :content "A")))
(row-node (ebox-build '(row (box :content "A") (box :content "B"))))
(column-node (ebox-build
'(column (box :content "A") (box :content "B"))))
(flex-node (ebox-build '(flex (box :content "A") (box :content "B")))))
(should (equal (ebox--computed-display box-node) '(block flow)))
(should (equal (ebox--computed-display row-node) '(block row)))
(should (equal (ebox--computed-display column-node) '(block column)))
(should (equal (ebox--computed-display flex-node) '(block flex)))))
(ert-deftest ebox-build-wrapper-child-layout-rerenders-on-runtime-viewport-change ()
"A DSL wrapper box should rerender child layout content on viewport resize."
(let ((node (let ((ebox-viewport-width 96))
(ebox-build
'(column :padding 1 :bgcolor "linen"
(box :content "This child text should wrap again when the preview becomes narrow."
:wrap-mode word)))))
buffer)
(unwind-protect
(progn
(setq buffer
(let ((ebox-viewport-width 96))
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-dsl-test*")
node)))
(let ((wide-text (with-current-buffer buffer
(buffer-substring-no-properties
(point-min) (point-max))))
(wide-height (with-current-buffer buffer
(count-lines (point-min) (point-max)))))
(ebox-rerender-buffer-with-context buffer 36)
(let ((narrow-text (with-current-buffer buffer
(buffer-substring-no-properties
(point-min) (point-max))))
(narrow-height (with-current-buffer buffer
(count-lines (point-min) (point-max)))))
(should-not (string= wide-text narrow-text))
(should (> narrow-height wide-height)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-build-item-is-flex-participation-sugar ()
"DSL item should attach flex participation to its child semantics."
(let* ((node (ebox-build
'(flex :width (300)
(item :flex 1 (box :content "A"))
(box :content "B" :flex 1))))
(rendered (let ((ebox-viewport-width 300))
(ebox-render node))))
(should (string-match-p "A" (substring-no-properties rendered)))
(should (string-match-p "B" (substring-no-properties rendered)))))
(ert-deftest ebox-build-keeps-pre-render-region-ids-available ()
"The DSL should not break the pre-render region-id workflow."
(let* ((node (ebox-build
'(row
(box :content "One" :width (80))
(box :content "Two" :width (80)))))
(ids (ebox-region-ids node)))
(should (= (length ids) 2))
(should (cl-every #'identity ids))))
(ert-deftest ebox-build-wrapper-region-ids-include-child-layout ()
"A wrapper box should keep both its own region id and child layout ids."
(let* ((node (ebox-build
'(column :padding 1
(box :content "One")
(box :content "Two"))))
(ids (ebox-region-ids node)))
(should (= (length ids) 3))
(should (cl-every #'identity ids))))
(ert-deftest ebox-build-rejects-invalid-dsl ()
"Bad forms should fail loudly instead of producing surprising nodes."
(should-error (ebox-build '(unknown :content "Nope")) :type 'error)
(should-error (ebox-build '(box :content)) :type 'error)
(should-error (ebox-build '(spacer :width (20) (box :content "bad"))) :type 'error))
(provide 'ebox-dsl-tests)
;;; ebox-dsl-tests.el ends here

View File

@ -0,0 +1,32 @@
;;; ebox-examples-tests.el --- Ebox example contracts -*- lexical-binding: t; -*-
(require 'ert)
(require 'ebox)
(defconst ebox-examples-test--root
(expand-file-name ".." (file-name-directory (or load-file-name buffer-file-name)))
"Repository root used by example tests.")
(load-file (expand-file-name "examples/ebox-basic-examples.el"
ebox-examples-test--root))
(ert-deftest ebox-basic-example-builds-a-public-tree ()
"The basic example should build through public constructors."
(let ((node (ebox-basic-examples-build)))
(should (listp node))
(should (string-match-p "Ebox"
(substring-no-properties (ebox-render node))))))
(ert-deftest ebox-dsl-examples-render-without-playground-code ()
"The maintained DSL fixtures should render through the Ebox facade."
(dolist (file '("basic.ebox" "comprehensive.ebox" "flex-reference.ebox"
"responsive-reference.ebox"))
(let ((form
(with-temp-buffer
(insert-file-contents
(expand-file-name (concat "examples/playground/" file)
ebox-examples-test--root))
(read (current-buffer)))))
(should (stringp (ebox-render (ebox-build form)))))))
(provide 'ebox-examples-tests)
;;; ebox-examples-tests.el ends here

1222
tests/ebox-flex-tests.el Normal file

File diff suppressed because it is too large Load Diff

154
tests/ebox-grid-tests.el Normal file
View File

@ -0,0 +1,154 @@
;;; ebox-grid-tests.el --- Ebox grid layout tests -*- lexical-binding: t; -*-
(require 'ert)
(require 'seq)
(load-file (expand-file-name "../ebox.el"
(file-name-directory (or load-file-name
buffer-file-name))))
(defun ebox-grid-test--plain (node)
"Render NODE and remove text properties."
(substring-no-properties (ebox-render node)))
(ert-deftest ebox-grid-renders-row-major-cells ()
"Grid should place unpositioned children in row-major order."
(let ((plain
(ebox-grid-test--plain
(ebox-grid
:grid-template-columns '((20) (20))
:grid-template-rows '(1 1)
:gap '(0 (1))
(ebox-create :content "A")
(ebox-create :content "B")
(ebox-create :content "C")
(ebox-create :content "D")))))
(should (string-match-p "A.*B" plain))
(should (string-match-p "C.*D" plain))))
(ert-deftest ebox-grid-supports-fractional-columns ()
"Fractional tracks should fill a definite grid width."
(let* ((node (ebox-grid
:width '(120)
:grid-template-columns '(1fr 2fr)
(ebox-create :content "A")
(ebox-create :content "B")))
(lines (ebox-string-lines (ebox-render node))))
(should (= (length lines) 1))
(should (= (ebox--string-pixel-width (car lines)) 120))))
(ert-deftest ebox-grid-supports-explicit-placement-and-span ()
"Explicit placement should support a cell spanning two columns."
(let ((plain
(ebox-grid-test--plain
(ebox-grid
:grid-template-columns '((20) (20))
:grid-template-rows '(1 1)
(ebox-grid-item (ebox-create :content "Header")
:grid-column '(1 :span 2))
(ebox-grid-item (ebox-create :content "Left")
:grid-row 2 :grid-column 1)
(ebox-grid-item (ebox-create :content "Right")
:grid-row 2 :grid-column 2)))))
(should (string-match-p "Header" plain))
(should (string-match-p "Left.*Right" plain))))
(ert-deftest ebox-grid-uses-implicit-track-templates ()
"Implicit columns should use `:grid-auto-columns' when they grow."
(let* ((node (ebox-grid
:grid-template-columns '((20))
:grid-auto-columns '((30))
:grid-auto-flow 'column
(ebox-create :content "A")
(ebox-create :content "B")))
(line (car (ebox-string-lines (ebox-render node)))))
(should (= (ebox--string-pixel-width line) 50))))
(ert-deftest ebox-grid-preserves-template-width-when-rows-grow ()
"Implicit rows should retain the declared template column count."
(let ((plain
(ebox-grid-test--plain
(ebox-grid
:grid-template-columns '((20) (20) (20))
(ebox-create :content "A")
(ebox-create :content "B")
(ebox-create :content "C")
(ebox-create :content "D")
(ebox-create :content "E")
(ebox-create :content "F")))))
(dolist (letter '("A" "B" "C" "D" "E" "F"))
(should (string-match-p letter plain)))))
(ert-deftest ebox-grid-supports-repeat-minmax-and-content-alignment ()
"Grid should normalize repeat/minmax tracks and align fixed tracks."
(let* ((node (ebox-grid
:width '(100)
:justify-content 'center
:grid-template-columns '((minmax (20) (30)) (repeat 1 (20)))
(ebox-create :content "A")
(ebox-create :content "B")))
(line (car (ebox-string-lines (ebox-render node)))))
(should (= (ebox--string-pixel-width line) 100))
(should (= (length (seq-filter (lambda (char) (= char ?\s)) line))
4))))
(ert-deftest ebox-grid-validates-flow-and-spans ()
"Invalid flow and non-positive spans should fail at render time."
(should-error
(ebox-render
(ebox-grid :grid-auto-flow 'diagonal (ebox-create :content "A"))))
(should-error
(ebox-render
(ebox-grid
(ebox-grid-item (ebox-create :content "A") :grid-column-span 0))))
(should-error
(ebox-render
(ebox-grid
(ebox-grid-item (ebox-create :content "A") :grid-column 0)))))
(ert-deftest ebox-grid-region-update-reflows-grid-child ()
"Grid children should retain region updates through buffer rendering."
(let* ((left (ebox-create :content "A" :id "left" :width 20))
(right (ebox-create :content "B" :id "right" :width 20))
(node (ebox-grid :grid-template-columns '((20) (20)) left right))
(buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-grid-update*") node))
(region-id (car (ebox-region-ids left))))
(unwind-protect
(progn
(should (with-current-buffer buffer
(and (string-match-p "A" (buffer-string))
(string-match-p "B" (buffer-string)))))
(with-current-buffer buffer
(ebox-region-update region-id :content "Updated"))
(let ((updated (with-current-buffer buffer (buffer-string))))
(should (string-match-p "Updated" updated))
(should (string-match-p "B" updated))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-build-compiles-grid-layout ()
"The public Ebox DSL should compile a grid node."
(let ((node
(ebox-build
'(grid :grid-template-columns ((20) (20))
(box :content "A")
(box :content "B")))))
(should (eq (plist-get node :ebox-type) 'grid))
(should (string-match-p "A" (ebox-grid-test--plain node)))
(should (string-match-p "B" (ebox-grid-test--plain node)))))
(ert-deftest ebox-build-compiles-grid-item-placement ()
"The Ebox DSL should preserve grid-item placement metadata."
(let ((node
(ebox-build
'(grid :grid-template-columns ((20) (20))
(grid-item :grid-column (1 :span 2)
(box :content "Header"))))))
(should (string-match-p "Header" (ebox-grid-test--plain node)))
(should (equal (plist-get (car (plist-get node :children)) :grid-column)
'(1 :span 2)))))
(provide 'ebox-grid-tests)
;;; ebox-grid-tests.el ends here

150
tests/ebox-package-tests.el Normal file
View File

@ -0,0 +1,150 @@
;;; ebox-package-tests.el --- Package contract tests -*- lexical-binding: t; -*-
(require 'cl-lib)
(require 'ert)
(defconst ebox-test--root
(expand-file-name ".." (file-name-directory (or load-file-name buffer-file-name)))
"Repository root used by package contract tests.")
(load-file (expand-file-name "ebox.el" ebox-test--root))
(defconst ebox-test--autoload-source-files
'("ebox.el"
"ebox-measure.el"
"ebox-layout.el"
"ebox-flex.el"
"ebox-grid.el"
"ebox-native-reflow.el")
"Source files that currently own public autoload entry points.")
(defun ebox-test--source-text ()
"Return combined source text for files that own public autoloads."
(with-temp-buffer
(dolist (file ebox-test--autoload-source-files)
(insert-file-contents (expand-file-name file ebox-test--root))
(insert "\n"))
(buffer-string)))
(ert-deftest ebox-package-defines-custom-group-and-scroll-option ()
"The package exposes a real customization group and default scroll option."
(should (get 'ebox 'custom-group))
(should (custom-variable-p 'ebox-scroll-step))
(should (integerp ebox-scroll-step))
(should (> ebox-scroll-step 0))
(should (custom-variable-p 'ebox-wheel-scroll-step))
(should (integerp ebox-wheel-scroll-step))
(should (> ebox-wheel-scroll-step 0))
(should (custom-variable-p 'ebox-wheel-smooth-scroll))
(should (custom-variable-p 'ebox-wheel-smooth-scroll-interval))
(should (numberp ebox-wheel-smooth-scroll-interval))
(should (> ebox-wheel-smooth-scroll-interval 0))
(should (custom-variable-p 'ebox-wheel-smooth-scroll-lines-per-tick))
(should (integerp ebox-wheel-smooth-scroll-lines-per-tick))
(should (> ebox-wheel-smooth-scroll-lines-per-tick 0)))
(ert-deftest ebox-facade-loads-internal-model-modules ()
"The public ebox facade should load all internal foundation modules."
(require 'ebox)
(dolist (feature '(ebox-cache ebox-style ebox-tree ebox-measure
ebox-fragment ebox-layout ebox-flex ebox-grid
ebox-buffer-backend ebox-incremental
ebox-dsl ebox-selector))
(should (featurep feature))))
(ert-deftest ebox-facade-exposes-native-workflow-without-loading-it ()
"The two native commands should be available without native load work."
(require 'ebox)
(should (autoloadp (symbol-function 'ebox-native-build)))
(should (autoloadp (symbol-function 'ebox-native-status)))
(should-not (featurep 'ebox-native-reflow)))
(ert-deftest ebox-byte-compile-is-an-interactive-public-command ()
"Users should be able to rebuild all active Ebox bytecode from Emacs."
(should (commandp 'ebox-byte-compile))
(should
(equal ebox--compile-sources
'("ebox-cache.el" "ebox-style.el" "ebox-tree.el" "ebox-measure.el"
"ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el"
"ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-incremental.el"
"ebox-dsl.el" "ebox-selector.el" "ebox.el"
"ebox-native-reflow.el"))))
(ert-deftest ebox-byte-compile-rebuilds-the-declared-source-set ()
"The interactive command should recompile every declared source in order."
(require 'bytecomp)
(let (compiled)
(unwind-protect
(cl-letf (((symbol-function 'byte-compile-file)
(lambda (file &optional _load)
(push (file-name-nondirectory file) compiled)
t))
((symbol-function 'message) #'ignore))
(should (ebox-byte-compile))
(should (equal (nreverse compiled) ebox--compile-sources)))
(when-let ((buffer (get-buffer "*Ebox Byte Compile*")))
(kill-buffer buffer)))))
(ert-deftest ebox-package-includes-native-build-source ()
"Source-only distribution should contain every native build input."
(dolist (file '("native/Cargo.toml"
"native/Cargo.lock"
"native/build.rs"
"native/src/lib.rs"
"native/src/layout.rs"
"native/c/ebox_module.c"
"native/vendor/emacs-30/emacs-module.h"))
(should (file-readable-p (expand-file-name file ebox-test--root)))))
(ert-deftest ebox-public-entry-points-have-autoload-cookies ()
"Stable public entry points should be discoverable by package autoloads."
(let ((source (ebox-test--source-text)))
(dolist (entry '(("ebox-clear-cache" . "defun")
("ebox-render" . "defun")
("ebox-region-ids" . "defun")
("ebox-concat" . "defun")
("ebox-spacer" . "defun")
("ebox-row" . "defun")
("ebox-stack" . "defun")
("ebox-column" . "defun")
("ebox-flex-item" . "defun")
("ebox-flex" . "defun")
("ebox-grid-fr" . "defun")
("ebox-grid-item" . "defun")
("ebox-grid" . "defun")
("ebox-build" . "defun")
("ebox-create" . "defun")
("ebox-scroll-down" . "defun")
("ebox-scroll-up" . "defun")
("ebox-region-update" . "defun")
("ebox-buffer-update-report" . "defun")
("ebox-byte-compile" . "defun")
("ebox-native-build" . "defun")
("ebox-native-status" . "defun")
("ebox-buffer-mode" . "define-minor-mode")
("ebox-render-to-buffer" . "defun")
("ebox-display-buffer" . "defun")))
(should
(string-match-p
(format ";;;###autoload[[:space:]\n]+(%s %s\\_>"
(regexp-quote (cdr entry))
(regexp-quote (car entry)))
source)))))
(ert-deftest ebox-package-exposes-buffer-report-accessor-only ()
"The package surface should expose buffer-owned reports, not a global API."
(require 'ebox)
(should (fboundp 'ebox-buffer-update-report))
(should-not (fboundp 'ebox-last-update-report))
(let ((source (ebox-test--source-text)))
(should
(string-match-p
";;;###autoload[[:space:]\n]+(defun ebox-buffer-update-report\\_>"
source))
(should-not
(string-match-p
";;;###autoload[[:space:]\n]+(defun ebox-last-update-report\\_>"
source))))
(provide 'ebox-package-tests)
;;; ebox-package-tests.el ends here

View File

@ -0,0 +1,384 @@
;;; ebox-selector-tests.el --- Selector query tests -*- lexical-binding: t; -*-
(require 'ert)
(require 'cl-lib)
(load-file (expand-file-name "../ebox.el"
(file-name-directory
(or load-file-name buffer-file-name))))
(require 'ebox-selector)
(defun ebox-selector-test--reset-runtime-state ()
"Reset runtime counters and tables for selector tests."
(setq ebox--region-id-counter 0)
(setq ebox--runtime-node-id-counter 0)
(setq ebox--render-region-id nil)
(dolist (table (list ebox--region-box-table
ebox--box-extents
ebox--buffer-render-state-table
ebox--scroll-global-state))
(clrhash table)))
(defun ebox-selector-test--width-unit-form (width)
"Return a layout whose root WIDTH constrains nested auto layout."
`(column :id root :width ,width
(box :content "Header text wraps when the root is narrow"
:padding (0 (12)) :border "#111111")
(flex :flex-flow (row wrap) :column-gap (12)
:padding (0 (12)) :border "#D97757"
(column
(box :content "row" :width (220) :border "#D97757")
(flex :width (220) :height 4 :border "#D97757"
(item (box :content "A" :width (42)))
(item (box :content "B" :width (42)))
(item (box :content "C" :width (42)))))
(column
(box :content "row-reverse" :width (220) :border "#D97757")
(flex :width (220) :height 4 :border "#D97757"
(item (box :content "A" :width (42)))
(item (box :content "B" :width (42)))
(item (box :content "C" :width (42))))))))
(defun ebox-selector-test--rendered-widths (rendered)
"Return pixel widths for RENDERED lines."
(mapcar #'ebox--string-pixel-width (ebox-string-lines rendered)))
(ert-deftest ebox-selector-parses-type-id-class-and-attributes ()
"Selectors should parse type, id, class, and attribute predicates."
(should (equal (ebox-selector-parse "box.warning#summary[key=main]")
'(:sequence
((:type box :id "summary" :classes ("warning")
:attrs ((:key . "main"))))))))
(ert-deftest ebox-selector-parses-child-and-descendant-combinators ()
"Selectors should preserve child and descendant combinator order."
(should (equal (ebox-selector-parse ".panel > box .label")
'(:sequence
((:classes ("panel"))
:child
(:type box)
:descendant
(:classes ("label")))))))
(ert-deftest ebox-selector-queries-tree-by-id-class-and-type ()
"Selector query should match metadata on the element tree."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column :id root :class page
(box :id summary :class (card warning) :content "A")
(box :class card :content "B"))))
(matches (ebox-selector-query-all node ".warning")))
(should (= (length matches) 1))
(should (equal (plist-get (car matches) :region-id)
(car (ebox-region-ids
(plist-get (car matches) :node)))))))
(ert-deftest ebox-selector-queries-child-and-descendant-paths ()
"Selector query should support child and descendant combinators."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column :class page
(row :class toolbar
(box :class action :content "Run"))
(box :class action :content "Loose")))))
(should (= (length (ebox-selector-query-all node ".toolbar > box")) 1))
(should (= (length (ebox-selector-query-all node ".page .action")) 2))))
(ert-deftest ebox-selector-queries-rendered-buffer-runtime ()
"Selector query should return handles owned by a rendered buffer."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column
(box :id summary :class card :content "Summary")
(box :class card :content "Other"))))
buffer)
(unwind-protect
(progn
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(let ((matches (ebox-selector-query-buffer buffer ".card")))
(should (= (length matches) 2))
(should (cl-every (lambda (match)
(and (plist-get match :node-id)
(plist-get match :region-id)
(eq (plist-get match :buffer) buffer)))
matches))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-selector-buffer-id-query-uses-runtime-index ()
"Rendered buffer #id queries should not walk the whole runtime tree."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column
(box :id summary :class card :content "Summary")
(box :class card :content "Other"))))
buffer)
(unwind-protect
(progn
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(cl-letf (((symbol-function 'ebox-selector--walk)
(lambda (&rest _)
(error "id query should use runtime index"))))
(let ((matches (ebox-selector-query-buffer buffer "#summary")))
(should (= (length matches) 1))
(should (equal (plist-get (car matches) :buffer) buffer)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-selector-buffer-class-query-uses-runtime-index ()
"Rendered buffer .class queries should not walk the whole runtime tree."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column
(box :id summary :class (card warning) :content "Summary")
(box :class card :content "Other"))))
buffer)
(unwind-protect
(progn
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(cl-letf (((symbol-function 'ebox-selector--walk)
(lambda (&rest _)
(error "class query should use runtime index"))))
(let ((matches (ebox-selector-query-buffer buffer ".card")))
(should (= (length matches) 2))
(should (cl-every (lambda (match)
(equal (plist-get match :buffer) buffer))
matches)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-selector-buffer-descendant-query-uses-runtime-index ()
"Rendered buffer descendant class queries should start from indexed leaves."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column :class page
(row :class toolbar
(box :class action :content "Run"))
(box :class action :content "Loose"))))
buffer)
(unwind-protect
(progn
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(cl-letf (((symbol-function 'ebox-selector--walk)
(lambda (&rest _)
(error "descendant query should use runtime index"))))
(let ((matches (ebox-selector-query-buffer buffer ".page .action")))
(should (= (length matches) 2))
(should (cl-every (lambda (match)
(plist-get match :region-id))
matches)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-selector-buffer-handles-survive-viewport-rerender ()
"Selector query should resolve current handles after viewport rerender."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column
(box :id card
:content "Text wraps when narrow"
:wrap-mode word))))
buffer)
(unwind-protect
(progn
(setq buffer (let ((ebox-viewport-width 360))
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node)))
(let* ((before (car (ebox-selector-query-buffer buffer "#card")))
(node-id (plist-get before :node-id)))
(ebox-rerender-buffer-with-context buffer 180)
(let ((after (car (ebox-selector-query-buffer buffer "#card"))))
(should (equal (plist-get after :node-id) node-id))
(should (plist-get after :region-id)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-selector-update-buffer-uses-paint-patch-for-paint-style ()
"Selector updates should route paint-only changes through paint patches."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column
(box :id card :content "Card" :border "#111111"))))
buffer)
(unwind-protect
(progn
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(let* ((result (ebox-selector-update-buffer
buffer "#card" :border-color "#ef4444"))
(report (car (plist-get result :reports))))
(should (eq (plist-get report :strategy) 'paint-patch))
(should (equal (plist-get report :patch-ops) '(paint-patch)))
(should (equal report (ebox-buffer-update-report buffer)))
(should-not (plist-get report :full-rerender))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-selector-update-buffer-adds-absent-properties ()
"Selector updates should add valid box properties that were initially absent."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column
(box :id card :content "Card"))))
buffer)
(unwind-protect
(progn
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(let ((result (ebox-selector-update-buffer
buffer "#card" :padding '(1 2) :bgcolor "#fef3c7")))
(should (= (plist-get result :matched) 1))
(should (= (plist-get result :updated) 1)))
(let* ((match (car (ebox-selector-query-buffer buffer "#card")))
(node (plist-get match :node)))
(should (equal (ebox-get node :bgcolor) "#fef3c7"))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-selector-width-unit-change-invalidates-render-cache ()
"Changing width units should not reuse stale containing-block output."
(ebox-selector-test--reset-runtime-state)
(let (buffer live-widths direct-widths)
(cl-letf (((symbol-function 'ebox--space-pixel-width)
(lambda () 7)))
(unwind-protect
(let ((ebox-viewport-width 1200))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
(ebox-build
(ebox-selector-test--width-unit-form '(550)))))
(ebox-selector-update-buffer buffer "#root" :width 300)
(ebox-selector-update-buffer buffer "#root" :width '(300))
(with-current-buffer buffer
(setq live-widths
(ebox-selector-test--rendered-widths
(buffer-substring (point-min) (point-max)))))
(setq direct-widths
(ebox-selector-test--rendered-widths
(ebox-render
(ebox-build
(ebox-selector-test--width-unit-form '(300))))))
(should (equal live-widths direct-widths)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))))))
(ert-deftest ebox-selector-update-buffer-reports-target-buffer-direct-update ()
"Single selector updates should return the target buffer's direct report."
(ebox-selector-test--reset-runtime-state)
(let* ((target-node (ebox-build
'(column
(box :id target-card :content "Target"))))
(other-node (ebox-build
'(column
(box :id other-card :content "Other"))))
target-buffer other-buffer)
(unwind-protect
(progn
(setq target-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-target*")
target-node))
(setq other-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-other*")
other-node))
(let ((calls nil))
(cl-letf (((symbol-function 'ebox-region-update)
(lambda (region-id &rest _props)
(let ((buffer ebox--region-update-buffer-hint))
(push (cons region-id buffer) calls)
(list :buffer buffer
:region-id region-id
:strategy 'stub-direct)))))
(ebox-selector-update-buffer
other-buffer "#other-card" :border-color "#0f766e")
(let* ((result (ebox-selector-update-buffer
target-buffer "#target-card"
:border-color "#ef4444"))
(report (car (plist-get result :reports))))
(should (eq (plist-get report :buffer) target-buffer))
(should (eq (plist-get report :strategy) 'stub-direct))
(should (eq (cdar calls) target-buffer))))))
(when (and target-buffer (buffer-live-p target-buffer))
(kill-buffer target-buffer))
(when (and other-buffer (buffer-live-p other-buffer))
(kill-buffer other-buffer)))))
(ert-deftest ebox-selector-update-buffer-does-not-write-handle-node-to-global-table ()
"Selector updates should leave process-global region ownership unchanged."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column
(box :id card :content "Card"))))
buffer)
(unwind-protect
(progn
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(let* ((match (car (ebox-selector-query-buffer buffer "#card")))
(region-id (plist-get match :region-id))
(poisoned-entry (list :poisoned-region region-id)))
(puthash region-id poisoned-entry ebox--region-box-table)
(cl-letf (((symbol-function 'ebox-region-update)
(lambda (called-region-id &rest _props)
(should (eq called-region-id region-id))
(should (eq (gethash region-id ebox--region-box-table)
poisoned-entry))
(list :strategy 'stub-direct))))
(let* ((result (ebox-selector-update-buffer
buffer "#card" :border-color "#ef4444"))
(report (car (plist-get result :reports))))
(should (eq (plist-get report :strategy) 'stub-direct))))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-selector-update-buffer-multi-match-uses-flush-return ()
"Multi-match selector updates should report `ebox-incremental-flush' result."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column
(box :class card :content "One")
(box :class card :content "Two"))))
buffer)
(unwind-protect
(progn
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(let ((direct-reports nil)
(flush-report (list :strategy 'stub-batch
:patch-ops '(owner-rerender)
:batched-count 2)))
(cl-letf (((symbol-function 'ebox-region-update)
(lambda (region-id &rest _props)
(push (list :region-id region-id
:strategy 'wrong-direct)
direct-reports)))
((symbol-function 'ebox-incremental-flush)
(lambda (flush-buffer)
(should (eq flush-buffer buffer))
flush-report)))
(let ((result (ebox-selector-update-buffer
buffer ".card" :border-color "#ef4444")))
(should (= (plist-get result :updated) 2))
(should (= (length direct-reports) 2))
(should (equal (plist-get result :reports)
(list flush-report)))))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(provide 'ebox-selector-tests)
;;; ebox-selector-tests.el ends here

View File

@ -0,0 +1,90 @@
;;; ebox-visual-check-tests.el --- Tests for visual check tooling -*- lexical-binding: t; -*-
(require 'ert)
(require 'cl-lib)
(unless load-file-name
(error "This test file must be loaded from disk, not eval'ed directly"))
(load-file (expand-file-name "../scripts/ebox-visual-check.el"
(file-name-directory load-file-name)))
(ert-deftest ebox-visual-check-report-passes-all-checks ()
(let ((report (ebox-visual-check-report)))
(should (listp report))
(should (cl-plusp (length report)))
(dolist (scenario report)
(should (plist-get scenario :name))
(should (plist-get scenario :metrics))
(dolist (check (plist-get scenario :checks))
(should (plist-get check :ok))))))
(ert-deftest ebox-visual-check-renders-buffer-for-gui-inspection ()
(let ((buffer (ebox-visual-check-render-buffer)))
(unwind-protect
(with-current-buffer buffer
(should (string-match-p "Ebox Visual Check"
(buffer-substring-no-properties
(point-min) (point-max))))
(should buffer-read-only))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(defun ebox-test--write-ppm (file pixels)
"Write a small P3 PPM FILE from PIXELS.
PIXELS is a list of rows; each row is a list of (R G B) triples."
(let ((height (length pixels))
(width (length (car pixels))))
(with-temp-file file
(insert (format "P3\n%d %d\n255\n" width height))
(dolist (row pixels)
(dolist (pixel row)
(insert (format "%d %d %d " (nth 0 pixel) (nth 1 pixel) (nth 2 pixel))))
(insert "\n")))))
(ert-deftest ebox-visual-check-compares-identical-ppm-region ()
(let* ((dir (make-temp-file "ebox-visual-check-" t))
(expected (expand-file-name "expected.ppm" dir))
(actual (expand-file-name "actual.ppm" dir)))
(unwind-protect
(progn
(ebox-test--write-ppm
expected
'(((0 0 0) (10 10 10) (20 20 20))
((30 30 30) (40 40 40) (50 50 50))))
(ebox-test--write-ppm
actual
'(((0 0 0) (10 10 10) (20 20 20))
((30 30 30) (40 40 40) (50 50 50))))
(let ((result (ebox-visual-check-compare-region
expected actual
(list :x 1 :y 0 :width 2 :height 2))))
(should (plist-get result :ok))
(should (= (plist-get result :mismatches) 0))
(should (= (plist-get result :samples) 4))))
(delete-directory dir t))))
(ert-deftest ebox-visual-check-detects-ppm-region-mismatch ()
(let* ((dir (make-temp-file "ebox-visual-check-" t))
(expected (expand-file-name "expected.ppm" dir))
(actual (expand-file-name "actual.ppm" dir)))
(unwind-protect
(progn
(ebox-test--write-ppm
expected
'(((0 0 0) (10 10 10))
((20 20 20) (30 30 30))))
(ebox-test--write-ppm
actual
'(((0 0 0) (10 10 10))
((20 20 20) (99 30 30))))
(let ((result (ebox-visual-check-compare-region
expected actual
(list :x 0 :y 0 :width 2 :height 2)
5)))
(should-not (plist-get result :ok))
(should (= (plist-get result :mismatches) 1))
(should (= (plist-get result :max-delta) 69))))
(delete-directory dir t))))
;;; ebox-visual-check-tests.el ends here