commit 8a8e862098f907dea7a2d0334024c60e3eff37b8 Author: Kinneyzhang Date: Wed Aug 5 09:15:35 2026 +0800 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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d7ec750 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e9eab9 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..809adcf --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..e0ed31b --- /dev/null +++ b/DESIGN.md @@ -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`. diff --git a/DESIGN.zh.md b/DESIGN.zh.md new file mode 100644 index 0000000..1e9cfe8 --- /dev/null +++ b/DESIGN.zh.md @@ -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`。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..651b025 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..412ed9c --- /dev/null +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..18e0155 --- /dev/null +++ b/README.zh-CN.md @@ -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)。 diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md new file mode 100644 index 0000000..93fd78f --- /dev/null +++ b/docs/maintainer/ebox-current-implementation-reference.en.md @@ -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. diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md new file mode 100644 index 0000000..d3650d6 --- /dev/null +++ b/docs/maintainer/ebox-current-implementation-reference.zh.md @@ -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 构建。 diff --git a/docs/maintainer/ebox-incremental-update-contract.en.md b/docs/maintainer/ebox-incremental-update-contract.en.md new file mode 100644 index 0000000..6213882 --- /dev/null +++ b/docs/maintainer/ebox-incremental-update-contract.en.md @@ -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`. diff --git a/docs/maintainer/ebox-incremental-update-contract.zh.md b/docs/maintainer/ebox-incremental-update-contract.zh.md new file mode 100644 index 0000000..a09b2cf --- /dev/null +++ b/docs/maintainer/ebox-incremental-update-contract.zh.md @@ -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 位置绑定于 generation;buffer 变更后调用者必须通过公共 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`。 diff --git a/docs/user/ebox-user-guide.en.md b/docs/user/ebox-user-guide.en.md new file mode 100644 index 0000000..fe27b0e --- /dev/null +++ b/docs/user/ebox-user-guide.en.md @@ -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 1–4-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. diff --git a/docs/user/ebox-user-guide.zh.md b/docs/user/ebox-user-guide.zh.md new file mode 100644 index 0000000..2e6cf43 --- /dev/null +++ b/docs/user/ebox-user-guide.zh.md @@ -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 风格的 1–4 值。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 和 ref;selector 适合检查和有限更新。 + +## 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`。 diff --git a/ebox-buffer-backend.el b/ebox-buffer-backend.el new file mode 100644 index 0000000..765f4d4 --- /dev/null +++ b/ebox-buffer-backend.el @@ -0,0 +1,5033 @@ +;;; ebox-buffer-backend.el --- Emacs buffer backend for Ebox -*- lexical-binding: t; -*- + +;;; Commentary: +;; Owns text insertion, text properties, spans, markers, buffer modes, and +;; backend patch primitives. It does not own computed style semantics. + +;;; Code: + +(require 'cl-lib) +(require 'subr-x) +(require 'ebox-fragment) +(require 'ebox-render-context) + +(defvar ebox-region-types) +(defvar ebox--box-extents) +(defvar ebox--defer-scroll-content-index) +(defvar ebox--region-box-table) +(defvar ebox--propertize-private-content-line-p nil + "Non-nil allows ownership to be added to fresh content lines in place.") +(declare-function ebox--buffer-region-role-span-table + "ebox-incremental" (buffer)) +(declare-function ebox--clear-buffer-extents + "ebox" (&optional buffer preserve-template)) +(declare-function ebox--install-box-extent-template + "ebox" (buffer template start)) +(declare-function ebox--preflight-scroll-content-span-template + "ebox" (buffer template start target-length)) +(declare-function ebox--refresh-patched-scroll-markers + "ebox" + (buffer affected-region-ids published-spans + &optional scroll-content-template root-start)) +(declare-function ebox--register-box-extents-in-range + "ebox" (start end)) +(declare-function ebox--set-box-extents + "ebox" (region-id start end)) +(declare-function ebox--ensure-node-id + "ebox" (node)) +(declare-function ebox--region-id-set + "ebox" (region-ids)) +(declare-function ebox--maplines + "ebox" (function string)) +(declare-function ebox--string-repeat-lines + "ebox" (string count)) +(declare-function ebox-render + "ebox" (node)) +(declare-function ebox-lines-join + "ebox" (lines)) +(declare-function ebox-string-height + "ebox" (string)) +(declare-function ebox-string-lines + "ebox" (string)) +(declare-function ebox--set-buffer-region-role-span-table + "ebox-incremental" (buffer table)) +(declare-function ebox--external-footprint-compatible-p + "ebox-incremental" (old new)) +(declare-function ebox--external-footprint-signature-from-span-footprint + "ebox-incremental" (signature)) +(declare-function ebox--parent-slot-compatible-p + "ebox-incremental" (old new)) +(declare-function ebox--project-parent-slot-signature + "ebox-incremental" (old-parent-slot new-span-footprint)) +(declare-function ebox--rendered-span-footprint-signature + "ebox-incremental" (rendered)) +(declare-function ebox--span-footprint-compatible-p + "ebox-incremental" (old new)) +(declare-function ebox--runtime-child-under-ancestor + "ebox-incremental" (buffer ancestor-id node-id)) +(declare-function ebox--dirty-provenance-node-ids + "ebox-incremental" (dirty)) +(declare-function ebox--buffer-runtime-node + "ebox-incremental" (buffer node-id)) +(declare-function ebox--buffer-render-state + "ebox-incremental" (buffer)) +(declare-function ebox--buffer-root-node + "ebox-incremental" (buffer)) +(declare-function ebox--buffer-root-node-id + "ebox-incremental" (buffer)) +(declare-function ebox--refresh-buffer-layout-snapshots + "ebox-incremental" (buffer &optional details)) +(declare-function ebox--ensure-layout-snapshot-details + "ebox-incremental" (buffer node-id)) +(declare-function ebox--ensure-layout-snapshot-spans + "ebox-incremental" (buffer node-id)) +(declare-function ebox--runtime-parent-id + "ebox-incremental" (buffer node-id)) +(declare-function ebox--viewport-height-dependent-subtree-p + "ebox-incremental" (node)) +(declare-function ebox--owned-overflow-coverage-wrapper-node-p + "ebox-incremental" (buffer node-id)) +(declare-function ebox--contained-partial-flex-owner-p + "ebox-incremental" (buffer node-id)) +(declare-function ebox--definite-containment-formatting-context-p + "ebox-incremental" (buffer node-id)) +(declare-function ebox--render-node-in-current-flex-slot + "ebox-incremental" + (buffer node-id snapshot &optional changed-keys)) +(declare-function ebox--render-cache-probe + "ebox-incremental" + (node &optional force external-signature)) +(declare-function ebox--render-cache-store-context + "ebox-incremental" (context rendered &optional metadata)) +(declare-function ebox--render-with-cache + "ebox-incremental" + (node &optional force cache-probe)) +(declare-function ebox--flex-render-row-line-for-child + "ebox-flex" (node child-id)) +(declare-function ebox--flex-render-row-line-for-child-ids + "ebox-flex" (node child-ids &optional min-item-main)) +(declare-function ebox--flex-item-source-node + "ebox-flex" (item)) +(declare-function ebox--flex-box-main-constraint + "ebox-flex" (box axis property)) +(declare-function ebox--wrapper-content-viewport-pixel + "ebox-layout" (props)) +(declare-function ebox--apply-surface-properties + "ebox-layout" (rendered properties)) +(declare-function ebox--layout-children "ebox-tree" (node)) +(declare-function ebox-get "ebox" (box property)) +(declare-function ebox--refresh-buffer-scroll-content-markers + "ebox" (buffer)) + +(defvar ebox-incremental--declarative-commit-buffer) +(defvar ebox--prepared-root-render) +(defvar ebox--scroll-global-state) +(defvar ebox--box-extent-template) +(defvar ebox-viewport-height) +(defvar ebox-viewport-width) + +(defvar ebox-buffer-owner-proof-renderer nil + "Optional renderer used to prepare one owner proof. +The function receives NODE, SNAPSHOT, DIRTY, and a normalized CONTEXT plist. +CONTEXT exposes `:root-owner-p' immediately, a +`:parent-content-width-function' thunk for containing geometry, and a +`:details-function' thunk for the remaining expensive runtime facts. The +function returns an exact propertized render string when it owns the proof, or +nil when the ordinary Ebox renderer should handle that owner. This is the +single backend boundary for optional render accelerators; publication and +buffer mutation remain owned by this module.") + +(defun ebox-buffer--owner-proof-parent-content-width + (buffer owner-id viewport-width) + "Return OWNER-ID's proven containing-box content width in BUFFER." + (let ((parent-id (ebox--runtime-parent-id buffer owner-id)) + boxes blocked anchor-width) + (while (and parent-id (not blocked) (not anchor-width)) + (let* ((parent (ebox--buffer-runtime-node buffer parent-id)) + (type (and parent (plist-get parent :ebox-type)))) + (pcase type + ('stack nil) + ('box + (if (memq (ebox-get parent :width) + '(min-content max-content)) + (setq blocked t) + (push parent boxes) + (let ((ebox-viewport-width nil)) + (setq anchor-width + (ebox--wrapper-content-viewport-pixel parent))))) + (_ (setq blocked t))) + (setq parent-id (ebox--runtime-parent-id buffer parent-id)))) + (when (and boxes (not blocked)) + (let ((content-width viewport-width)) + (dolist (box boxes content-width) + (let ((ebox-viewport-width content-width)) + (setq content-width + (ebox--wrapper-content-viewport-pixel box)))))))) + +(defun ebox-buffer--owner-proof-side-effects-safe-p (node) + "Return non-nil when proof rendering NODE cannot mutate scroll state." + (cl-labels + ((safe-p + (current) + (and + (or (not (eq (and (listp current) + (plist-get current :ebox-type)) + 'box)) + (let ((region-id (plist-get current :region-id))) + (and + (not (and region-id + (hash-table-p ebox--scroll-global-state) + (gethash region-id ebox--scroll-global-state))) + (not + (and (eq (ebox-get current :overflow) 'scroll) + (or (ebox-get current :height) + (ebox-get current :max-height) + (not (zerop + (or (ebox-get current :scroll-offset) + 0))))))))) + (cl-every #'safe-p (ebox--layout-children current))))) + (safe-p node))) + +(defun ebox-buffer--owner-proof-slot-width-placeholder (buffer snapshot) + "Return a positive non-semantic slot width for SNAPSHOT in BUFFER. +The result is derived only from published buffer spans. It may carry an +unknown-width native proof frame, but must never become a containing-block +constraint." + (when (and (buffer-live-p buffer) + (plist-get snapshot :buffer-spans)) + (with-current-buffer buffer + (save-restriction + (widen) + (let ((widths + (cl-loop for span in (plist-get snapshot :buffer-spans) + for width = (ebox-buffer--span-slot-width span) + when (> width 0) + collect width))) + (when widths + (truncate (apply #'min widths)))))))) + +(defun ebox-buffer--owner-proof-context-details + (buffer owner-id node snapshot) + "Return expensive normalized proof facts for OWNER-ID in BUFFER." + (let* ((state (ebox--buffer-render-state buffer)) + (viewport-width + (or (and state (plist-get state :viewport-width)) + ebox-viewport-width)) + (viewport-height + (or (and state (plist-get state :viewport-height)) + ebox-viewport-height)) + (parent-content-width + (ebox-buffer--owner-proof-parent-content-width + buffer owner-id viewport-width))) + (list :viewport-width viewport-width + :viewport-height viewport-height + :runtime-revision + (or (and state (plist-get state :runtime-revision)) 0) + :viewport-height-dependent-p + (ebox--viewport-height-dependent-subtree-p node) + :parent-content-width parent-content-width + :slot-width-placeholder + (unless (and (numberp parent-content-width) + (> parent-content-width 0)) + (ebox-buffer--owner-proof-slot-width-placeholder buffer snapshot)) + :side-effects-safe-p + (ebox-buffer--owner-proof-side-effects-safe-p node)))) + +(defun ebox-buffer--owner-proof-context + (buffer owner-id node snapshot root-owner-p) + "Return lazy normalized accelerator context for OWNER-ID in BUFFER." + (let (details details-ready-p parent-width parent-width-ready-p) + (list + :root-owner-p root-owner-p + :parent-content-width-function + (lambda () + (unless parent-width-ready-p + (let* ((state (ebox--buffer-render-state buffer)) + (viewport-width + (or (and state (plist-get state :viewport-width)) + ebox-viewport-width))) + (setq parent-width + (ebox-buffer--owner-proof-parent-content-width + buffer owner-id viewport-width) + parent-width-ready-p t))) + parent-width) + :details-function + (lambda () + (unless details-ready-p + (setq details + (ebox-buffer--owner-proof-context-details + buffer owner-id node snapshot) + parent-width (plist-get details :parent-content-width) + parent-width-ready-p t + details-ready-p t)) + details)))) + +(defun ebox-buffer--owner-cache-probe (node context) + "Return NODE's ordinary or exact owner cache probe using CONTEXT." + (let ((ordinary (ebox--render-cache-probe node))) + (if (and ordinary (plist-member ordinary :rendered)) + ordinary + (let* ((details + (funcall (plist-get context :details-function))) + (parent-width (plist-get details :parent-content-width))) + (if (and (numberp parent-width) (> parent-width 0)) + (let ((exact + (ebox--render-cache-probe + node nil (list 'owner-context parent-width)))) + (if (and exact (plist-member exact :rendered)) + exact + (or ordinary exact))) + ordinary))))) + +(defun ebox-buffer--render-owner-for-rerender + (buffer owner-id node snapshot dirty root-owner-p) + "Render OWNER-ID for rerender through the single owner proof boundary." + (if (ebox--definite-containment-formatting-context-p buffer owner-id) + (ebox-render node) + (or (ebox--render-node-in-current-flex-slot + buffer owner-id snapshot (plist-get dirty :changed-keys)) + (let* ((context + (and (not root-owner-p) + (ebox-buffer--owner-proof-context + buffer owner-id node snapshot nil))) + (probe + (if root-owner-p + (and (null ebox--prepared-root-render) + (ebox--render-cache-probe node t)) + (ebox-buffer--owner-cache-probe node context)))) + (if (and probe (plist-member probe :rendered)) + (plist-get probe :rendered) + (let* ((context + (or context + (and ebox-buffer-owner-proof-renderer + (ebox-buffer--owner-proof-context + buffer owner-id node snapshot t)))) + (rendered + (and ebox-buffer-owner-proof-renderer + context + (funcall ebox-buffer-owner-proof-renderer + node snapshot dirty context)))) + (if (not rendered) + (let* ((parent-width + (and context + (funcall + (plist-get + context + :parent-content-width-function)))) + (ebox-viewport-width + (if (and (numberp parent-width) + (> parent-width 0)) + parent-width + ebox-viewport-width))) + (ebox--render-with-cache node root-owner-p probe)) + (when (and probe + (plist-get + (funcall (plist-get context :details-function)) + :side-effects-safe-p)) + (ebox--render-cache-store-context probe rendered)) + rendered))))))) + +;;; Window State + +(defvar ebox-buffer--window-state-preserved-buffer nil + "Buffer whose point and windows are preserved by the outer transaction.") + +(defun ebox--content-owner-ids-at (pos) + "Return Ebox content owners at POS, ordered from inner to outer. +When POS is an unowned boundary such as `point-max', inspect the preceding +character so a point at the end of owned content keeps its stable anchor." + (let* ((pos (min (max pos (point-min)) (point-max))) + (owners-at + (lambda (position) + (let ((owners + (copy-sequence + (or (get-text-property + position 'ebox-content-owners) + nil))) + (owner + (get-text-property position 'ebox-content-owner))) + (when (and owner (not (member owner owners))) + (setq owners (append owners (list owner)))) + owners)))) + (or (funcall owners-at pos) + (when (> pos (point-min)) + (funcall owners-at (1- pos)))))) + +(defun ebox--position-owned-by-content-region-p (pos region-id) + "Return non-nil when the content at POS is owned by REGION-ID." + (or (equal (get-text-property pos 'ebox-content-owner) region-id) + (member region-id + (get-text-property pos 'ebox-content-owners)))) + +(defvar ebox--content-owner-runs-cache nil + "Per-transaction cache of content owner runs, or nil when inactive. +When non-nil, a hash table keyed by region id. Each entry records the +buffer, its `buffer-modified-tick', and the restriction it was computed +under, so any text or property edit invalidates the entry without +explicit bookkeeping. Bound by +`ebox--with-preserved-buffer-window-state' so one capture/restore pass +walks each owner's runs at most once per buffer state.") + +(defun ebox--next-content-ownership-change (position limit) + "Return the next position before LIMIT where content ownership can change." + (min (or (next-single-property-change + position 'ebox-content-owner nil limit) + limit) + (or (next-single-property-change + position 'ebox-content-owners nil limit) + limit))) + +(defun ebox--compute-content-owner-runs (region-id) + "Compute maximal buffer runs owned by content REGION-ID." + (let ((position (point-min)) + (limit (point-max)) + run-start runs) + (while (< position limit) + (let ((next (ebox--next-content-ownership-change position limit)) + (owned-p + (ebox--position-owned-by-content-region-p position region-id))) + (cond + ((and owned-p (null run-start)) + (setq run-start position)) + ((and (not owned-p) run-start) + (push (cons run-start position) runs) + (setq run-start nil))) + (setq position next))) + (when run-start + (push (cons run-start limit) runs)) + (nreverse runs))) + +(defun ebox--content-owner-runs (region-id) + "Return maximal buffer runs owned by content REGION-ID. +Callers must not mutate the returned list: it may be shared through +`ebox--content-owner-runs-cache' within one preservation transaction." + (let ((cache ebox--content-owner-runs-cache)) + (if (null cache) + (ebox--compute-content-owner-runs region-id) + (let ((entry (gethash region-id cache))) + (if (and entry + (eq (nth 0 entry) (current-buffer)) + (= (nth 1 entry) (buffer-modified-tick)) + (= (nth 2 entry) (point-min)) + (= (nth 3 entry) (point-max))) + (nth 4 entry) + (let ((runs (ebox--compute-content-owner-runs region-id))) + (puthash region-id + (list (current-buffer) (buffer-modified-tick) + (point-min) (point-max) runs) + cache) + runs)))))) + +(defun ebox--content-owner-run-anchor-at (region-id pos) + "Return REGION-ID run ordinal and intra-run offset for POS." + (let* ((pos (min (max pos (point-min)) (point-max))) + (probe + (if (ebox--position-owned-by-content-region-p pos region-id) + pos + (and (> pos (point-min)) (1- pos)))) + (runs (ebox--content-owner-runs region-id)) + (index 0) + found) + (while (and probe runs (not found)) + (let ((run (pop runs))) + (if (and (<= (car run) probe) (< probe (cdr run))) + (setq found (list index (- pos (car run)))) + (cl-incf index)))) + found)) + +(defun ebox--resolve-content-owner-run-anchor (region-id run-index offset) + "Resolve REGION-ID RUN-INDEX and intra-run OFFSET in the current buffer." + (when-let ((run (nth run-index (ebox--content-owner-runs region-id)))) + (min (+ (car run) offset) (cdr run)))) + +(defun ebox--line-occurrence-before (line line-start) + "Count full lines equal to LINE that begin before position LINE-START." + (let ((regexp (concat "^" (regexp-quote line) "$")) + (case-fold-search nil) + (search-spaces-regexp nil) + (occurrence 0)) + (save-excursion + (goto-char (point-min)) + (while (and (< (point) line-start) + (re-search-forward regexp line-start t) + (< (match-beginning 0) line-start)) + (cl-incf occurrence) + (forward-line 1))) + occurrence)) + +(defun ebox--line-anchor-at (pos) + "Return a stable owner-first line anchor for POS in the current buffer. +The `:occurrence' index is captured only when POS yields no owner-run +anchor, because a captured owner run resolves without consulting the +line-text fallback; when an owner run is captured but later fails to +resolve, the fallback targets the first matching line." + (save-restriction + (widen) + (let* ((pos (min (max pos (point-min)) (point-max))) + (line-start (save-excursion + (goto-char pos) + (line-beginning-position))) + (line-end (save-excursion + (goto-char pos) + (line-end-position))) + (line (buffer-substring-no-properties line-start line-end)) + (column (- pos line-start)) + (owner-run-anchors + (delq + nil + (mapcar + (lambda (region-id) + (when-let ((run-anchor + (ebox--content-owner-run-anchor-at + region-id pos))) + (list region-id + (car run-anchor) + (cadr run-anchor)))) + (ebox--content-owner-ids-at pos))))) + (list :position pos + :owner-run-anchors owner-run-anchors + :line line + :column column + :occurrence (if owner-run-anchors + 0 + (ebox--line-occurrence-before line line-start)))))) + +(defun ebox--resolve-line-anchor (anchor) + "Resolve ANCHOR in the current buffer after text replacement." + (save-restriction + (widen) + (let ((line (plist-get anchor :line)) + (occurrence (plist-get anchor :occurrence)) + (column (plist-get anchor :column)) + (seen 0) + found) + (dolist (owner-anchor (plist-get anchor :owner-run-anchors)) + (unless found + (setq found + (ebox--resolve-content-owner-run-anchor + (nth 0 owner-anchor) + (nth 1 owner-anchor) + (nth 2 owner-anchor))))) + (unless found + (save-excursion + (goto-char (point-min)) + (while (and (not found) (not (eobp))) + (when (equal (buffer-substring-no-properties + (line-beginning-position) + (line-end-position)) + line) + (if (= seen occurrence) + (setq found (min (+ (line-beginning-position) column) + (line-end-position))) + (cl-incf seen))) + (forward-line 1)))) + (or found + (min (max (plist-get anchor :position) (point-min)) + (point-max)))))) + +(defun ebox--capture-buffer-window-state (buffer) + "Capture point, start, and horizontal scroll for live windows showing BUFFER." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (mapcar (lambda (window) + (list :window window + :point (ebox--line-anchor-at (window-point window)) + :start (ebox--line-anchor-at (window-start window)) + :hscroll (window-hscroll window))) + (get-buffer-window-list buffer nil t))))) + +(defun ebox--restore-buffer-window-state (buffer states) + "Restore captured window STATES for BUFFER." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (dolist (state states) + (let ((window (plist-get state :window))) + (when (and (window-live-p window) + (eq (window-buffer window) buffer)) + (set-window-start + window (ebox--resolve-line-anchor (plist-get state :start)) t) + (set-window-point + window (ebox--resolve-line-anchor (plist-get state :point))) + (set-window-hscroll window (plist-get state :hscroll)))))))) + +(defmacro ebox--with-preserved-buffer-window-state (buffer &rest body) + "Evaluate BODY while preserving BUFFER point and showing windows." + (declare (indent 1) (debug t)) + `(let ((ebox--window-state-buffer ,buffer)) + (if (eq ebox-buffer--window-state-preserved-buffer + ebox--window-state-buffer) + (progn ,@body) + (let* ((ebox--content-owner-runs-cache (make-hash-table :test #'equal)) + (ebox--window-states + (ebox--capture-buffer-window-state ebox--window-state-buffer)) + (ebox--buffer-point-anchor + (when (buffer-live-p ebox--window-state-buffer) + (with-current-buffer ebox--window-state-buffer + (ebox--line-anchor-at (point))))) + (ebox--selected-window (selected-window)) + (ebox--current-buffer (current-buffer))) + (let ((ebox-buffer--window-state-preserved-buffer + ebox--window-state-buffer)) + (unwind-protect + (progn ,@body) + (when (and (buffer-live-p ebox--window-state-buffer) + ebox--buffer-point-anchor) + (with-current-buffer ebox--window-state-buffer + (goto-char + (ebox--resolve-line-anchor ebox--buffer-point-anchor)))) + (ebox--restore-buffer-window-state + ebox--window-state-buffer ebox--window-states) + (when (window-live-p ebox--selected-window) + (select-window ebox--selected-window)) + (when (buffer-live-p ebox--current-buffer) + (set-buffer ebox--current-buffer)))))))) + +(defun ebox-buffer--native-root-patch-map-offset (offset patches) + "Map root-relative old OFFSET through native PATCHES." + (let ((delta 0) + mapped) + (catch 'mapped + (dolist (patch patches) + (let* ((old-start (plist-get patch :old-start)) + (old-end (plist-get patch :old-end)) + (new-start (plist-get patch :new-start)) + (new-end (plist-get patch :new-end))) + (cond + ((< offset old-start) + (setq mapped (+ offset delta)) + (throw 'mapped nil)) + ((<= offset old-end) + (setq mapped + (if (= offset old-end) + new-end + (+ new-start + (min (max 0 (- offset old-start)) + (- new-end new-start))))) + (throw 'mapped nil)) + (t + (setq delta (+ delta (- (- new-end new-start) + (- old-end old-start)))))))) + (setq mapped (+ offset delta))) + mapped)) + +(defun ebox-buffer--role-span-containing-position + (spans pos &optional left-edge-p) + "Return the indexed role span in SPANS containing POS. +When LEFT-EDGE-P is non-nil, allow POS at a span end." + (let ((index 0) + found) + (while (and spans (not found)) + (let* ((span (ebox-buffer--numeric-role-span (car spans))) + (start (car-safe span)) + (end (cdr-safe span))) + (when (and span + (or (and (<= start pos) (< pos end)) + (and left-edge-p (> pos start) (= pos end)))) + (setq found (list :run-index index + :start start + :end end + :offset (- pos start)))) + (setq spans (cdr spans) + index (1+ index)))) + found)) + +(defun ebox-buffer--native-root-patch-anchor (pos plan) + "Return a prepared native patch window anchor for POS." + (let* ((root-start (plist-get plan :start)) + (root-end (plist-get plan :end)) + (clamped (min (max pos root-start) root-end)) + (probe (if (and (= clamped root-end) (> clamped root-start)) + (1- clamped) + clamped)) + (left-edge-p (or (= clamped root-end) + (not (ebox--content-owner-ids-at clamped)))) + (table (ebox-buffer--region-role-span-table)) + owner owner-anchor) + (when table + (dolist (region-id (ebox--content-owner-ids-at probe)) + (unless owner-anchor + (when-let ((span-anchor + (ebox-buffer--role-span-containing-position + (gethash + (ebox-buffer--region-role-key region-id 'content-owner) + table) + clamped left-edge-p))) + (setq owner region-id + owner-anchor span-anchor))))) + (list :position clamped + :root-offset (- clamped root-start) + :owner owner + :run-index (plist-get owner-anchor :run-index) + :owner-offset (plist-get owner-anchor :offset)))) + +(defun ebox-buffer--resolve-native-root-patch-anchor (anchor plan) + "Resolve prepared native patch ANCHOR after PLAN has been published." + (let* ((root-start (plist-get plan :start)) + (target-count (plist-get plan :target-character-count)) + (owner (plist-get anchor :owner)) + (run-index (plist-get anchor :run-index)) + (owner-offset (plist-get anchor :owner-offset)) + (target-table (plist-get plan :prepared-role-table)) + (target-spans + (and owner (hash-table-p target-table) + (gethash + (ebox-buffer--region-role-key owner 'content-owner) + target-table))) + (target-span (and (natnump run-index) + (nth run-index target-spans)))) + (if-let ((span (and target-span + (ebox-buffer--numeric-role-span target-span)))) + (min (+ (car span) owner-offset) (cdr span)) + (+ root-start + (min (max 0 + (ebox-buffer--native-root-patch-map-offset + (plist-get anchor :root-offset) + (plist-get plan :patches))) + target-count))))) + +(defun ebox-buffer--capture-native-root-patch-window-state (buffer plan) + "Capture BUFFER point and live windows for native patch PLAN." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (let ((capture + (lambda (window) + (list :window window + :point (ebox-buffer--native-root-patch-anchor + (window-point window) plan) + :start (ebox-buffer--native-root-patch-anchor + (window-start window) plan) + :hscroll (window-hscroll window))))) + (list :point (ebox-buffer--native-root-patch-anchor (point) plan) + :windows (mapcar capture + (get-buffer-window-list buffer nil t))))))) + +(defun ebox-buffer--restore-native-root-patch-window-state (buffer plan state) + "Restore native patch window STATE for BUFFER after PLAN publication." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (goto-char + (ebox-buffer--resolve-native-root-patch-anchor + (plist-get state :point) plan)) + (dolist (window-state (plist-get state :windows)) + (let ((window (plist-get window-state :window))) + (when (and (window-live-p window) + (eq (window-buffer window) buffer)) + (set-window-start + window + (ebox-buffer--resolve-native-root-patch-anchor + (plist-get window-state :start) plan) + t) + (set-window-point + window + (ebox-buffer--resolve-native-root-patch-anchor + (plist-get window-state :point) plan)) + (set-window-hscroll window + (plist-get window-state :hscroll)))))))) + +(defun ebox-buffer--resolve-source-window-anchor (anchor) + "Resolve source-state ANCHOR after rollback." + (min (max (plist-get anchor :position) (point-min)) (point-max))) + +(defun ebox-buffer--restore-source-window-state (buffer state) + "Restore BUFFER window STATE in the original source buffer coordinates." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (goto-char (ebox-buffer--resolve-source-window-anchor + (plist-get state :point))) + (dolist (window-state (plist-get state :windows)) + (let ((window (plist-get window-state :window))) + (when (and (window-live-p window) + (eq (window-buffer window) buffer)) + (set-window-start + window + (ebox-buffer--resolve-source-window-anchor + (plist-get window-state :start)) + t) + (set-window-point + window + (ebox-buffer--resolve-source-window-anchor + (plist-get window-state :point))) + (set-window-hscroll window + (plist-get window-state :hscroll)))))))) + +(defmacro ebox-buffer--with-native-root-patch-window-state + (buffer plan &rest body) + "Evaluate BODY while preserving BUFFER windows through native root PLAN." + (declare (indent 2) (debug t)) + `(let ((ebox-buffer--native-window-buffer ,buffer)) + (if (eq ebox-buffer--window-state-preserved-buffer + ebox-buffer--native-window-buffer) + (progn ,@body) + (let ((ebox-buffer--native-window-plan ,plan) + (ebox-buffer--selected-window (selected-window)) + (ebox-buffer--current-buffer (current-buffer)) + (ebox-buffer--native-window-completed-p nil)) + (let ((ebox-buffer--native-window-state + (ebox-buffer--capture-native-root-patch-window-state + ebox-buffer--native-window-buffer + ebox-buffer--native-window-plan)) + (ebox-buffer--window-state-preserved-buffer + ebox-buffer--native-window-buffer)) + (unwind-protect + (prog1 (progn ,@body) + (setq ebox-buffer--native-window-completed-p t)) + (if ebox-buffer--native-window-completed-p + (ebox-buffer--restore-native-root-patch-window-state + ebox-buffer--native-window-buffer + ebox-buffer--native-window-plan + ebox-buffer--native-window-state) + (ebox-buffer--restore-source-window-state + ebox-buffer--native-window-buffer + ebox-buffer--native-window-state)) + (when (window-live-p ebox-buffer--selected-window) + (select-window ebox-buffer--selected-window)) + (when (buffer-live-p ebox-buffer--current-buffer) + (set-buffer ebox-buffer--current-buffer)))))))) + +;;; Render Transaction + +(defvar ebox-render-gc-cons-threshold (* 128 1024 1024) + "Temporary `gc-cons-threshold' used during Ebox render transactions. +Set this to nil to leave `gc-cons-threshold' unchanged.") + +(defvar ebox-deferred-render-gc-cons-threshold 'auto + "Temporary `gc-cons-threshold' used across interactive render bursts. +The default `auto' uses a bounded fraction of physical memory so visible frames +can favor throughput without assuming every machine has the same headroom. +Set this to a byte count for a fixed budget, or nil to leave the setting alone.") + +(defvar ebox-deferred-render-gc-auto-min-threshold (* 128 1024 1024) + "Minimum interactive render allocation budget selected by `auto'.") + +(defvar ebox-deferred-render-gc-auto-max-threshold (* 512 1024 1024) + "Maximum interactive render allocation budget selected by `auto'.") + +(defvar ebox-deferred-render-gc-auto-memory-fraction (/ 1.0 32.0) + "Fraction of physical memory available to an interactive render burst.") + +(defvar ebox--physical-memory-bytes 'unknown + "Cached physical memory size used by automatic render GC tuning.") + +(defun ebox--darwin-physical-memory-bytes () + "Return macOS physical memory in bytes, or nil when unavailable." + (when (eq system-type 'darwin) + (when-let ((sysctl (executable-find "sysctl"))) + (with-temp-buffer + (when (zerop (call-process sysctl nil t nil "-n" "hw.memsize")) + (goto-char (point-min)) + (when (looking-at "[0-9]+") + (string-to-number (match-string 0)))))))) + +(defun ebox--physical-memory-bytes () + "Return local physical memory in bytes, or nil when unavailable." + (when (eq ebox--physical-memory-bytes 'unknown) + (setq ebox--physical-memory-bytes + (or (when-let ((info + (and (fboundp 'memory-info) + (let ((default-directory + temporary-file-directory)) + (memory-info))))) + (* 1024 (car info))) + (ebox--darwin-physical-memory-bytes) + 'unavailable))) + (unless (eq ebox--physical-memory-bytes 'unavailable) + ebox--physical-memory-bytes)) + +(defun ebox--effective-deferred-render-gc-cons-threshold () + "Return the byte threshold for the current interactive render burst." + (pcase ebox-deferred-render-gc-cons-threshold + ('auto + (let* ((minimum (max 1 ebox-deferred-render-gc-auto-min-threshold)) + (maximum + (max minimum ebox-deferred-render-gc-auto-max-threshold)) + (physical-memory (ebox--physical-memory-bytes)) + (scaled + (and physical-memory + (floor (* physical-memory + ebox-deferred-render-gc-auto-memory-fraction))))) + (max minimum (min maximum (or scaled minimum))))) + ((pred numberp) ebox-deferred-render-gc-cons-threshold) + (_ nil))) + +(defvar ebox-render-gc-cons-percentage 0.6 + "Temporary `gc-cons-percentage' used during Ebox render transactions. +Set this to nil to leave `gc-cons-percentage' unchanged.") + +(defmacro ebox--with-render-gc (&rest body) + "Evaluate BODY with GC tuned for one Ebox render transaction." + (declare (indent 0) (debug t)) + `(let ((gc-cons-threshold + (if (numberp ebox-render-gc-cons-threshold) + (max gc-cons-threshold ebox-render-gc-cons-threshold) + gc-cons-threshold)) + (gc-cons-percentage + (if (numberp ebox-render-gc-cons-percentage) + (max gc-cons-percentage ebox-render-gc-cons-percentage) + gc-cons-percentage))) + ,@body)) + +(defvar ebox--deferred-render-gc-state nil + "Saved GC settings while an interactive render transaction is settling.") + +(defvar ebox--deferred-render-gc-timer nil + "Timer used to restore GC settings after interactive rendering settles.") + +(defvar ebox--deferred-render-gc-depth 0 + "Number of overlapping interactive render bursts that still own GC defer.") + +(defun ebox--deferred-render-gc-restore () + "Restore GC settings saved by `ebox--with-deferred-render-gc'." + (let ((state ebox--deferred-render-gc-state)) + (setq ebox--deferred-render-gc-state nil) + (setq ebox--deferred-render-gc-timer nil) + (setq ebox--deferred-render-gc-depth 0) + (when state + (setq gc-cons-threshold + (plist-get state :gc-cons-threshold)) + (setq gc-cons-percentage + (plist-get state :gc-cons-percentage)) + (garbage-collect)))) + +(defun ebox--deferred-render-gc-enter () + "Raise GC settings for an interactive render transaction." + (cl-incf ebox--deferred-render-gc-depth) + (unless ebox--deferred-render-gc-state + (setq ebox--deferred-render-gc-state + (list :gc-cons-threshold gc-cons-threshold + :gc-cons-percentage gc-cons-percentage))) + (when-let ((threshold + (ebox--effective-deferred-render-gc-cons-threshold))) + (setq gc-cons-threshold threshold)) + (when (numberp ebox-render-gc-cons-percentage) + (setq gc-cons-percentage + (max gc-cons-percentage ebox-render-gc-cons-percentage))) + (when (timerp ebox--deferred-render-gc-timer) + (cancel-timer ebox--deferred-render-gc-timer) + (setq ebox--deferred-render-gc-timer nil))) + +(defun ebox--deferred-render-gc-raise-threshold (threshold) + "Raise an active interactive burst allocation budget to THRESHOLD." + (when (and ebox--deferred-render-gc-state + (numberp threshold)) + (setq gc-cons-threshold (max gc-cons-threshold threshold)))) + +(defun ebox--deferred-render-gc-set-threshold (threshold) + "Set the active burst budget to THRESHOLD without lowering its saved value." + (when (and ebox--deferred-render-gc-state + (numberp threshold)) + (setq gc-cons-threshold + (max (plist-get ebox--deferred-render-gc-state :gc-cons-threshold) + threshold)))) + +(defun ebox--deferred-render-gc-schedule-restore () + "Restore deferred GC settings after a short settling delay." + (when (> ebox--deferred-render-gc-depth 0) + (cl-decf ebox--deferred-render-gc-depth)) + (when (and (zerop ebox--deferred-render-gc-depth) + ebox--deferred-render-gc-state) + (when (timerp ebox--deferred-render-gc-timer) + (cancel-timer ebox--deferred-render-gc-timer)) + (setq ebox--deferred-render-gc-timer + (run-at-time 0.2 nil #'ebox--deferred-render-gc-restore)))) + +(defmacro ebox--with-deferred-render-gc (&rest body) + "Evaluate BODY with render GC settings restored after idle redisplay. +This is for interactive UI commands where restoring the default GC threshold +immediately after BODY can trigger GC before the updated buffer is visible." + (declare (indent 0) (debug t)) + `(progn + (ebox--deferred-render-gc-enter) + (unwind-protect + (progn ,@body) + (ebox--deferred-render-gc-schedule-restore)))) + +;;; Paint Mapping + +(defun ebox-buffer--paint-color (style role) + "Return the foreground paint color for STYLE and ROLE." + (or (plist-get style :color) + (and (memq role '(border border-top border-right border-bottom border-left)) + (or (plist-get style :border-color) + (plist-get style :border-top-color) + (plist-get style :border-right-color) + (plist-get style :border-bottom-color) + (plist-get style :border-left-color))))) + +(defun ebox-buffer--paint-background-color (style role) + "Return the background paint color for STYLE and ROLE." + (unless (memq role '(border border-top border-right border-bottom border-left)) + (or (plist-get style :background-color) + (plist-get style :bgcolor)))) + +(defun ebox-buffer-paint-text-properties (style role) + "Return Emacs text properties for computed STYLE in paint ROLE. +This backend mapper intentionally accepts computed style facts and emits only +buffer-facing paint properties. Layout-only properties never pass through." + (let ((face nil)) + (when-let ((foreground (ebox-buffer--paint-color style role))) + (setq face (plist-put face :foreground foreground))) + (when-let ((background (ebox-buffer--paint-background-color style role))) + (setq face (plist-put face :background background))) + (when face + (list 'face face)))) + +;;; Text Property Helpers + +(defun ebox-buffer-side-border-face (color) + "Return the canonical Emacs face for a side border with COLOR." + (if color + `(:background ,color) + '(:inverse-video t))) + +(defun ebox--pixel-border (pixel-width height &optional color) + "Generate a border line of PIXEL-WIDTH and HEIGHT." + (when (and pixel-width height (> pixel-width 0) (> height 0)) + (let ((line (propertize " " + 'face (ebox-buffer-side-border-face color) + 'display `(space :width (,pixel-width))))) + (ebox--string-repeat-lines line height)))) + +(defun ebox--propertize-bgcolor (string bgcolor) + "Apply background color BGCOLOR to STRING." + (let* ((string (copy-sequence string)) + (length (length string))) + (add-face-text-property 0 length `(:background ,bgcolor) t string) + string)) + +(defun ebox--propertize-color (string color) + "Apply foreground COLOR to STRING." + (let* ((string (copy-sequence string)) + (length (length string))) + (add-face-text-property 0 length `(:foreground ,color) t string) + string)) + +(defun ebox--propertize-colors (string color bgcolor) + "Apply foreground COLOR and background BGCOLOR to one copy of STRING." + (let* ((string (copy-sequence string)) + (length (length string))) + (when color + (add-face-text-property 0 length `(:foreground ,color) t string)) + (when bgcolor + (add-face-text-property 0 length `(:background ,bgcolor) t string)) + string)) + +(defun ebox--propertize-overline (string &optional color) + "Apply overline to STRING with optional COLOR." + (let ((string (copy-sequence string))) + (add-face-text-property + 0 (length string) `(:overline ,(or color t)) t string) + string)) + +(defun ebox--propertize-underline (string &optional color) + "Apply underline to STRING with optional COLOR." + (let ((string (copy-sequence string))) + (add-face-text-property + 0 (length string) + `(:underline (:position t ,@(when color `(:color ,color)))) + t string) + string)) + +(defun ebox--propertize-region (string property region-id) + "Add PROPERTY with REGION-ID to each line of STRING." + (when string + (ebox--maplines + (lambda (line) (propertize line property region-id)) + string))) + +(defun ebox--content-owner-stack-at (string pos) + "Return the existing owner stack in STRING at POS, inner to outer." + (let ((owners (copy-sequence + (or (get-text-property pos 'ebox-content-owners string) + nil))) + (owner (get-text-property pos 'ebox-content-owner string))) + (if (and owner (not (member owner owners))) + (append owners (list owner)) + owners))) + +(defun ebox--next-content-owner-property-change + (pos string limit &optional complete-stack-p) + "Return next owner metadata change in STRING after POS before LIMIT. +When COMPLETE-STACK-P is non-nil, `ebox-content-owners' already contains the +current direct owner, so only a stack change can affect the next output run." + (let ((next limit)) + (dolist (prop (if complete-stack-p + '(ebox-content-owners) + '(ebox-content-owner ebox-content-owners))) + (let ((change (next-single-property-change pos prop string limit))) + (when (and change (< change next)) + (setq next change)))) + next)) + +(defun ebox--add-content-owner! (string region-id) + "Destructively append REGION-ID to STRING's content owner stack." + (let ((length (length string))) + (unless (= length 0) + (let ((owner (get-text-property 0 'ebox-content-owner string)) + (owners-prop (get-text-property 0 'ebox-content-owners string))) + (if (and (not (text-property-not-all + 0 length 'ebox-content-owner owner string)) + (not (text-property-not-all + 0 length 'ebox-content-owners owners-prop string))) + (let ((owners (copy-sequence (or owners-prop nil)))) + (when (and owner (not (member owner owners))) + (setq owners (append owners (list owner)))) + (unless (member region-id owners) + (setq owners (append owners (list region-id)))) + (add-text-properties + 0 length + (list 'ebox-content-owner region-id + 'ebox-content-owners owners) + string)) + (let ((pos 0) + next owners) + (while (< pos length) + (let* ((owner (get-text-property + pos 'ebox-content-owner string)) + (owners-prop (get-text-property + pos 'ebox-content-owners string)) + (complete-stack-p + (and owners-prop (member owner owners-prop)))) + (setq next (ebox--next-content-owner-property-change + pos string length complete-stack-p)) + (setq owners + (if complete-stack-p + owners-prop + (ebox--content-owner-stack-at string pos)))) + (unless (member region-id owners) + (setq owners (append owners (list region-id)))) + (add-text-properties pos next + (list 'ebox-content-owner region-id + 'ebox-content-owners owners) + string) + (setq pos next)))))) + string)) + +(defun ebox--add-content-owner (string region-id) + "Return a copy of STRING with REGION-ID appended to its owner stack." + (ebox--add-content-owner! (copy-sequence string) region-id)) + +(defun ebox--add-content-owners (string owners) + "Return STRING with OWNERS appended to its content owner stack." + (let ((result (copy-sequence string)) + (length (length string))) + (when (and owners (> length 0)) + (let ((position 0) + (final-owner (car (last owners)))) + (while (< position length) + (let* ((owner (get-text-property + position 'ebox-content-owner result)) + (stack (get-text-property + position 'ebox-content-owners result)) + (complete-stack-p (and stack (member owner stack))) + (next (ebox--next-content-owner-property-change + position result length complete-stack-p))) + (unless complete-stack-p + (setq stack + (ebox--content-owner-stack-at result position))) + (dolist (new-owner owners) + (unless (member new-owner stack) + (setq stack (append stack (list new-owner))))) + (add-text-properties + position next + (list 'ebox-content-owner final-owner + 'ebox-content-owners stack) + result) + (setq position next))))) + result)) + +(defun ebox--propertize-content-line (line region-id idx fallback) + "Return LINE with region ownership for REGION-ID at content index IDX. +Preserve child `ebox-content' properties and add wrapper ownership through +`ebox-content-owner'. If LINE has no child content region, use REGION-ID as +the line's direct `ebox-content' owner. When the current render context marks +LINE as a fresh private allocation, ownership may be added in place." + (let* ((empty-line-p (string-empty-p line)) + (source (if empty-line-p fallback line)) + (mutate-p (and ebox--propertize-private-content-line-p + (not empty-line-p))) + (line + (if (and (not (text-property-not-all + 0 (length source) + 'ebox-content-owner nil source)) + (not (text-property-not-all + 0 (length source) + 'ebox-content-owners nil source))) + (let ((copy (if mutate-p source (copy-sequence source)))) + (add-text-properties + 0 (length copy) + (list 'ebox-content-owner region-id + 'ebox-content-owners (list region-id)) + copy) + copy) + (if mutate-p + (ebox--add-content-owner! source region-id) + (ebox--add-content-owner source region-id))))) + (unless (text-property-not-all 0 (length line) + 'ebox-content nil line) + (add-text-properties 0 (length line) + (list 'ebox-content region-id + 'ebox-content-idx idx) + line)) + line)) + +;;; Span and Buffer Patch Execution + +(defun ebox--copy-string-text-properties-to-buffer (string start) + "Copy text properties from STRING to buffer text starting at START." + (dotimes (index (length string)) + (set-text-properties + (+ start index) (+ start index 1) + (text-properties-at index string)))) + +(defconst ebox-buffer--remove-face-key (make-symbol "ebox-remove-face-key") + "Sentinel used to remove a single face plist key.") + +(defun ebox-buffer--face-plist-remove (plist key) + "Return PLIST without KEY and its value." + (let (result) + (while plist + (let ((current-key (pop plist)) + (current-value (pop plist))) + (unless (eq current-key key) + (push current-key result) + (push current-value result)))) + (nreverse result))) + +(defun ebox-buffer--face-put (face key value) + "Return FACE with KEY set to VALUE, preserving nested face specs." + (cond + ((null face) + (unless (eq value ebox-buffer--remove-face-key) + (list key value))) + ((and (listp face) (keywordp (car face))) + (let ((face (copy-sequence face))) + (if (eq value ebox-buffer--remove-face-key) + (ebox-buffer--face-plist-remove face key) + (plist-put face key value)))) + ((listp face) + (delq nil + (mapcar (lambda (item) + (ebox-buffer--face-put item key value)) + face))) + (t + (list face (list key value))))) + +(defun ebox--patch-face-key-in-span (start end key value) + "Set FACE KEY to VALUE in START..END without touching other properties." + (let ((pos start)) + (while (< pos end) + (let* ((next (or (next-single-property-change pos 'face nil end) + end)) + (face (get-text-property pos 'face))) + (put-text-property pos next 'face + (ebox-buffer--face-put face key value)) + (setq pos next))))) + +(defun ebox--patch-horizontal-border-face-in-span (start end key value) + "Set horizontal border FACE KEY to VALUE in START..END. +Enabled horizontal borders are one visual surface over the whole span, so they +can be applied as one high-priority face spec. Removal still uses the precise +face-key patcher so raw face properties do not retain stale border keys." + (if (eq value ebox-buffer--remove-face-key) + (ebox--patch-face-key-in-span start end key value) + (add-face-text-property start end (list key value) nil))) + +(defconst ebox--paint-content-region-roles + '(content content-owner pt pb pl pr) + "Region roles whose faces receive content paint properties.") + +(defconst ebox--horizontal-border-anchor-roles + '(content content-owner pt pb pl pr bl br) + "Region roles that define the box edge excluding margins.") + +(defconst ebox-buffer--role-span-table-marker-key + (make-symbol "ebox-role-span-table-marker-backed") + "Internal hash key marking role span tables whose spans use markers.") + +(defconst ebox-buffer--role-span-table-template-key + (make-symbol "ebox-role-span-table-prepared-template") + "Internal hash key marking immutable prepared root role span tables.") + +(defun ebox-buffer--role-property (role) + "Return the text property that stores ROLE ownership." + (or (alist-get role ebox-region-types) + (error "Unknown region role: %S" role))) + +(defun ebox-buffer--region-role-key (region-id role) + "Return the runtime span index key for REGION-ID and ROLE." + (list region-id role)) + +(defun ebox-buffer--region-role-span-table () + "Return the current buffer's runtime region role span table." + (and (buffer-live-p (current-buffer)) + (ebox--buffer-region-role-span-table (current-buffer)))) + +(defun ebox-buffer--put-region-role-spans (region-id role spans) + "Store SPANS for REGION-ID and ROLE in the current buffer span index." + (when-let ((table (ebox-buffer--region-role-span-table))) + (when (gethash ebox-buffer--role-span-table-template-key table) + (ebox-buffer-materialize-region-role-spans (current-buffer)))) + (when-let ((table (ebox-buffer--region-role-span-table))) + (let ((key (ebox-buffer--region-role-key region-id role))) + (if spans + (puthash key + (mapcar (lambda (span) + (ebox-buffer--make-role-span + (car span) (cdr span))) + spans) + table) + (remhash key table))))) + +(defun ebox-buffer--scan-region-role-spans (region-id role) + "Scan current buffer for ROLE spans owned by REGION-ID." + (let ((prop (ebox-buffer--role-property role)) + spans) + (save-excursion + (goto-char (point-min)) + (while (< (point) (point-max)) + (when (equal (get-text-property (point) prop) region-id) + (let ((start (point)) + (end (next-single-property-change + (point) prop nil (point-max)))) + (push (cons start end) spans) + (goto-char end))) + (goto-char (next-single-property-change + (point) prop nil (point-max))))) + (nreverse spans))) + +(defun ebox-buffer--record-role-span (table region-id role start end) + "Record START..END for REGION-ID ROLE in TABLE." + (let ((key (ebox-buffer--region-role-key region-id role))) + (puthash key + (cons (ebox-buffer--make-role-span start end) + (gethash key table)) + table))) + +(defun ebox-buffer--make-role-span (start end) + "Return a marker-backed role span for START..END." + (cons (copy-marker start nil) + (copy-marker end t))) + +(defun ebox-buffer--role-keys-in-range (start end) + "Return distinct region-role keys touching START..END in the current buffer." + (let (keys) + (dolist (entry ebox-region-types) + (let ((role (car entry)) + (property (cdr entry)) + (position start)) + ;; Marker endpoints at either edge move during delete/insert even when + ;; their property byte is immediately outside the replaced interval. + (dolist (boundary (delq nil + (list (and (> start (point-min)) + (1- start)) + (and (< end (point-max)) end)))) + (when-let ((region-id + (get-text-property boundary property))) + (cl-pushnew (ebox-buffer--region-role-key region-id role) + keys :test #'equal))) + (while (< position end) + (when-let ((region-id (get-text-property position property))) + (cl-pushnew (ebox-buffer--region-role-key region-id role) + keys :test #'equal)) + (setq position + (or (next-single-property-change + position property nil end) + end))))) + keys)) + +(defun ebox-buffer--string-role-keys (string) + "Return distinct region-role keys present in STRING." + (let ((limit (length string)) + keys) + (dolist (entry ebox-region-types) + (let ((role (car entry)) + (property (cdr entry)) + (position 0)) + (while (< position limit) + (when-let ((region-id + (get-text-property position property string))) + (cl-pushnew (ebox-buffer--region-role-key region-id role) + keys :test #'equal)) + (setq position + (or (next-single-property-change + position property string limit) + limit))))) + keys)) + +(defun ebox-buffer--region-ids-in-range (start end) + "Return distinct Ebox region ids touching START..END in the current buffer." + (let ((position start) + ids) + (dolist (boundary (delq nil + (list (and (> start (point-min)) (1- start)) + (and (< end (point-max)) end)))) + (dolist (region-id (ebox-buffer--region-ids-at boundary)) + (cl-pushnew region-id ids :test #'equal))) + (while (< position end) + (dolist (region-id (ebox-buffer--region-ids-at position)) + (cl-pushnew region-id ids :test #'equal)) + (setq position (or (next-property-change position nil end) end))) + ids)) + +(defun ebox-buffer--region-ids-at (position) + "Return role and wrapper-owner region ids at buffer POSITION." + (let ((ids + (append + (get-text-property position 'ebox-content-owners) + (when-let ((owner + (get-text-property position 'ebox-content-owner))) + (list owner))))) + (dolist (entry ebox-region-types) + (when-let ((region-id (get-text-property position (cdr entry)))) + (cl-pushnew region-id ids :test #'equal))) + ids)) + +(defun ebox-buffer--string-all-region-ids (string) + "Return distinct Ebox role and wrapper-owner ids present in STRING." + (let ((position 0) + (limit (length string)) + ids) + (while (< position limit) + (dolist (region-id + (append + (get-text-property position 'ebox-content-owners string) + (when-let ((owner + (get-text-property + position 'ebox-content-owner string))) + (list owner)))) + (cl-pushnew region-id ids :test #'equal)) + (dolist (entry ebox-region-types) + (when-let ((region-id + (get-text-property position (cdr entry) string))) + (cl-pushnew region-id ids :test #'equal))) + (setq position (or (next-property-change position string limit) + limit))) + ids)) + +(defun ebox-buffer--replacement-region-ids (spans rendered) + "Return region ids affected by replacing SPANS with RENDERED." + (let ((region-ids (ebox-buffer--string-all-region-ids rendered))) + (dolist (span spans) + (dolist (region-id + (ebox-buffer--region-ids-in-range (car span) (cdr span))) + (cl-pushnew region-id region-ids :test #'equal))) + region-ids)) + +(defun ebox-buffer--range-text-equal-string-p (start end string) + "Return non-nil when START..END has the same characters as STRING." + (and (= (- end start) (length string)) + (let ((position start) + (index 0) + (equal-p t)) + (while (and equal-p (< position end)) + (unless (= (char-after position) (aref string index)) + (setq equal-p nil)) + (setq position (1+ position) + index (1+ index))) + equal-p))) + +(defun ebox-buffer--range-properties-equal-string-p + (start end string properties) + "Return non-nil when PROPERTIES match between START..END and STRING." + (and (= (- end start) (length string)) + (let ((offset 0) + (limit (length string)) + (equal-p t)) + (while (and equal-p (< offset limit)) + (dolist (property properties) + (unless (equal (get-text-property (+ start offset) property) + (get-text-property offset property string)) + (setq equal-p nil))) + (setq offset (1+ offset))) + equal-p))) + +(defun ebox-buffer--in-place-property-patch-p (root-start patch) + "Return non-nil when PATCH can update properties without replacing text." + (let ((old-start (+ root-start (plist-get patch :old-start))) + (old-end (+ root-start (plist-get patch :old-end))) + (replacement (plist-get patch :replacement))) + (ebox-buffer--range-text-equal-string-p old-start old-end replacement))) + +(defun ebox-buffer--role-neutral-property-patch-p (root-start patch) + "Return non-nil when in-place PATCH preserves every runtime owner role." + (let ((old-start (+ root-start (plist-get patch :old-start))) + (old-end (+ root-start (plist-get patch :old-end))) + (replacement (plist-get patch :replacement))) + (and (ebox-buffer--range-text-equal-string-p + old-start old-end replacement) + (ebox-buffer--range-properties-equal-string-p + old-start old-end replacement + (delete-dups + (append (mapcar #'cdr ebox-region-types) + '(ebox-content-owners))))))) + +(defun ebox-buffer--apply-string-properties-in-place (start string) + "Replace only text properties at START with those carried by STRING." + (let ((position 0) + (limit (length string))) + (while (< position limit) + (let ((next (or (next-property-change position string limit) limit))) + (set-text-properties + (+ start position) (+ start next) + (text-properties-at position string)) + (setq position next))))) + +(defun ebox-buffer--role-context-in-range (start end &optional string) + "Return role keys in START..END of the current buffer or STRING. +Buffer ranges include their immediate boundaries because delete/insert marker +motion can affect a role span whose property byte is just outside the patch." + (let ((positions nil) + keys) + (unless string + (when (> start (point-min)) + (push (1- start) positions)) + (when (< end (point-max)) + (push end positions))) + (let ((position start)) + (while (< position end) + (push position positions) + (setq position + (or (next-property-change position string end) end)))) + (dolist (position positions) + (dolist (entry ebox-region-types) + (when-let ((region-id + (get-text-property position (cdr entry) string))) + (cl-pushnew + (ebox-buffer--region-role-key region-id (car entry)) + keys :test #'equal)))) + keys)) + +(defun ebox-buffer--numeric-span-touches-range-p (span range) + "Return non-nil when numeric SPAN touches numeric RANGE." + (and (<= (car span) (cdr range)) + (>= (cdr span) (car range)))) + +(defun ebox-buffer--numeric-role-span (span) + "Return marker-backed or integer SPAN as an integer pair." + (let ((start (if (markerp (car span)) + (marker-position (car span)) + (car span))) + (end (if (markerp (cdr span)) + (marker-position (cdr span)) + (cdr span)))) + (and start end (< start end) (cons start end)))) + +(defun ebox-buffer--copy-frozen-region-role-span-table (table) + "Return an independent integer-coordinate copy of role span TABLE." + (when (hash-table-p table) + (let ((copy (make-hash-table :test 'equal))) + (maphash + (lambda (key spans) + (when (consp key) + (puthash key + (delq nil + (mapcar (lambda (span) + (when-let ((numeric + (ebox-buffer--numeric-role-span + span))) + (copy-tree numeric))) + spans)) + copy))) + table) + copy))) + +(defun ebox-buffer--capture-role-delta (root-start patches) + "Capture the role-index delta for PATCHES rooted at ROOT-START. +Only keys touching replaced bytes or insertion boundaries are retained." + (when-let ((table (ebox-buffer--region-role-span-table))) + (when (gethash ebox-buffer--role-span-table-template-key table) + (ebox-buffer-materialize-region-role-spans (current-buffer)) + (setq table (ebox-buffer--region-role-span-table))) + (when (ebox-buffer--marker-role-span-table-p table) + (let ((entries-by-key (make-hash-table :test 'equal))) + (dolist (patch patches) + ;; Display and face changes dominate resize animation tapes. When + ;; their characters, role owners, and coordinates are unchanged, + ;; the existing marker index is already the exact target index. + (if (ebox-buffer--role-neutral-property-patch-p root-start patch) + nil + (let* ((old-start (+ root-start (plist-get patch :old-start))) + (old-end (+ root-start (plist-get patch :old-end))) + (new-start (+ root-start (plist-get patch :new-start))) + (new-end (+ root-start (plist-get patch :new-end))) + (replacement (plist-get patch :replacement)) + (old-context + (ebox-buffer--role-context-in-range old-start old-end)) + (new-context + (ebox-buffer--role-context-in-range + 0 (length replacement) replacement)) + (keys + (delete-dups + (append old-context new-context))) + (old-range (cons old-start old-end)) + (new-range (cons new-start new-end))) + (dolist (key keys) + (let ((entry (gethash key entries-by-key))) + (unless entry + (setq entry (list :key key))) + (plist-put entry :old-ranges + (cons old-range + (plist-get entry :old-ranges))) + (plist-put entry :new-ranges + (cons new-range + (plist-get entry :new-ranges))) + (puthash key entry entries-by-key)))))) + (let (entries) + (maphash + (lambda (key entry) + (let* ((old-ranges + (ebox-buffer--merge-numeric-spans + (plist-get entry :old-ranges))) + (old-spans + (cl-remove-if-not + (lambda (span) + (when-let ((numeric + (ebox-buffer--numeric-role-span span))) + (cl-some + (lambda (range) + (ebox-buffer--numeric-span-touches-range-p + numeric range)) + old-ranges))) + (gethash key table)))) + (plist-put entry :old-spans old-spans) + (plist-put entry :new-ranges + (ebox-buffer--merge-numeric-spans + (plist-get entry :new-ranges))) + (push entry entries))) + entries-by-key) + (list :entries entries)))))) + +(defun ebox-buffer--role-span-outside-replacements (span replacements) + "Translate the parts of old SPAN outside REPLACEMENTS to new coordinates." + (let ((cursor (car span)) + (end (cdr span)) + (delta 0) + translated) + (catch 'done + (dolist (replacement replacements) + (let ((old-start (plist-get replacement :old-start)) + (old-end (plist-get replacement :old-end)) + (replacement-delta (plist-get replacement :delta))) + (cond + ((<= old-end cursor) + (setq delta (+ delta replacement-delta))) + ((>= old-start end) + (throw 'done nil)) + (t + (let ((left-end (min end old-start))) + (when (< cursor left-end) + (push (cons (+ cursor delta) (+ left-end delta)) + translated))) + (setq cursor (max cursor (min end old-end)) + delta (+ delta replacement-delta))))))) + (when (< cursor end) + (push (cons (+ cursor delta) (+ end delta)) translated)) + (nreverse translated))) + +(defun ebox-buffer--role-spans-for-key-in-ranges (key ranges) + "Return exact current spans for role KEY inside buffer RANGES." + (let ((region-id (car key)) + (property (ebox-buffer--role-property (cadr key))) + spans) + (dolist (range ranges) + (let ((position (car range)) + (end (cdr range))) + (while (< position end) + (let ((next (or (next-single-property-change + position property nil end) + end))) + (when (equal (get-text-property position property) region-id) + (push (cons position next) spans)) + (setq position next))))) + spans)) + +(defun ebox-buffer--merge-numeric-spans (spans) + "Return sorted, coalesced numeric SPANS." + (let (merged) + (dolist (span (sort (delq nil spans) + (lambda (left right) + (< (car left) (car right))))) + (if (and merged (<= (car span) (cdar merged))) + (setcdr (car merged) (max (cdar merged) (cdr span))) + (push (copy-tree span) merged))) + (nreverse merged))) + +(defun ebox-buffer--detach-role-spans (spans) + "Detach marker endpoints in SPANS." + (dolist (span spans) + (when (markerp (car span)) + (set-marker (car span) nil)) + (when (markerp (cdr span)) + (set-marker (cdr span) nil)))) + +(defun ebox-buffer--detach-region-role-span-table (buffer) + "Detach BUFFER's marker-backed role table before replacing it." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (when-let ((table (ebox-buffer--region-role-span-table))) + (when (ebox-buffer--marker-role-span-table-p table) + (maphash + (lambda (key spans) + (when (consp key) + (ebox-buffer--detach-role-spans spans))) + table) + (ebox--set-buffer-region-role-span-table buffer nil) + t))))) + +(defun ebox-buffer--detach-role-span-table-markers (table) + "Detach marker endpoints in marker-backed role span TABLE." + (when (and (hash-table-p table) + (ebox-buffer--marker-role-span-table-p table)) + (maphash + (lambda (key spans) + (when (consp key) + (ebox-buffer--detach-role-spans spans))) + table))) + +(defun ebox-buffer--merge-role-markers-with-numeric-spans + (marker-spans numeric-spans) + "Merge exact NUMERIC-SPANS into sorted existing MARKER-SPANS. +Markers outside the changed ranges are retained rather than recreated." + (let ((result (copy-sequence marker-spans))) + (dolist (numeric (ebox-buffer--merge-numeric-spans numeric-spans)) + (let (touching untouched) + (dolist (span result) + (if (ebox-buffer--numeric-span-touches-range-p + (cons (marker-position (car span)) + (marker-position (cdr span))) + numeric) + (push span touching) + (push span untouched))) + (if touching + (let* ((touching (nreverse touching)) + (primary (car touching)) + (start + (min (car numeric) + (cl-loop for span in touching + minimize (marker-position (car span))))) + (end + (max (cdr numeric) + (cl-loop for span in touching + maximize (marker-position (cdr span)))))) + (set-marker (car primary) start) + (set-marker (cdr primary) end) + (ebox-buffer--detach-role-spans (cdr touching)) + (setq result (cons primary untouched))) + (setq result + (cons (ebox-buffer--make-role-span + (car numeric) (cdr numeric)) + untouched))))) + (sort result + (lambda (left right) + (< (marker-position (car left)) + (marker-position (car right))))))) + +(defun ebox-buffer-apply-role-delta (buffer delta) + "Apply captured role-index DELTA after exact root patches changed BUFFER." + (when (and (buffer-live-p buffer) delta) + (with-current-buffer buffer + (when-let ((table (ebox-buffer--region-role-span-table))) + (dolist (entry (plist-get delta :entries)) + (let* ((key (plist-get entry :key)) + (old-spans (plist-get entry :old-spans)) + (current-spans (gethash key table)) + (preserved-spans + (cl-remove-if + (lambda (span) (memq span old-spans)) + current-spans)) + (dirty-ranges + (append + (plist-get entry :new-ranges) + (delq nil + (mapcar #'ebox-buffer--numeric-role-span + old-spans)))) + (replacement-spans + (ebox-buffer--role-spans-for-key-in-ranges + key (ebox-buffer--merge-numeric-spans dirty-ranges))) + (merged + (ebox-buffer--merge-role-markers-with-numeric-spans + preserved-spans replacement-spans))) + (ebox-buffer--detach-role-spans old-spans) + (if merged + (puthash key + merged + table) + (remhash key table)))) + t)))) + +(defun ebox-buffer--current-region-role-span-p (span region-id role) + "Return non-nil when SPAN still carries REGION-ID for ROLE. +Marker survival alone is insufficient: scroll-window replacement can leave a +nonempty marker range at coordinates now owned by a different region." + (let* ((start (car-safe span)) + (end (cdr-safe span)) + (start-pos (if (markerp start) (marker-position start) start)) + (end-pos (if (markerp end) (marker-position end) end))) + (and (number-or-marker-p start) + (number-or-marker-p end) + (or (not (markerp start)) + (eq (marker-buffer start) (current-buffer))) + (or (not (markerp end)) + (eq (marker-buffer end) (current-buffer))) + start-pos end-pos (< start-pos end-pos) + (not (text-property-not-all + start-pos end-pos + (ebox-buffer--role-property role) region-id))))) + +(defun ebox-buffer--marker-role-span-table-p (table) + "Return non-nil when TABLE stores marker-backed role spans." + (gethash ebox-buffer--role-span-table-marker-key table)) + +(defun ebox-buffer--build-region-role-span-table () + "Build a role span index for the current buffer." + (let ((table (make-hash-table :test 'equal))) + (puthash ebox-buffer--role-span-table-marker-key t table) + (dolist (entry ebox-region-types) + (let ((role (car entry)) + (prop (cdr entry))) + (save-excursion + (goto-char (point-min)) + (while (< (point) (point-max)) + (let* ((region-id (get-text-property (point) prop)) + (next (or (next-single-property-change + (point) prop nil (point-max)) + (point-max)))) + (when region-id + (ebox-buffer--record-role-span + table region-id role (point) next)) + (goto-char (max next (1+ (point))))))))) + (maphash (lambda (key spans) + (when (consp key) + (puthash key (nreverse spans) table))) + table) + table)) + +(defvar ebox-buffer--region-property-role-table-cache nil + "Cached property-to-role lookup for `ebox-region-types'.") + +(defvar ebox-buffer--region-property-role-table-source nil + "The `ebox-region-types' object used to build the role lookup cache.") + +(defun ebox-buffer--region-property-role-table () + "Return an eq table mapping Ebox text properties to region roles." + (unless (and ebox-buffer--region-property-role-table-cache + (eq ebox-buffer--region-property-role-table-source + ebox-region-types)) + (let ((table (make-hash-table :test 'eq))) + (dolist (entry ebox-region-types) + (puthash (cdr entry) (car entry) table)) + (setq ebox-buffer--region-property-role-table-cache table + ebox-buffer--region-property-role-table-source + ebox-region-types))) + ebox-buffer--region-property-role-table-cache) + +(defun ebox-buffer--build-region-role-span-template (string) + "Build an immutable point-min-based role span template from STRING." + (let ((table (make-hash-table :test 'equal)) + (property-roles (ebox-buffer--region-property-role-table)) + (limit (length string)) + (position 0)) + (puthash ebox-buffer--role-span-table-template-key t table) + ;; Role properties share the same interval tree. Walking each role over + ;; the whole output multiplies root-prewarm work by `ebox-region-types'. + ;; Visit every text-property interval once, then coalesce boundaries caused + ;; only by unrelated properties back into the role's maximal span. + (while (< position limit) + (let* ((properties (text-properties-at position string)) + (next (or (next-property-change position string limit) limit)) + (start (1+ position)) + (end (1+ next))) + (while properties + (let* ((property (pop properties)) + (region-id (pop properties)) + (role (gethash property property-roles))) + (when (and role region-id) + (let* ((key (ebox-buffer--region-role-key region-id role)) + (spans (gethash key table))) + (if (and spans (= (cdar spans) start)) + (setcdr (car spans) end) + (puthash key (cons (cons start end) spans) table)))))) + (setq position (max next (1+ position))))) + (maphash (lambda (key spans) + (when (consp key) + (puthash key (nreverse spans) table))) + table) + table)) + +(defun ebox-buffer--install-region-role-span-template + (buffer template start) + "Install prepared TEMPLATE in BUFFER at root START." + (when (and (buffer-live-p buffer) (hash-table-p template)) + (with-current-buffer buffer + (ebox-buffer--detach-region-role-span-table buffer) + (let ((table + (if (= start (point-min)) + (progn + (puthash ebox-buffer--role-span-table-template-key + t template) + template) + (let ((translated (make-hash-table :test 'equal)) + (delta (- start (point-min)))) + (puthash ebox-buffer--role-span-table-template-key + t translated) + (maphash + (lambda (key spans) + (when (consp key) + (puthash + key + (mapcar (lambda (span) + (cons (+ delta (car span)) + (+ delta (cdr span)))) + spans) + translated))) + template) + translated)))) + (ebox--set-buffer-region-role-span-table buffer table) + table)))) + +(defun ebox-buffer--region-role-span-table-from-template (template start) + "Return a buffer-coordinate role span table from TEMPLATE at START." + (when (hash-table-p template) + (if (= start (point-min)) + (let ((table (copy-hash-table template))) + (puthash ebox-buffer--role-span-table-template-key t table) + table) + (let ((translated (make-hash-table :test 'equal)) + (delta (- start (point-min)))) + (puthash ebox-buffer--role-span-table-template-key t translated) + (maphash + (lambda (key spans) + (when (consp key) + (puthash + key + (mapcar (lambda (span) + (cons (+ delta (car span)) + (+ delta (cdr span)))) + spans) + translated))) + template) + translated)))) + +(defun ebox-buffer--target-span-valid-p (span start end) + "Return non-nil when SPAN is an integer range inside START..END." + (and (consp span) + (integerp (car span)) + (integerp (cdr span)) + (<= start (car span)) + (< (car span) (cdr span)) + (<= (cdr span) end))) + +(defun ebox-buffer--validate-role-span-template (table start target-length) + "Validate translated role span TABLE inside START plus TARGET-LENGTH." + (let ((end (+ start target-length))) + (unless (hash-table-p table) + (error "Native root patch has invalid role indexes")) + (maphash + (lambda (key spans) + (when (consp key) + (dolist (span spans) + (unless (ebox-buffer--target-span-valid-p span start end) + (error "Native root patch has invalid role index span"))))) + table) + t)) + +(defun ebox-buffer--validate-box-extent-template + (template start target-length) + "Return validated buffer-coordinate extents for target TEMPLATE." + (let ((end (+ start target-length)) + (delta (- start (point-min))) + (translated-table (make-hash-table :test 'equal))) + (unless (hash-table-p template) + (error "Native root patch has invalid extent indexes")) + (maphash + (lambda (region-id span) + (let ((translated (cons (+ delta (car span)) + (+ delta (cdr span))))) + (unless (ebox-buffer--target-span-valid-p translated start end) + (error "Native root patch has invalid extent index span")) + (puthash region-id translated translated-table))) + template) + translated-table)) + +(defun ebox-buffer-materialize-region-role-spans (buffer) + "Convert BUFFER's current integer role span table to marker-backed spans." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (when-let ((current (ebox-buffer--region-role-span-table))) + (unless (ebox-buffer--marker-role-span-table-p current) + (let ((table (make-hash-table :test 'equal))) + (puthash ebox-buffer--role-span-table-marker-key t table) + (maphash + (lambda (key spans) + (when (consp key) + (puthash key + (mapcar (lambda (span) + (ebox-buffer--make-role-span + (car span) (cdr span))) + spans) + table))) + current) + (ebox--set-buffer-region-role-span-table buffer table))) + (ebox-buffer--region-role-span-table))))) + +(defun ebox-buffer-refresh-region-role-spans (buffer) + "Refresh BUFFER's region role span index from current text properties." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (ebox--set-buffer-region-role-span-table + buffer (ebox-buffer--build-region-role-span-table))))) + +(defun ebox-buffer--span-overlaps-p (left right) + "Return non-nil when LEFT and RIGHT buffer spans overlap." + (and (< (car left) (cdr right)) + (> (cdr left) (car right)))) + +(defun ebox-buffer--role-span-replacements (old-spans new-spans) + "Return OLD-SPANS to NEW-SPANS replacement maps, or nil if ambiguous." + (when (= (length old-spans) (length new-spans)) + (cl-loop for old in old-spans + for new in new-spans + collect (list :old-start (car old) + :old-end (cdr old) + :new-start (car new) + :new-end (cdr new) + :delta (- (- (cdr new) (car new)) + (- (cdr old) (car old))))))) + +(defun ebox-buffer--role-span-replacement-index (replacements) + "Return a binary-search index with cumulative deltas for REPLACEMENTS." + (let ((delta 0)) + (vconcat + (mapcar + (lambda (replacement) + (cl-incf delta (plist-get replacement :delta)) + (vector (plist-get replacement :old-start) + (plist-get replacement :old-end) + delta)) + replacements)))) + +(defun ebox-buffer--translate-position-through-replacement-index + (pos index &optional end-boundary-p) + "Translate old POS through replacement INDEX, or return nil if ambiguous." + (let ((low 0) + (high (length index))) + (while (< low high) + (let* ((middle (/ (+ low high) 2)) + (old-start (aref (aref index middle) 0))) + (if (if end-boundary-p (< old-start pos) (<= old-start pos)) + (setq low (1+ middle)) + (setq high middle)))) + (if (zerop low) + pos + (let ((replacement (aref index (1- low)))) + (unless (if end-boundary-p + (<= pos (aref replacement 1)) + (< pos (aref replacement 1))) + (+ pos (aref replacement 2))))))) + +(defun ebox-buffer--translate-role-span-through-replacements + (span replacements &optional replacement-index) + "Translate SPAN through REPLACEMENTS, or return nil if ambiguous." + (let* ((index (or replacement-index + (ebox-buffer--role-span-replacement-index replacements))) + (start (ebox-buffer--translate-position-through-replacement-index + (car span) index)) + (end (ebox-buffer--translate-position-through-replacement-index + (cdr span) index t))) + (and start end (<= start end) (cons start end)))) + +(defun ebox-buffer--translate-region-role-span-table + (table region-ids old-spans new-spans) + "Translate untouched TABLE entries after replacing OLD-SPANS with NEW-SPANS. +Return non-nil when the existing table remains valid after translation. +Return nil when an untouched role span crosses replaced text; callers must +discard the table so later lookups rescan current text properties." + (let ((replacements (ebox-buffer--role-span-replacements + old-spans new-spans))) + (and replacements + (let ((region-set (ebox--region-id-set region-ids)) + (replacement-index + (ebox-buffer--role-span-replacement-index replacements))) + (catch 'unsafe + (maphash + (lambda (key spans) + (when (and (consp key) + (not (gethash (car key) region-set))) + (let (translated) + (dolist (span spans) + (if-let ((new-span + (ebox-buffer--translate-role-span-through-replacements + span replacements replacement-index))) + (push new-span translated) + (throw 'unsafe nil))) + (puthash key (nreverse translated) table)))) + table) + t))))) + +(defun ebox-buffer--same-role-span-bounds-p (old-spans new-spans) + "Return non-nil when OLD-SPANS and NEW-SPANS occupy the same coordinates." + (and (= (length old-spans) (length new-spans)) + (cl-every + (lambda (pair) + (let ((old (car pair)) + (new (cdr pair))) + (and (= (car old) (car new)) + (= (cdr old) (cdr new))))) + (cl-mapcar #'cons old-spans new-spans)))) + +(defun ebox-buffer--rendered-line-bounds (rendered) + "Return 0-based content bounds for each line in RENDERED." + (let ((start 0) + bounds) + (while (<= start (length rendered)) + (let ((newline (string-match-p "\n" rendered start))) + (push (cons start (or newline (length rendered))) bounds) + (if newline + (setq start (1+ newline)) + (setq start (1+ (length rendered)))))) + (vconcat (nreverse bounds)))) + +(defun ebox-buffer--rendered-line-index-at (line-bounds position) + "Return the LINE-BOUNDS index containing rendered POSITION." + (let ((index 0) + found) + (while (and (< index (length line-bounds)) (not found)) + (let ((bounds (aref line-bounds index))) + (if (and (<= (car bounds) position) + (< position (cdr bounds))) + (setq found index) + (setq index (1+ index))))) + found)) + +(defun ebox-buffer--numeric-span-start (span) + "Return SPAN's numeric start coordinate." + (let ((start (car-safe span))) + (if (markerp start) (marker-position start) start))) + +(defun ebox-buffer--record-active-extent-segment + (segments-by-region region-set region-id start end) + "Record REGION-ID START..END in SEGMENTS-BY-REGION when active." + (when (and segments-by-region + (gethash region-id region-set) + (< start end)) + (puthash region-id + (cons (cons start end) + (gethash region-id segments-by-region)) + segments-by-region))) + +(defun ebox-buffer--record-rendered-active-extent-segments + (segments-by-region region-set owners line-bounds spans start end) + "Record active extent OWNERS from rendered START..END in buffer SPANS." + (when (and segments-by-region owners) + (let ((segment-start start)) + (while (< segment-start end) + (if-let* ((line-index + (ebox-buffer--rendered-line-index-at + line-bounds segment-start)) + ((< line-index (length spans))) + (line-bounds-entry + (aref line-bounds line-index)) + (span-start + (ebox-buffer--numeric-span-start + (nth line-index spans)))) + (let* ((segment-end (min end (cdr line-bounds-entry))) + (buffer-start + (+ span-start (- segment-start + (car line-bounds-entry)))) + (buffer-end + (+ span-start (- segment-end + (car line-bounds-entry))))) + (dolist (owner owners) + (ebox-buffer--record-active-extent-segment + segments-by-region region-set owner + buffer-start buffer-end)) + (setq segment-start + (if (< segment-end end) + (1+ segment-end) + segment-end))) + (setq segment-start end)))))) + +(defun ebox-buffer--record-rendered-role-spans + (table region-set rendered spans &optional touched-keys active-segments) + "Record role spans from RENDERED into TABLE translated through SPANS." + (let ((property-roles (ebox-buffer--region-property-role-table)) + (line-bounds (ebox-buffer--rendered-line-bounds rendered)) + (limit (length rendered)) + (position 0)) + (while (< position limit) + (let* ((properties (text-properties-at position rendered)) + (next (or (next-property-change position rendered limit) + limit))) + (ebox-buffer--record-rendered-active-extent-segments + active-segments region-set + (get-text-property position 'ebox-content-owners rendered) + line-bounds spans position next) + (while properties + (let* ((property (pop properties)) + (region-id (pop properties)) + (role (gethash property property-roles))) + (when (and role region-id (gethash region-id region-set)) + (let ((segment-start position)) + (while (< segment-start next) + (if-let* ((line-index + (ebox-buffer--rendered-line-index-at + line-bounds segment-start)) + ((< line-index (length spans))) + (line-bounds-entry + (aref line-bounds line-index)) + (span-start + (ebox-buffer--numeric-span-start + (nth line-index spans)))) + (let* ((segment-end + (min next (cdr line-bounds-entry))) + (buffer-start + (+ span-start + (- segment-start + (car line-bounds-entry)))) + (buffer-end + (+ span-start + (- segment-end + (car line-bounds-entry))))) + (when (and role region-id + (gethash region-id region-set)) + (ebox-buffer--record-role-span + table region-id role buffer-start buffer-end)) + (unless (eq role 'content-owner) + (ebox-buffer--record-active-extent-segment + active-segments region-set region-id + buffer-start buffer-end)) + (when (and role region-id touched-keys) + (puthash + (ebox-buffer--region-role-key region-id role) + t touched-keys)) + (setq segment-start + (if (< segment-end next) + (1+ segment-end) + segment-end))) + (setq segment-start next))))))) + (setq position (max next (1+ position))))) + t)) + +(defun ebox-buffer-refresh-region-role-spans-for-region-ids + (buffer region-ids spans &optional old-spans rendered-spans) + "Refresh role span table entries for REGION-IDS inside SPANS in BUFFER. +When OLD-SPANS is non-nil, translate untouched integer span coordinates from +OLD-SPANS to SPANS before rebuilding REGION-IDS entries. RENDERED-SPANS, +when non-nil, is a list of (RENDERED . SPANS) pairs used to rebuild touched +entries without rescanning BUFFER text properties." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (when-let ((table (ebox-buffer--region-role-span-table))) + (if (and old-spans + (not (ebox-buffer--same-role-span-bounds-p + old-spans spans)) + (not (ebox-buffer--marker-role-span-table-p table)) + (not (ebox-buffer--translate-region-role-span-table + table region-ids old-spans spans))) + (ebox--set-buffer-region-role-span-table + buffer (ebox-buffer--build-region-role-span-table)) + (let ((region-set (ebox--region-id-set region-ids)) + (touched-keys (make-hash-table :test 'equal)) + (active-segments (make-hash-table :test 'equal))) + (dolist (region-id region-ids) + (dolist (entry ebox-region-types) + (remhash + (ebox-buffer--region-role-key region-id (car entry)) + table))) + (if rendered-spans + (dolist (entry rendered-spans) + (ebox-buffer--record-rendered-role-spans + table region-set (car entry) (cdr entry) + touched-keys active-segments)) + (dolist (span spans) + (dolist (entry ebox-region-types) + (let ((role (car entry)) + (prop (cdr entry)) + (pos (car span))) + (while (< pos (cdr span)) + (let* ((region-id (get-text-property pos prop)) + (next (or (next-single-property-change + pos prop nil (cdr span)) + (cdr span)))) + (when (gethash region-id region-set) + (let ((key (ebox-buffer--region-role-key + region-id role))) + (puthash key t touched-keys) + (ebox-buffer--record-role-span + table region-id role pos next) + (unless (eq role 'content-owner) + (ebox-buffer--record-active-extent-segment + active-segments region-set + region-id pos next)))) + (setq pos (max next (1+ pos)))))))) + (dolist (span spans) + (let ((pos (car span))) + (while (< pos (cdr span)) + (let* ((owners (get-text-property + pos 'ebox-content-owners)) + (next (or (next-single-property-change + pos 'ebox-content-owners nil + (cdr span)) + (cdr span)))) + (dolist (owner owners) + (ebox-buffer--record-active-extent-segment + active-segments region-set owner pos next)) + (setq pos (max next (1+ pos)))))))) + (maphash + (lambda (key _) + (when-let ((spans (gethash key table))) + (puthash key + (sort spans (lambda (left right) + (< (car left) (car right)))) + table))) + touched-keys) + (list :active-segments active-segments))))))) + +(defun ebox-buffer--horizontal-border-anchor-spans (region-id role) + "Infer a top or bottom border span for REGION-ID from existing box anchors." + (let ((line-ranges (make-hash-table :test 'eql)) + line-keys) + (save-excursion + (dolist (anchor-role ebox--horizontal-border-anchor-roles) + (dolist (span (ebox--region-find region-id anchor-role)) + (goto-char (car span)) + (let* ((line-start (line-beginning-position)) + (range (gethash line-start line-ranges))) + (if range + (progn + (setcar range (min (car range) (car span))) + (setcdr range (max (cdr range) (cdr span)))) + (push line-start line-keys) + (puthash line-start (copy-tree span) line-ranges)))))) + (when line-keys + (let* ((line (if (eq role 'bt) + (apply #'min line-keys) + (apply #'max line-keys))) + (span (gethash line line-ranges))) + (and span (list span)))))) + +(defun ebox-buffer--horizontal-border-face-value (box role) + "Return the face value for BOX horizontal border ROLE." + (pcase role + ('bt + (or (ebox-get box :border-top-color) t)) + ('bb + (let ((color (ebox-get box :border-bottom-color))) + (if color + (list :position t :color color) + (list :position t)))))) + +(defun ebox-buffer--horizontal-border-enabled-p (box role) + "Return non-nil when BOX should display horizontal border ROLE." + (pcase role + ('bt (ebox-get box :border-top-p)) + ('bb (ebox-get box :border-bottom-p)))) + +(defun ebox--paint-horizontal-border-surface (box role) + "Return a paint-surface spec for BOX horizontal border ROLE." + (let ((enabled (ebox-buffer--horizontal-border-enabled-p box role))) + (list :role role + :face-key (if (eq role 'bt) :overline :underline) + :face-value (if enabled + (ebox-buffer--horizontal-border-face-value box role) + ebox-buffer--remove-face-key) + :missing (if enabled 'infer 'noop) + :remove-role (not enabled)))) + +(defun ebox-buffer--paint-side-border-surfaces (box role color-key) + "Return paint specs for BOX side ROLE using COLOR-KEY." + (let ((color (ebox-get box color-key))) + (list + (list :role role + :face-key :background + :face-value (or color ebox-buffer--remove-face-key) + :missing 'noop + :own-region-only t) + (list :role role + :face-key :inverse-video + :face-value (if color ebox-buffer--remove-face-key t) + :missing 'noop + :own-region-only t)))) + +(defun ebox--paint-text-property-patches (box changed-key) + "Return text-property patch specs for BOX CHANGED-KEY, or nil. +Each result is a backend paint-surface plist." + (pcase changed-key + (:color + (mapcar (lambda (role) + (list :role role + :face-key :foreground + :face-value (or (ebox-get box :color) + ebox-buffer--remove-face-key) + :missing 'noop + :paint-prop :color)) + ebox--paint-content-region-roles)) + (:bgcolor + (mapcar (lambda (role) + (list :role role + :face-key :background + :face-value (or (ebox-get box :bgcolor) + ebox-buffer--remove-face-key) + :missing 'noop + :paint-prop :bgcolor)) + ebox--paint-content-region-roles)) + (:border-left-color + (ebox-buffer--paint-side-border-surfaces + box 'bl :border-left-color)) + (:border-right-color + (ebox-buffer--paint-side-border-surfaces + box 'br :border-right-color)) + (:border-top-color + (list (append (ebox--paint-horizontal-border-surface box 'bt) + '(:own-region-only t)))) + (:border-bottom-color + (list (append (ebox--paint-horizontal-border-surface box 'bb) + '(:own-region-only t)))) + (:border-top-p + (list (append (ebox--paint-horizontal-border-surface box 'bt) + '(:own-region-only t)))) + (:border-bottom-p + (list (append (ebox--paint-horizontal-border-surface box 'bb) + '(:own-region-only t)))) + (_ nil))) + +(defun ebox-buffer--paint-surface-key (spec) + "Return the deduplication key for paint-surface SPEC." + (list (plist-get spec :role) + (plist-get spec :face-key))) + +(defun ebox--paint-text-property-surfaces (box changed-keys) + "Return deduplicated paint surfaces for BOX CHANGED-KEYS. +Return nil when any changed key is not a direct text-property paint surface." + (let ((table (make-hash-table :test 'equal)) + keys + supported) + (setq supported t) + (dolist (changed-key changed-keys) + (if-let ((specs (ebox--paint-text-property-patches + box changed-key))) + (dolist (spec specs) + (let ((key (ebox-buffer--paint-surface-key spec))) + (unless (gethash key table) + (push key keys)) + (puthash key spec table))) + (setq supported nil))) + (when supported + (mapcar (lambda (key) + (gethash key table)) + (nreverse keys))))) + +(defun ebox-buffer--surface-spans (region-id spec) + "Return patch spans for REGION-ID and paint-surface SPEC." + (let* ((role (plist-get spec :role)) + (spans (ebox--region-find region-id role))) + (cond + (spans spans) + ((eq (plist-get spec :missing) 'infer) + (let ((inferred + (ebox-buffer--horizontal-border-anchor-spans region-id role))) + (when inferred + (let ((prop (ebox-buffer--role-property role))) + (dolist (span inferred) + (put-text-property (car span) (cdr span) prop region-id))) + (ebox-buffer--put-region-role-spans region-id role inferred) + inferred))) + (t nil)))) + +(defun ebox-buffer--patch-surface (region-id spec) + "Apply paint-surface SPEC to REGION-ID and return patched span count." + (let ((spans (ebox-buffer--surface-spans region-id spec)) + (role (plist-get spec :role)) + (face-key (plist-get spec :face-key)) + (face-value (plist-get spec :face-value)) + (remove-role (plist-get spec :remove-role)) + (count 0)) + (dolist (span spans) + (if (memq role '(bt bb)) + (ebox--patch-horizontal-border-face-in-span + (car span) (cdr span) face-key face-value) + (ebox--patch-face-key-in-span + (car span) (cdr span) face-key face-value)) + (when remove-role + (remove-text-properties + (car span) (cdr span) + (list (ebox-buffer--role-property + role) + nil))) + (cl-incf count)) + (when remove-role + (ebox-buffer--put-region-role-spans region-id role nil)) + count)) + +(defun ebox-buffer--paint-surfaces-safe-for-node-p (node surfaces) + "Return non-nil when SURFACES can be face-patched across NODE's spans. +Content paint follows nearest-declaration semantics, but the owner's +content-owner spans cover every descendant character and anonymous +face entries carry no ownership, so the cheap face patch cannot skip a +descendant's own entry. When any descendant declares the same paint +property, decline; callers fall back to an exact owner render." + (cl-every (lambda (spec) + (let ((paint-prop (plist-get spec :paint-prop))) + (or (null paint-prop) + (not (ebox--node-paint-conflict-p node paint-prop))))) + surfaces)) + +(defun ebox--execute-text-property-paint-patch (buffer op node) + "Execute OP as direct text-property edits for NODE when possible." + (let* ((dirty (plist-get op :dirty)) + (changed-keys (plist-get dirty :changed-keys)) + (region-ids (or (plist-get dirty :region-ids) + (ebox--node-all-region-ids node))) + (surfaces (and changed-keys + (not (and (memq :color changed-keys) + (ebox--node-visible-overflow-p node))) + (ebox--paint-text-property-surfaces + node changed-keys))) + (patched 0)) + (when (and surfaces + (ebox-buffer--paint-surfaces-safe-for-node-p node surfaces)) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (with-silent-modifications + (dolist (spec surfaces) + ;; Border surfaces belong to the owner's own chrome: a + ;; full render never propagates an ancestor's border + ;; color into a descendant's border runs, so the cheap + ;; face patch must not either (issue014). + (dolist (region-id + (if (plist-get spec :own-region-only) + (let ((own (ebox-get node :region-id))) + (and own (memq own region-ids) (list own))) + region-ids)) + (cl-incf patched + (ebox-buffer--patch-surface + region-id spec))))))) + (list :owner-id (plist-get op :owner-id) + :op 'paint-patch + :owner-type (plist-get node :ebox-type) + :span-count patched + :text-property-patch t)))) + +(defun ebox--line-spans-contiguous-p (spans) + "Return non-nil when SPANS cover adjacent whole buffer lines." + (or (null spans) + (cl-loop for rest on spans + while (cdr rest) + always (= (caadr rest) (1+ (cdar rest)))))) + +(defun ebox--buffer-line-spans-between (start end) + "Return actual buffer line spans between START and END." + (if (= start end) + (list (cons start end)) + (let (spans) + (save-excursion + (goto-char start) + (while (< (point) end) + (push (cons (point) (min (line-end-position) end)) spans) + (forward-line 1))) + (nreverse spans)))) + +(defun ebox--span-wrapper-owners (span) + "Return SPAN's wrapper owner stack, inner to outer." + (or (get-text-property (car span) 'ebox-content-owners) + (when-let ((owner (get-text-property (car span) 'ebox-content-owner))) + (list owner)))) + +(defun ebox--common-wrapper-owners (owner-stacks) + "Return owner ids common to every stack in OWNER-STACKS. +The returned order follows the first stack, preserving inner-to-outer order." + (when owner-stacks + (let ((common (copy-sequence (car owner-stacks)))) + (dolist (owners (cdr owner-stacks)) + (setq common + (cl-remove-if-not + (lambda (owner) (member owner owners)) + common))) + common))) + +(defun ebox--spans-wrapper-owners (spans) + "Return outer wrapper owners shared by every nonempty SPAN. +When a replacement covers sibling boxes, child-specific owners from the first +line must not be copied into all new lines." + (let (owner-stacks) + (dolist (span spans) + (when (< (car span) (cdr span)) + (push (ebox--span-wrapper-owners span) owner-stacks))) + (ebox--common-wrapper-owners (nreverse owner-stacks)))) + +(defun ebox--string-content-owner-set (string) + "Return a set of content owner ids present anywhere in STRING." + (let ((pos 0) + (limit (length string)) + (owners (make-hash-table :test 'equal))) + (while (< pos limit) + (dolist (owner (ebox--content-owner-stack-at string pos)) + (puthash owner t owners)) + (setq pos (ebox--next-content-owner-property-change + pos string limit))) + owners)) + +(defun ebox-buffer--replacement-owner-set + (rendered dirty &optional replaced-owner-ids) + "Return RENDERED owners plus replaced history from DIRTY and OWNER-IDS." + (let ((owners (ebox--string-content-owner-set rendered))) + ;; Structural replacement must suppress removed child owners inherited + ;; from the old lines. They are replacement-owned history, not wrappers. + (dolist (owner (plist-get dirty :old-region-ids)) + (puthash owner t owners)) + (dolist (owner replaced-owner-ids) + (puthash owner t owners)) + owners)) + +(defun ebox-buffer--owners-inside-replacement-boundary + (spans boundary-owner-ids) + "Return old SPANS owners at or inside BOUNDARY-OWNER-IDS." + (let ((boundary-set (make-hash-table :test 'equal)) + replaced) + (dolist (owner boundary-owner-ids) + (puthash owner t boundary-set)) + (dolist (span spans) + (let ((position (car span)) + (limit (cdr span))) + (while (< position limit) + (let* ((owners (ebox--content-owner-stack-at nil position)) + (boundary + (cl-position-if + (lambda (owner) (gethash owner boundary-set)) + owners :from-end t))) + (when boundary + (setq replaced + (nconc (cl-subseq owners 0 (1+ boundary)) replaced)))) + (setq position + (ebox--next-content-owner-property-change + position nil limit))))) + (delete-dups replaced))) + +(defun ebox--string-with-preserved-horizontal-border + (string span owner box &optional edge) + "Return STRING with OWNER's horizontal EDGE from SPAN and BOX. +Nil EDGE preserves both outer edges." + (let ((result string)) + (when (and span (< (car span) (cdr span))) + (when (and (memq edge '(nil top)) + (equal (get-text-property (car span) 'ebox-bt) owner)) + (setq result + (ebox--propertize-overline + result (ebox-get box :border-top-color))) + (add-text-properties 0 (length result) `(ebox-bt ,owner) result)) + (when (and (memq edge '(nil bottom)) + (equal (get-text-property (car span) 'ebox-bb) owner)) + (setq result + (ebox--propertize-underline + result (ebox-get box :border-bottom-color))) + (add-text-properties 0 (length result) `(ebox-bb ,owner) result))) + result)) + +(defun ebox--string-with-preserved-wrapper-context + (string owners rendered-owner-set &optional span horizontal-edge) + "Return STRING with missing outer OWNERS restored. +RENDERED-OWNER-SET identifies already-owned content. SPAN supplies old paint; +HORIZONTAL-EDGE limits restored border paint to `top', `bottom', or neither." + (let* ((missing (cl-remove-if (lambda (owner) + (gethash owner rendered-owner-set)) + owners)) + (result (copy-sequence string))) + (dolist (owner missing) + (when-let ((box (gethash owner ebox--region-box-table))) + (when-let ((color (ebox-get box :color))) + (setq result (ebox--propertize-color result color))) + (when-let ((bgcolor (ebox-get box :bgcolor))) + (setq result (ebox--propertize-bgcolor result bgcolor))) + (when-let ((properties (ebox-get box :surface-properties))) + (setq result + (ebox--apply-surface-properties result properties))) + (setq result + (ebox--string-with-preserved-horizontal-border + result span owner box horizontal-edge)))) + (ebox--add-content-owners result missing))) + +(defun ebox--string-with-preserved-wrapper-owners (spans string) + "Return STRING preserving SPANS' outer wrapper ownership and paint." + (let ((rendered-owner-set (ebox--string-content-owner-set string))) + (if-let ((owners (ebox--spans-wrapper-owners spans))) + (let* ((lines (ebox-string-lines string)) + (last-index (1- (length lines))) + (last-span (car (last spans))) + (interior-limit (1- (length spans)))) + (ebox-lines-join + (cl-loop + for line in lines + for index from 0 + for span = (cond + ((zerop index) (car spans)) + ((= index last-index) last-span) + ((< index interior-limit) (nth index spans))) + for horizontal-edge = (cond + ((zerop last-index) nil) + ((zerop index) 'top) + ((= index last-index) 'bottom) + (t 'none)) + collect + (ebox--string-with-preserved-wrapper-context + line owners rendered-owner-set span horizontal-edge)))) + string))) + +(defun ebox--replace-contiguous-line-spans + (spans rendered &optional skip-wrapper-context) + "Replace contiguous SPANS with RENDERED and return new spans on success. +When SKIP-WRAPPER-CONTEXT is non-nil, RENDERED already covers the complete +runtime root and therefore needs no ownership recovered from the old text." + (when (and spans (ebox--line-spans-contiguous-p spans)) + (ebox--with-preserved-buffer-window-state (current-buffer) + (let* ((inhibit-read-only t) + (point-marker (copy-marker (point) t)) + (start (caar spans)) + (end (cdar (last spans))) + (inserted + (if skip-wrapper-context + rendered + (ebox--string-with-preserved-wrapper-owners + spans rendered))) + (start-marker (copy-marker start nil)) + (end-marker nil)) + (save-excursion + (goto-char start) + (delete-region start end) + (insert inserted) + (setq end-marker (copy-marker (point) t))) + (goto-char (min (marker-position point-marker) (point-max))) + (prog1 + (ebox--buffer-line-spans-between + (marker-position start-marker) + (marker-position end-marker)) + (set-marker point-marker nil) + (set-marker start-marker nil) + (set-marker end-marker nil)))))) + +(defun ebox-buffer--canonical-property-alist (properties) + "Return text PROPERTIES in semantic key order. +Text-property plists are unordered maps even though their cons order can +change when strings cross the native module boundary." + (sort (cl-loop for (key value) on properties by #'cddr + collect (cons key value)) + (lambda (left right) + (string< (symbol-name (car left)) + (symbol-name (car right)))))) + +(defun ebox-buffer--property-maps-equal-p (old-properties new-properties) + "Return non-nil when two unordered property plists are semantically equal." + (or (equal old-properties new-properties) + (equal (ebox-buffer--canonical-property-alist old-properties) + (ebox-buffer--canonical-property-alist new-properties)))) + +(defun ebox-buffer--myers-trace-value (trace distance diagonal) + "Return TRACE's furthest position for DISTANCE and DIAGONAL." + (if (or (< diagonal (- distance)) (> diagonal distance)) + -1 + (aref trace (+ diagonal distance)))) + +(defvar ebox-buffer--myers-maximum-edit-distance 128 + "Maximum exact edit distance used by root property diffing.") + +(defvar ebox-buffer--myers-maximum-trace-cells 20000 + "Maximum retained Myers frontier cells for one root property diff.") + +(defvar ebox-buffer--myers-maximum-work 250000 + "Maximum frontier and character-comparison work for one Myers diff.") + +(defconst ebox-buffer--myers-budget-exceeded :budget-exceeded + "Sentinel returned when exact root alignment exceeds its fixed budget.") + +(defvar ebox-buffer--last-myers-stats nil + "Diagnostic facts from the most recent bounded Myers alignment.") + +(defvar ebox-buffer--last-property-patch-plan nil + "Diagnostic facts from the most recent root property patch plan.") + +(defun ebox-buffer--myers-text-matches (old new) + "Return ordered equal runs from a bounded shortest-edit OLD/NEW alignment. +Each result is (OLD-START NEW-START LENGTH). Text properties are checked +after textual alignment so large property-only geometry changes do not +inflate the edit-search frontier. Return +`ebox-buffer--myers-budget-exceeded' when the deterministic edit, trace, or +work budget is exhausted." + (let* ((old-length (length old)) + (new-length (length new)) + (maximum (+ old-length new-length)) + (distance-limit + (min maximum (max 0 ebox-buffer--myers-maximum-edit-distance))) + (traces (make-vector (1+ distance-limit) nil)) + (work 0) + (trace-cells 0) + final-distance stop-reason) + (cl-labels + ((consume-work + () + (if (>= work (max 1 ebox-buffer--myers-maximum-work)) + (progn + (setq stop-reason 'work-budget) + (throw 'stop nil)) + (cl-incf work)))) + (catch 'stop + (cl-loop + for distance from 0 to distance-limit + do + (let ((current-cells (1+ (* 2 distance)))) + (when (> (+ trace-cells current-cells) + (max 1 ebox-buffer--myers-maximum-trace-cells)) + (setq stop-reason 'trace-budget) + (throw 'stop nil)) + (cl-incf trace-cells current-cells) + (let* ((previous (and (> distance 0) + (aref traces (1- distance)))) + (current (make-vector current-cells -1)) + complete-p) + (cl-loop + for diagonal from (- distance) to distance by 2 + until complete-p + do + (consume-work) + (let* ((down + (and previous + (ebox-buffer--myers-trace-value + previous (1- distance) (1+ diagonal)))) + (right + (and previous + (ebox-buffer--myers-trace-value + previous (1- distance) (1- diagonal)))) + (old-position + (cond + ((zerop distance) 0) + ((= diagonal (- distance)) down) + ((= diagonal distance) (1+ right)) + ((< right down) down) + (t (1+ right)))) + (new-position (- old-position diagonal))) + (while (and (< old-position old-length) + (< new-position new-length) + (= (aref old old-position) + (aref new new-position))) + (consume-work) + (cl-incf old-position) + (cl-incf new-position)) + (aset current (+ diagonal distance) old-position) + (when (and (>= old-position old-length) + (>= new-position new-length)) + (setq complete-p t)))) + (aset traces distance current) + (when complete-p + (setq final-distance distance + stop-reason 'complete) + (throw 'stop t))))) + (setq stop-reason + (if (< distance-limit maximum) + 'edit-distance-budget + 'incomplete)))) + (setq ebox-buffer--last-myers-stats + (list :status stop-reason + :work work + :trace-cells trace-cells + :distance final-distance + :maximum-distance maximum)) + (if (null final-distance) + ebox-buffer--myers-budget-exceeded + (let ((old-position old-length) + (new-position new-length) + matches) + (cl-loop + for distance from final-distance downto 1 + do + (let* ((previous-distance (1- distance)) + (previous (aref traces previous-distance)) + (diagonal (- old-position new-position)) + (previous-diagonal + (if (or (= diagonal (- distance)) + (and (/= diagonal distance) + (< (ebox-buffer--myers-trace-value + previous previous-distance + (1- diagonal)) + (ebox-buffer--myers-trace-value + previous previous-distance + (1+ diagonal))))) + (1+ diagonal) + (1- diagonal))) + (previous-old + (ebox-buffer--myers-trace-value + previous previous-distance previous-diagonal)) + (previous-new (- previous-old previous-diagonal))) + (when (and (> old-position previous-old) + (> new-position previous-new)) + (let ((length + (min (- old-position previous-old) + (- new-position previous-new)))) + (push (list (- old-position length) + (- new-position length) + length) + matches))) + (setq old-position previous-old + new-position previous-new))) + (when (and (> old-position 0) (> new-position 0)) + (let ((length (min old-position new-position))) + (push (list (- old-position length) + (- new-position length) + length) + matches))) + matches)))) + +(defun ebox-buffer--common-text-edges (old new) + "Return non-overlapping common prefix and suffix lengths for OLD and NEW." + (let* ((old-length (length old)) + (new-length (length new)) + (shared-limit (min old-length new-length)) + (prefix 0) + (suffix 0)) + (while (and (< prefix shared-limit) + (= (aref old prefix) (aref new prefix))) + (cl-incf prefix)) + (while (and (< suffix (- old-length prefix)) + (< suffix (- new-length prefix)) + (= (aref old (- old-length suffix 1)) + (aref new (- new-length suffix 1)))) + (cl-incf suffix)) + (cons prefix suffix))) + +(defun ebox-buffer--push-property-patch + (patches old-start old-end new-start new-end) + "Add one changed range to reverse-order PATCHES, coalescing adjacency." + (if (and (= old-start old-end) (= new-start new-end)) + patches + (let ((previous (car patches))) + (if (and previous + (= (plist-get previous :old-end) old-start) + (= (plist-get previous :new-end) new-start)) + (progn + (plist-put previous :old-end old-end) + (plist-put previous :new-end new-end) + patches) + (cons (list :old-start old-start :old-end old-end + :new-start new-start :new-end new-end) + patches))))) + +(defun ebox-buffer--property-interval-index (string) + "Return a position index over STRING's compact property intervals." + (let* ((length (length string)) + (index (make-vector length (list 0 length nil)))) + (dolist (interval (object-intervals string)) + (let ((position (nth 0 interval)) + (end (nth 1 interval))) + (while (< position end) + (aset index position interval) + (cl-incf position)))) + index)) + +(defun ebox-buffer--property-run-patches + (old-index new-index old-start new-start length patches) + "Add property changes inside one aligned text run to PATCHES. +OLD-START and NEW-START begin an equal-character run of LENGTH. Properties +are compared once per property interval instead of once per character." + (let ((old-position old-start) + (new-position new-start) + (old-limit (+ old-start length)) + (new-limit (+ new-start length))) + (while (< old-position old-limit) + (let* ((old-interval (aref old-index old-position)) + (new-interval (aref new-index new-position)) + (old-change (min old-limit (nth 1 old-interval))) + (new-change (min new-limit (nth 1 new-interval))) + (segment-length + (min (- old-change old-position) + (- new-change new-position)))) + (unless (ebox-buffer--property-maps-equal-p + (nth 2 old-interval) + (nth 2 new-interval)) + (setq patches + (ebox-buffer--push-property-patch + patches + old-position (+ old-position segment-length) + new-position (+ new-position segment-length)))) + (setq old-position (+ old-position segment-length) + new-position (+ new-position segment-length)))) + patches)) + +(defun ebox-buffer--aligned-property-patches + (old new old-index new-index matches) + "Return property-aware patches for OLD, NEW, and textual MATCHES." + (let ((old-position 0) + (new-position 0) + patches) + (dolist (match matches) + (let ((matched-old (nth 0 match)) + (matched-new (nth 1 match)) + (run-length (nth 2 match))) + (when (or (< old-position matched-old) + (< new-position matched-new)) + (setq patches + (ebox-buffer--push-property-patch + patches old-position matched-old + new-position matched-new))) + (setq patches + (ebox-buffer--property-run-patches + old-index new-index + matched-old matched-new run-length patches)) + (setq old-position (+ matched-old run-length) + new-position (+ matched-new run-length)))) + (setq patches + (ebox-buffer--push-property-patch + patches old-position (length old) + new-position (length new))) + (nreverse patches))) + +(defun ebox-buffer--minimal-property-patches (old new) + "Return bounded property-aware replacement patches from OLD to NEW. +Text-equal strings produce one patch per disjoint property change. Text +changes retain exact common edges and use a shortest edit alignment while it +fits a deterministic budget. When the middle exceeds that budget, one +correct middle replacement is used instead of blocking Emacs on an unbounded +quadratic trace. Patch coordinates are zero-based half-open offsets in OLD +and NEW." + (setq ebox-buffer--last-myers-stats nil) + (let ((old-length (length old))) + (if (equal old new) + (let ((old-index (ebox-buffer--property-interval-index old)) + (new-index (ebox-buffer--property-interval-index new))) + (setq ebox-buffer--last-property-patch-plan + (list :strategy 'property-only + :work 0 :trace-cells 0)) + (nreverse + (ebox-buffer--property-run-patches + old-index new-index 0 0 old-length nil))) + (let* ((new-length (length new)) + (edges (ebox-buffer--common-text-edges old new)) + (prefix (car edges)) + (suffix (cdr edges)) + (old-middle-end (- old-length suffix)) + (new-middle-end (- new-length suffix)) + (old-middle-length (- old-middle-end prefix)) + (new-middle-length (- new-middle-end prefix)) + (middle-matches + (if (or (zerop old-middle-length) + (zerop new-middle-length)) + nil + (ebox-buffer--myers-text-matches + (substring old prefix old-middle-end) + (substring new prefix new-middle-end)))) + (fallback-p + (eq middle-matches ebox-buffer--myers-budget-exceeded)) + (matches + (append + (when (> prefix 0) + (list (list 0 0 prefix))) + (unless fallback-p + (mapcar + (lambda (match) + (list (+ prefix (nth 0 match)) + (+ prefix (nth 1 match)) + (nth 2 match))) + middle-matches)) + (when (> suffix 0) + (list (list (- old-length suffix) + (- new-length suffix) + suffix))))) + (stats ebox-buffer--last-myers-stats) + (strategy + (cond + (fallback-p 'bounded-fallback) + ((or (zerop old-middle-length) + (zerop new-middle-length)) + 'exact-edges) + (t 'exact-myers))) + (old-index + (and matches (ebox-buffer--property-interval-index old))) + (new-index + (and matches (ebox-buffer--property-interval-index new)))) + (setq ebox-buffer--last-property-patch-plan + (list :strategy strategy + :common-prefix prefix + :common-suffix suffix + :myers-status (plist-get stats :status) + :work (or (plist-get stats :work) 0) + :trace-cells (or (plist-get stats :trace-cells) 0) + :distance (plist-get stats :distance))) + (ebox-buffer--aligned-property-patches + old new old-index new-index matches))))) + +(defun ebox-buffer--prepare-root-property-patches (spans rendered) + "Prepare a property-aware root replacement for SPANS and RENDERED. +This function reads the current buffer but does not modify it." + (when (and spans (ebox--line-spans-contiguous-p spans)) + (let* ((start (caar spans)) + (end (cdar (last spans))) + (old (buffer-substring start end)) + (patches (ebox-buffer--minimal-property-patches old rendered)) + (diff-plan ebox-buffer--last-property-patch-plan)) + (list :start start + :end end + :base old + :rendered rendered + :patches patches + :diff-strategy (plist-get diff-plan :strategy) + :diff-work (plist-get diff-plan :work) + :diff-trace-cells (plist-get diff-plan :trace-cells) + :full-root-required-p + (and (= (length patches) 1) + (let ((patch (car patches))) + (and (zerop (plist-get patch :old-start)) + (= (plist-get patch :old-end) (length old)) + (zerop (plist-get patch :new-start)) + (= (plist-get patch :new-end) + (length rendered))))))))) + +(defun ebox-buffer--publish-prepared-root-property-patches (prepared) + "Publish PREPARED root property patches and return publication metadata." + (let* ((start (plist-get prepared :start)) + (end (plist-get prepared :end)) + (base (plist-get prepared :base)) + (rendered (plist-get prepared :rendered)) + (patches (plist-get prepared :patches))) + (when (and (integer-or-marker-p start) + (integer-or-marker-p end) + (stringp base) + (stringp rendered) + (= (- end start) (length base)) + (equal (buffer-substring start end) base)) + (ebox--with-preserved-buffer-window-state (current-buffer) + (let ((inhibit-read-only t) + (root-start-marker (copy-marker start nil)) + (root-end-marker (copy-marker end t)) + published-markers) + (unwind-protect + (progn + (atomic-change-group + (dolist (patch (reverse patches)) + (let* ((old-start (+ start (plist-get patch :old-start))) + (old-end (+ start (plist-get patch :old-end))) + (new-start (plist-get patch :new-start)) + (new-end (plist-get patch :new-end)) + (published-start (copy-marker old-start nil))) + (goto-char old-start) + (delete-region old-start old-end) + (insert (substring rendered new-start new-end)) + (push (cons published-start (copy-marker (point) t)) + published-markers)))) + (list + :new-buffer-spans + (ebox--buffer-line-spans-between + (marker-position root-start-marker) + (marker-position root-end-marker)) + :published-buffer-spans + (mapcar (lambda (span) + (cons (marker-position (car span)) + (marker-position (cdr span)))) + published-markers) + :patches patches + :diff-strategy (plist-get prepared :diff-strategy) + :diff-work (plist-get prepared :diff-work) + :diff-trace-cells (plist-get prepared :diff-trace-cells) + :full-root-required-p + (plist-get prepared :full-root-required-p))) + (set-marker root-start-marker nil) + (set-marker root-end-marker nil) + (dolist (span published-markers) + (set-marker (car span) nil) + (set-marker (cdr span) nil)))))))) + +(defun ebox-buffer--replace-root-with-property-patches (spans rendered) + "Replace root SPANS with RENDERED through bounded property-aware writes. +Return the resulting root line spans, the actually published buffer spans, +and whether correctness required replacing the complete root." + (when-let ((prepared + (ebox-buffer--prepare-root-property-patches spans rendered))) + (ebox-buffer--publish-prepared-root-property-patches prepared))) + +(defun ebox-buffer-publish-prepared-root (buffer rendered) + "Publish prepared root string RENDERED into BUFFER. + +This function owns only the property-aware buffer write. Declarative tree +validation, candidate rendering, and runtime state publication belong to the +incremental transaction layer. The caller may wrap this function in a wider +`atomic-change-group' that includes its own publication work." + (unless (buffer-live-p buffer) + (error "Ebox cannot publish into dead buffer %S" buffer)) + (unless (stringp rendered) + (error "Ebox prepared root must be a string")) + (with-current-buffer buffer + (let ((spans (ebox--buffer-line-spans-between (point-min) (point-max)))) + (or (ebox-buffer--replace-root-with-property-patches spans rendered) + (error "Ebox could not publish the prepared root"))))) + +(defun ebox-buffer--role-delta-region-ids (role-delta) + "Return region ids affected by ROLE-DELTA." + (delete-dups + (delq nil + (mapcar + (lambda (entry) + (car-safe (plist-get entry :key))) + (plist-get role-delta :entries))))) + +(defun ebox-buffer--preflight-native-root-property-patches + (spans native-patch) + "Return a validated text publication plan for Rust NATIVE-PATCH. +The plan validates current root SPANS, source/target counts, patch ordering, +replacement payloads, and complete prepared root metadata. It translates the +prepared role table and preflights extent and scroll target ranges without +publishing text, role tables, extent templates, scroll state, or markers." + (when (and spans + (ebox--line-spans-contiguous-p spans) + (plist-get native-patch :native-patch) + (eq (plist-get native-patch :patch-origin) 'rust)) + (let* ((start (caar spans)) + (end (cdar (last spans))) + (base-character-count + (plist-get native-patch :base-character-count)) + (target-character-count + (plist-get native-patch :target-character-count)) + (target-line-count (plist-get native-patch :target-line-count)) + (patches (plist-get native-patch :patches)) + (root-render-metadata + (plist-get native-patch :root-render-metadata)) + (prepared-metadata-p + (plist-get root-render-metadata :prepared-p)) + (role-template + (and prepared-metadata-p + (plist-get root-render-metadata :role-span-template))) + (extent-template + (and prepared-metadata-p + (plist-get root-render-metadata :box-extent-template))) + (scroll-template + (and prepared-metadata-p + (plist-get root-render-metadata + :scroll-content-span-template))) + (prepared-role-table + (and (hash-table-p role-template) + (ebox-buffer--region-role-span-table-from-template + role-template start))) + prepared-extent-table + scroll-preflight + role-delta + (previous-old-end 0) + (previous-new-end 0)) + (unless (and (integerp base-character-count) + (= base-character-count (- end start)) + (integerp target-character-count) + (>= target-character-count 0) + (integerp target-line-count) + (> target-line-count 0) + (listp patches)) + (error "Native root patch does not match the current buffer")) + (dolist (patch patches) + (let ((old-start (plist-get patch :old-start)) + (old-end (plist-get patch :old-end)) + (new-start (plist-get patch :new-start)) + (new-end (plist-get patch :new-end)) + (replacement (plist-get patch :replacement))) + (unless (and (integerp old-start) (integerp old-end) + (integerp new-start) (integerp new-end) + (<= previous-old-end old-start old-end + base-character-count) + (<= previous-new-end new-start new-end + target-character-count) + (= (- old-start previous-old-end) + (- new-start previous-new-end)) + (stringp replacement) + (= (length replacement) (- new-end new-start))) + (error "Native root patch contains invalid ranges")) + (setq previous-old-end old-end + previous-new-end new-end))) + (unless (= (- base-character-count previous-old-end) + (- target-character-count previous-new-end)) + (error "Native root patch has an inconsistent target length")) + (when (and prepared-metadata-p + (not (and (hash-table-p role-template) + (hash-table-p extent-template) + (hash-table-p scroll-template)))) + (error "Native root patch lacks prepared target indexes")) + (when prepared-role-table + (ebox-buffer--validate-role-span-template + prepared-role-table start target-character-count)) + (when (hash-table-p extent-template) + (setq prepared-extent-table + (ebox-buffer--validate-box-extent-template + extent-template start target-character-count))) + (when (hash-table-p scroll-template) + (setq scroll-preflight + (ebox--preflight-scroll-content-span-template + (current-buffer) scroll-template start target-character-count))) + (unless prepared-role-table + (setq role-delta + (ebox-buffer--capture-role-delta start patches))) + (list + :start start + :end end + :base-character-count base-character-count + :target-character-count target-character-count + :target-line-count target-line-count + :patches patches + :patch-plan + (mapcar + (lambda (patch) + (cons patch + (ebox-buffer--in-place-property-patch-p start patch))) + patches) + :root-render-metadata root-render-metadata + :prepared-role-table prepared-role-table + :prepared-extent-table prepared-extent-table + :role-delta role-delta + :scroll-preflight scroll-preflight + :replacement-bytes + (or (plist-get native-patch :replacement-bytes) + (cl-loop for patch in patches + sum (string-bytes + (plist-get patch :replacement)))))))) + +(defun ebox-buffer--publish-native-root-property-patch-text (plan) + "Apply validated native root text PLAN and return publication metadata. +The caller owns the surrounding `atomic-change-group' and all runtime index +publication. This function mutates only buffer text and text properties." + (let* ((start (plist-get plan :start)) + (end (plist-get plan :end)) + (target-character-count + (plist-get plan :target-character-count)) + (target-line-count (plist-get plan :target-line-count)) + (patches (plist-get plan :patches)) + (root-start-marker (copy-marker start nil)) + (root-end-marker (copy-marker end t)) + published-spans) + (unwind-protect + (progn + (dolist (planned (reverse (plist-get plan :patch-plan))) + (let* ((patch (car planned)) + (in-place-p (cdr planned)) + (old-start (+ start (plist-get patch :old-start))) + (old-end (+ start (plist-get patch :old-end))) + (replacement (plist-get patch :replacement))) + (if in-place-p + (ebox-buffer--apply-string-properties-in-place + old-start replacement) + (goto-char old-start) + (delete-region old-start old-end) + (insert replacement)) + ;; Rust already supplied validated target coordinates. Recording + ;; them directly avoids allocating and moving two temporary + ;; markers for every minimal property span. + (push (cons (+ start (plist-get patch :new-start)) + (+ start (plist-get patch :new-end))) + published-spans))) + (unless (= (- (marker-position root-end-marker) + (marker-position root-start-marker)) + target-character-count) + (error "Native root patch published the wrong target length")) + (let ((new-spans + (ebox--buffer-line-spans-between + (marker-position root-start-marker) + (marker-position root-end-marker)))) + (unless (= (length new-spans) target-line-count) + (error "Native root patch published the wrong line count")) + (let* ((role-delta (plist-get plan :role-delta)) + (scroll-preflight (plist-get plan :scroll-preflight)) + (scroll-region-ids + (plist-get scroll-preflight :scroll-region-ids))) + (list + :new-buffer-spans new-spans + :published-buffer-spans published-spans + :patches patches + :role-delta role-delta + :affected-region-ids + (or scroll-region-ids + (ebox-buffer--role-delta-region-ids role-delta)) + :patch-origin 'rust + :full-frame-bytes 0 + :replacement-bytes + (plist-get plan :replacement-bytes) + :full-root-required-p + (and (= (length patches) 1) + (let ((patch (car patches))) + (and (zerop (plist-get patch :old-start)) + (= (plist-get patch :old-end) + (plist-get plan :base-character-count)) + (zerop (plist-get patch :new-start)) + (= (plist-get patch :new-end) + target-character-count)))))))) + (set-marker root-start-marker nil) + (set-marker root-end-marker nil)))) + +(defun ebox-buffer--apply-root-property-patches (spans native-patch) + "Apply Rust-origin NATIVE-PATCH directly inside contiguous root SPANS. +No complete old or target string is allocated. The native tape parser has +already validated semantic patch ordering; this compatibility boundary repeats +the buffer-relative length and replacement checks before one atomic write." + (when-let ((plan + (ebox-buffer--preflight-native-root-property-patches + spans native-patch))) + (ebox--with-preserved-buffer-window-state (current-buffer) + (let ((inhibit-read-only t)) + (atomic-change-group + (ebox-buffer--publish-native-root-property-patch-text plan)))))) + +(defun ebox--line-with-preserved-wrapper-owner + (span line rendered-owner-set) + "Return LINE preserving SPAN's outer wrapper ownership and paint." + (let ((owners (or (get-text-property (car span) 'ebox-content-owners) + (when-let ((owner (get-text-property + (car span) + 'ebox-content-owner))) + (list owner))))) + (if owners + (ebox--string-with-preserved-wrapper-context + line owners rendered-owner-set span) + line))) + +(defun ebox--paint-line-spans (spans rendered) + "Copy RENDERED paint over SPANS while retaining wrapper context." + (let ((lines (ebox-string-lines rendered)) + (owner-set (ebox--string-content-owner-set rendered))) + (when (and (= (length spans) (length lines)) + (cl-loop for span in spans + for line in lines + always + (and (= (- (cdr span) (car span)) (length line)) + (string= + (buffer-substring-no-properties + (car span) (cdr span)) + (substring-no-properties line))))) + (let ((inhibit-read-only t)) + (cl-loop for span in spans + for line in lines + do (ebox--copy-string-text-properties-to-buffer + (ebox--line-with-preserved-wrapper-owner + span line owner-set) + (car span)))) + t))) + +(defun ebox-buffer--external-extent-boundaries (span) + "Return live extent markers immediately outside SPAN. +The car contains left-side end markers; the cdr contains right-side start +markers. These markers must stay outside a replacement inserted at SPAN." + (let ((buffer (current-buffer)) + (start (car span)) + (end (cdr span)) + (region-ids + (delete-dups + (append + (and (> (car span) (point-min)) + (ebox-buffer--region-ids-at (1- (car span)))) + (and (< (cdr span) (point-max)) + (ebox-buffer--region-ids-at (cdr span)))))) + left right) + (dolist (region-id region-ids) + (when-let ((extents (gethash region-id ebox--box-extents))) + (let ((extent-start (car extents)) + (extent-end (cdr extents))) + (when (and (eq (marker-buffer extent-start) buffer) + (eq (marker-buffer extent-end) buffer)) + (cond + ((and (< (marker-position extent-start) start) + (= (marker-position extent-end) start)) + (push extent-end left)) + ((and (= (marker-position extent-start) end) + (> (marker-position extent-end) end)) + (push extent-start right))))))) + (cons left right))) + +(defun ebox--replace-line-spans + (spans rendered &optional rendered-owner-set skip-wrapper-context) + "Replace SPANS with RENDERED lines and return new spans on success. +RENDERED-OWNER-SET avoids rescanning an already inspected replacement. +When SKIP-WRAPPER-CONTEXT is non-nil, the replacement is the complete runtime +root and no ownership may exist outside it." + (if (and skip-wrapper-context + (ebox--line-spans-contiguous-p spans)) + (ebox--replace-contiguous-line-spans spans rendered t) + (ebox--with-preserved-buffer-window-state (current-buffer) + (let ((lines (ebox-string-lines rendered)) + (rendered-owner-set + (or rendered-owner-set + (ebox--string-content-owner-set rendered)))) + (if (= (length spans) (length lines)) + (let (new-span-markers) + (let ((inhibit-read-only t) + (point-marker (copy-marker (point) t))) + (save-excursion + (cl-loop for span in (reverse spans) + for line in (reverse lines) + do (progn + (let* ((start (car span)) + (extent-boundaries + (ebox-buffer--external-extent-boundaries + span))) + (goto-char start) + (setq line + (ebox--line-with-preserved-wrapper-owner + span line rendered-owner-set)) + (delete-region start (cdr span)) + (insert line) + (dolist (marker (car extent-boundaries)) + (set-marker marker start)) + (dolist (marker (cdr extent-boundaries)) + (set-marker marker (point))) + (push (cons (copy-marker start nil) + (copy-marker (point) t)) + new-span-markers))))) + (goto-char (min (marker-position point-marker) (point-max))) + (set-marker point-marker nil)) + (prog1 + (mapcar (lambda (span) + (cons (marker-position (car span)) + (marker-position (cdr span)))) + new-span-markers) + (dolist (span new-span-markers) + (set-marker (car span) nil) + (set-marker (cdr span) nil)))) + (ebox--replace-contiguous-line-spans spans rendered)))))) + +(defun ebox-buffer--span-slot-width (span) + "Return SPAN's visual slot width in its current buffer line." + (save-excursion + (goto-char (car span)) + (let ((line-start (line-beginning-position))) + (- (ebox--string-pixel-width + (buffer-substring line-start (cdr span))) + (ebox--string-pixel-width + (buffer-substring line-start (car span))))))) + +(defun ebox-buffer--partial-line-slots-p (spans) + "Return non-nil when SPANS are parent-owned partial line slots." + (cl-some (lambda (span) + (save-excursion + (goto-char (car span)) + (not (and (= (car span) (line-beginning-position)) + (= (cdr span) (line-end-position)))))) + spans)) + +(defun ebox-buffer--pad-line-to-slot-width (line width) + "Return LINE padded to visual slot WIDTH, or nil if LINE is wider." + (let* ((line-width (ebox--string-pixel-width line)) + (extra (- width line-width))) + (when (>= extra 0) + (concat line (ebox-pixel-space extra))))) + +(defun ebox-buffer--slots-require-wrapper-side-border-p (spans owner-set) + "Return non-nil when SPANS contain side borders absent from OWNER-SET." + (cl-some + (lambda (span) + (cl-some + (lambda (property) + (let ((position (car span)) + (limit (cdr span)) + owner) + (while (and (< position limit) + (progn + (setq owner (get-text-property position property)) + (or (not owner) + (and owner-set (gethash owner owner-set))))) + (setq position + (or (next-single-property-change + position property nil limit) + limit))) + (and owner (< position limit)))) + '(ebox-bl ebox-br))) + spans)) + +(defun ebox-buffer--rendered-in-existing-slots + (spans rendered &optional owner-set require-exact-width-p) + "Return RENDERED padded to fit SPANS' safe old parent slots. +Return nil when RENDERED needs more lines or width than the old slots provide. +When REQUIRE-EXACT-WIDTH-P is non-nil, decline padding that would hide a +parent layout change." + (let* ((lines (ebox-string-lines rendered)) + (slot-count (length spans)) + (slot-widths (mapcar #'ebox-buffer--span-slot-width spans)) + (line-widths (mapcar #'ebox--string-pixel-width lines))) + (when (and (or (null owner-set) + (not + (ebox-buffer--slots-require-wrapper-side-border-p + spans owner-set))) + (ebox-buffer--partial-line-slots-p spans) + (<= (length lines) slot-count) + (or (not require-exact-width-p) + (cl-loop for width in line-widths + for slot-width in slot-widths + always (= width slot-width)))) + (let (padded ok) + (setq ok t) + (cl-loop for width in slot-widths + for line = (or (pop lines) (ebox-pixel-space 0)) + do (if-let ((padded-line + (ebox-buffer--pad-line-to-slot-width + line width))) + (push padded-line padded) + (setq ok nil))) + (when ok + (ebox-lines-join (nreverse padded))))))) + +(defun ebox-buffer--inline-slot-span-patch-op-p (op) + "Return non-nil when OP may use old slot padding as an L2 span patch." + (let ((changed-keys (plist-get (plist-get op :dirty) :changed-keys))) + (and changed-keys + (cl-every (lambda (key) + (memq key '(:margin-left-pixel :margin-right-pixel))) + changed-keys)))) + +(defun ebox--execute-paint-patch (buffer op) + "Execute paint patch OP in BUFFER and return patch metadata." + (let* ((owner-id (plist-get op :owner-id)) + (node (ebox--buffer-runtime-node buffer owner-id))) + (when node + (or (ebox--execute-text-property-paint-patch buffer op node) + (let* ((dirty (plist-get op :dirty)) + (old (or (ebox--ensure-layout-snapshot-spans + buffer owner-id) + (plist-get dirty :old))) + (spans (plist-get old :buffer-spans))) + (and spans + (ebox--with-buffer-render-context buffer + (let* ((ebox--defer-scroll-content-index t) + (rendered (ebox-render node))) + (when (with-current-buffer buffer + (ebox--paint-line-spans spans rendered)) + (list :owner-id owner-id + :op 'paint-patch + :owner-type (plist-get node :ebox-type) + :span-count (length spans))))))))))) + +(defun ebox-buffer--span-line-range (spans) + "Return inclusive line range covered by SPANS in the current buffer." + (when spans + (let (lines) + (dolist (span spans) + (save-excursion + (goto-char (car span)) + (push (line-number-at-pos (car span) t) lines))) + (cons (apply #'min lines) (apply #'max lines))))) + +(defun ebox-buffer--line-range-overlap-p (left right) + "Return non-nil when inclusive line ranges LEFT and RIGHT overlap." + (and left right + (<= (car left) (cdr right)) + (<= (car right) (cdr left)))) + +(defun ebox-buffer--line-span-at-number (line-number) + "Return whole-line span at LINE-NUMBER in the current buffer." + (save-excursion + (goto-char (point-min)) + (forward-line (1- line-number)) + (cons (line-beginning-position) (line-end-position)))) + +(defun ebox-buffer--position-after-pixel-width (start pixel-width) + "Return position after PIXEL-WIDTH pixels from START on the same line." + (save-excursion + (goto-char start) + (let ((end (line-end-position)) + (pos start) + (width 0) + found) + (while (and (< pos end) (not found)) + (let* ((next (1+ pos)) + (char-width (ebox--substring-pixel-width + (buffer-substring pos next) 0 1))) + (setq width (+ width char-width)) + (setq pos next) + (when (= width pixel-width) + (setq found pos)))) + found))) + +(defun ebox-buffer--foreign-content-owner-in-range-p + (start end owned-region-ids) + "Return non-nil when START..END crosses content outside OWNED-REGION-IDS." + (let ((owned (ebox--region-id-set owned-region-ids)) + (position start) + foreign) + (while (and (< position end) (not foreign)) + (let ((region-id (get-text-property position 'ebox-content))) + (when (and region-id (not (gethash region-id owned))) + (setq foreign t))) + (setq position + (or (next-single-property-change + position 'ebox-content nil end) + end))) + foreign)) + +(defun ebox-buffer--contained-flex-fixed-slot-size (node) + "Return NODE's fixed outer `(WIDTH . HEIGHT)' slot size, or nil." + (when-let ((style-node (ebox-fragment-style-source-node node))) + (let ((width (ebox--flex-box-main-constraint style-node 'row :width)) + (height + (ebox--flex-box-main-constraint style-node 'column :height))) + (when (and (integerp width) (> width 0) + (integerp height) (> height 0) + (zerop (ebox-get style-node :margin-left-pixel)) + (zerop (ebox-get style-node :margin-right-pixel)) + (zerop (ebox-get style-node :margin-top-height)) + (zerop (ebox-get style-node :margin-bottom-height))) + (cons width height))))) + +(defun ebox-buffer--contained-flex-publication-spans + (buffer node-id node spans &optional replaced-region-ids) + "Return fixed-slot publication spans for contained partial flex NODE-ID." + (when-let* (((ebox--contained-partial-flex-owner-p buffer node-id)) + ((ebox--definite-containment-formatting-context-p + buffer node-id)) + (size (ebox-buffer--contained-flex-fixed-slot-size node)) + ((= (length spans) (cdr size))) + (owned-region-ids + (append (ebox--node-all-region-ids node) + replaced-region-ids))) + (catch 'unsafe + (let ((expected-line + (line-number-at-pos (caar spans) t)) + expanded) + (dolist (span spans (nreverse expanded)) + (unless (= (line-number-at-pos (car span) t) expected-line) + (throw 'unsafe nil)) + (let ((end (ebox-buffer--position-after-pixel-width + (car span) (car size)))) + (unless (and end (<= (cdr span) end) + (not + (ebox-buffer--foreign-content-owner-in-range-p + (car span) end owned-region-ids))) + (throw 'unsafe nil)) + (push (cons (car span) end) expanded)) + (setq expected-line (1+ expected-line))))))) + +(defun ebox-buffer--node-role-spans (node) + "Return role spans for NODE and descendants in the current buffer." + (when-let ((region-ids (ebox--node-all-region-ids node))) + (unless (ebox-buffer--region-role-span-table) + (ebox-buffer-refresh-region-role-spans (current-buffer))) + (when-let ((table (ebox-buffer--region-role-span-table))) + (let (spans) + (dolist (region-id region-ids) + (dolist (entry ebox-region-types) + (setq spans + (append (copy-sequence + (gethash + (ebox-buffer--region-role-key + region-id (car entry)) + table)) + spans)))) + spans)))) + +(defun ebox-buffer--spans-line-range (spans) + "Return inclusive line range covered by SPANS in the current buffer." + (let (start-line end-line) + (dolist (span spans) + (when (< (car span) (cdr span)) + (let ((first-line (line-number-at-pos (car span) t)) + (last-line (line-number-at-pos (1- (cdr span)) t))) + (setq start-line (if start-line + (min start-line first-line) + first-line)) + (setq end-line (if end-line + (max end-line last-line) + last-line))))) + (and start-line end-line (cons start-line end-line)))) + +(defun ebox-buffer--node-line-range (buffer node) + "Return NODE's current buffer line range without completing snapshots." + (with-current-buffer buffer + (ebox-buffer--spans-line-range + (ebox-buffer--node-role-spans node)))) + +(defun ebox-buffer--node-line-pixel-width (node line-span) + "Return NODE's covered pixel width on LINE-SPAN in the current buffer." + (let (start end) + (dolist (span (ebox-buffer--node-role-spans node)) + (when (and (< (car span) (cdr line-span)) + (> (cdr span) (car line-span))) + (setq start (if start + (min start (max (car span) (car line-span))) + (max (car span) (car line-span)))) + (setq end (if end + (max end (min (cdr span) (cdr line-span))) + (min (cdr span) (cdr line-span)))))) + (when (and start end (< start end)) + (ebox--substring-pixel-width + (buffer-substring start end) 0 (- end start))))) + +(defun ebox-buffer--child-line-item-main-footprint + (buffer child-ids line-number) + "Return summed child item width for CHILD-IDS on LINE-NUMBER." + (with-current-buffer buffer + (let ((line-span (ebox-buffer--line-span-at-number line-number)) + widths) + (dolist (child-id child-ids) + (when-let* ((node (ebox--buffer-runtime-node buffer child-id)) + (width (ebox-buffer--node-line-pixel-width + node line-span))) + (push width widths))) + (when (= (length widths) (length child-ids)) + (apply #'+ widths))))) + +(defun ebox-buffer--child-line-ranges (buffer flex-node) + "Return direct child line ranges for FLEX-NODE in BUFFER." + (let (ranges) + (dolist (child (plist-get flex-node :children)) + (let* ((source (ebox--flex-item-source-node child)) + (child-id (plist-get source :node-id)) + (range (and child-id + (ebox-buffer--node-line-range buffer source)))) + (when range + (push (cons child-id range) ranges)))) + (nreverse ranges))) + +(defun ebox-buffer--child-content-start-on-line (buffer child-ids line-number) + "Return minimum child span start for CHILD-IDS on LINE-NUMBER." + (let (starts) + (with-current-buffer buffer + (let ((line-span (ebox-buffer--line-span-at-number line-number))) + (dolist (child-id child-ids) + (when-let ((node (ebox--buffer-runtime-node buffer child-id))) + (dolist (span (ebox-buffer--node-role-spans node)) + (when (and (< (car span) (cdr line-span)) + (> (cdr span) (car line-span))) + (push (max (car span) (car line-span)) starts))))))) + (and starts (apply #'min starts)))) + +(defun ebox-buffer--rendered-flex-row-lines + (buffer line-spans start-line child-ids rendered-content main-size) + "Return full replacement lines for LINE-SPANS using RENDERED-CONTENT." + (catch 'failed + (let ((content-lines (ebox-string-lines rendered-content)) + full-lines) + (with-current-buffer buffer + (cl-loop for span in line-spans + for line-number from start-line + for content-line in content-lines + do (let* ((line-start (car span)) + (line-end (cdr span)) + (content-start + (ebox-buffer--child-content-start-on-line + buffer child-ids line-number)) + suffix-start prefix suffix) + (unless content-start + (throw 'failed nil)) + (setq suffix-start + (ebox-buffer--position-after-pixel-width + content-start main-size)) + (unless suffix-start + (setq suffix-start line-end)) + (setq prefix (buffer-substring line-start content-start) + suffix (buffer-substring suffix-start line-end)) + (push (concat prefix content-line suffix) full-lines)))) + (nreverse full-lines)))) + +(defun ebox--execute-flex-line-rerender (buffer op) + "Try to rerender only the affected row line for flex owner OP." + (let* ((owner-id (plist-get op :owner-id)) + (dirty (plist-get op :dirty)) + (dirty-node-ids (ebox--dirty-provenance-node-ids dirty)) + (dirty-node-id (and (= (length dirty-node-ids) 1) + (car dirty-node-ids))) + (node (ebox--buffer-runtime-node buffer owner-id)) + (child-id (and dirty-node-id + (ebox--runtime-child-under-ancestor + buffer owner-id dirty-node-id)))) + (when (and node child-id + (eq (plist-get node :ebox-type) 'flex)) + (let* ((child-ranges (ebox-buffer--child-line-ranges buffer node)) + (dirty-range (cdr (assq child-id child-ranges))) + (old-line-ids + (mapcar #'car + (cl-remove-if-not + (lambda (entry) + (ebox-buffer--line-range-overlap-p + dirty-range (cdr entry))) + child-ranges))) + (old-item-main + (and dirty-range old-line-ids + (ebox-buffer--child-line-item-main-footprint + buffer old-line-ids (car dirty-range)))) + (line-render + (ebox--with-buffer-render-context buffer + (or (ebox--flex-render-row-line-for-child-ids + node old-line-ids old-item-main) + (ebox--flex-render-row-line-for-child node child-id)))) + (new-line-ids (plist-get line-render :item-ids)) + (rendered (plist-get line-render :rendered)) + (rendered-owner-set + (and rendered + (ebox-buffer--replacement-owner-set rendered dirty))) + (main-size (plist-get line-render :main))) + (when (and dirty-range + line-render + rendered + main-size + (equal old-line-ids new-line-ids) + (= (ebox-string-height rendered) + (1+ (- (cdr dirty-range) (car dirty-range))))) + (with-current-buffer buffer + (let* ((line-spans + (cl-loop for line from (car dirty-range) + to (cdr dirty-range) + collect (ebox-buffer--line-span-at-number line))) + (replacement-lines + (ebox-buffer--rendered-flex-row-lines + buffer line-spans (car dirty-range) old-line-ids + rendered main-size))) + (when (and replacement-lines + (= (length replacement-lines) + (length line-spans))) + (when-let ((new-spans + (ebox--replace-line-spans + line-spans + (ebox-lines-join replacement-lines) + rendered-owner-set))) + (list :owner-id owner-id + :op 'owner-rerender + :owner-type (plist-get node :ebox-type) + :span-count (length line-spans) + :flex-line-rerender t + :old-buffer-spans line-spans + :new-buffer-spans new-spans)))))))))) + +(defun ebox-buffer--line-replacement-feasible-p (spans rendered) + "Return non-nil when RENDERED can replace line SPANS without mutation." + (let ((line-count (length (ebox-string-lines rendered)))) + (or (= (length spans) line-count) + (ebox--line-spans-contiguous-p spans)))) + +(defun ebox-buffer--paint-lines-compatible-p (spans rendered) + "Return non-nil when RENDERED may update only properties in SPANS." + (let ((lines (ebox-string-lines rendered))) + (and (= (length spans) (length lines)) + (cl-loop for span in spans + for line in lines + always + (and (= (- (cdr span) (car span)) (length line)) + (string= + (buffer-substring-no-properties + (car span) (cdr span)) + (substring-no-properties line))))))) + +(defun ebox-buffer-prepare-paint-patch (buffer op) + "Prepare paint OP for BUFFER without modifying buffer text or properties." + (let* ((owner-id (plist-get op :owner-id)) + (node (ebox--buffer-runtime-node buffer owner-id)) + (snapshot (and node + (ebox--ensure-layout-snapshot-spans + buffer owner-id))) + (spans (and snapshot (plist-get snapshot :buffer-spans)))) + (when (and node spans) + (ebox--with-buffer-render-context buffer + (let ((ebox--defer-scroll-content-index t) + (rendered (ebox-render node))) + (when (with-current-buffer buffer + (ebox-buffer--paint-lines-compatible-p spans rendered)) + (list :op 'paint-patch + :owner-id owner-id + :owner-type (plist-get node :ebox-type) + :spans spans + :rendered rendered))))))) + +(defun ebox-buffer-publish-prepared-paint-patch (buffer prepared) + "Publish PREPARED paint patch into BUFFER." + (with-current-buffer buffer + (let ((spans (plist-get prepared :spans)) + (rendered (plist-get prepared :rendered))) + (when (and (ebox-buffer--paint-lines-compatible-p spans rendered) + (ebox--paint-line-spans spans rendered)) + (list :owner-id (plist-get prepared :owner-id) + :op 'paint-patch + :owner-type (plist-get prepared :owner-type) + :span-count (length spans)))))) + +(defun ebox-buffer--child-reorder-middle (old-ids new-ids) + "Return the minimal changed middle ranges of OLD-IDS and NEW-IDS." + (let* ((old (vconcat old-ids)) + (new (vconcat new-ids)) + (start 0) + (old-end (length old)) + (new-end (length new))) + (while (and (< start old-end) + (< start new-end) + (equal (aref old start) (aref new start))) + (cl-incf start)) + (while (and (> old-end start) + (> new-end start) + (equal (aref old (1- old-end)) + (aref new (1- new-end)))) + (cl-decf old-end) + (cl-decf new-end)) + (list (append (cl-subseq old start old-end) nil) + (append (cl-subseq new start new-end) nil)))) + +(defun ebox-buffer--child-list-window (old-ids new-ids) + "Return changed child window for OLD-IDS and NEW-IDS." + (let* ((old (vconcat old-ids)) + (new (vconcat new-ids)) + (start 0) + (old-end (length old)) + (new-end (length new))) + (while (and (< start old-end) (< start new-end) + (equal (aref old start) (aref new start))) + (cl-incf start)) + (while (and (> old-end start) (> new-end start) + (equal (aref old (1- old-end)) + (aref new (1- new-end)))) + (cl-decf old-end) + (cl-decf new-end)) + (list :prefix-count start + :old-middle (append (cl-subseq old start old-end) nil) + :new-middle (append (cl-subseq new start new-end) nil)))) + +(defun ebox-buffer--child-reorder-span-groups (buffer child-ids) + "Return CHILD-IDS' published span groups in BUFFER." + (catch 'unsafe + (let ((groups (make-hash-table :test 'equal))) + (dolist (child-id child-ids) + (let* ((snapshot + (ebox--ensure-layout-snapshot-spans buffer child-id)) + (spans (and snapshot (plist-get snapshot :buffer-spans)))) + (unless spans + (throw 'unsafe nil)) + (puthash child-id (copy-tree spans) groups))) + groups))) + +(defun ebox-buffer--vertical-slot-spans-p (spans) + "Return non-nil when SPANS occupy one fixed slot on consecutive lines." + (let (first-start first-width previous-line valid) + (setq valid t) + (dolist (span spans valid) + (save-excursion + (goto-char (car span)) + (let* ((line (line-number-at-pos (car span) t)) + (line-start (line-beginning-position)) + (start (ebox--string-pixel-width + (buffer-substring line-start (car span)))) + (width (ebox-buffer--span-slot-width span))) + (if previous-line + (unless (and (= line (1+ previous-line)) + (= start first-start) + (= width first-width)) + (setq valid nil)) + (setq first-start start + first-width width)) + (setq previous-line line)))))) + +(defun ebox-buffer--child-reorder-rendered (segments spans dirty) + "Return SEGMENTS in SPANS with published wrapper context from DIRTY." + (let ((lines + (cl-loop for segment in segments + append + (mapcar (lambda (span) + (buffer-substring (car span) (cdr span))) + segment)))) + (ebox-lines-join + (cl-loop for line in lines + for span in spans + collect + (ebox--line-with-preserved-wrapper-owner + span line + (ebox-buffer--replacement-owner-set line dirty)))))) + +(defun ebox-buffer--child-reorder-segments-fit-p (segments spans) + "Return non-nil when SEGMENTS fit the published destination SPANS." + (let ((lines + (cl-loop for segment in segments + append + (mapcar (lambda (span) + (buffer-substring (car span) (cdr span))) + segment)))) + (and (= (length lines) (length spans)) + (cl-loop for line in lines + for span in spans + always + (= (ebox--string-pixel-width line) + (ebox-buffer--span-slot-width span)))))) + +(defun ebox-buffer--child-reorder-candidate-p + (owner snapshot dirty old-ids new-ids) + "Return non-nil when OWNER can reorder OLD-IDS into NEW-IDS." + (and owner snapshot old-ids new-ids + (plist-get dirty :pure-child-reorder-p) + (equal (plist-get dirty :changed-keys) '(:children)) + (equal (plist-get snapshot :child-ids) old-ids) + (equal (mapcar #'ebox--ensure-node-id + (ebox-tree-node-children owner)) + new-ids) + (eq (plist-get owner :ebox-type) 'stack) + (not (ebox-tree-node-visible-overflow-p owner)))) + +(defun ebox-buffer--child-reorder-publication + (buffer owner snapshot dirty old-middle new-middle groups) + "Return prepared BUFFER publication for OWNER's changed child middle." + (ebox--with-buffer-render-context buffer + (let* ((ebox--defer-scroll-content-index t) + (old-groups (mapcar (lambda (id) (gethash id groups)) old-middle)) + (spans (apply #'append (mapcar #'copy-sequence old-groups))) + (owner-spans (plist-get snapshot :buffer-spans)) + (segments (mapcar (lambda (id) (gethash id groups)) new-middle))) + (when (and spans + (cl-every #'identity segments) + (cl-every (lambda (span) (member span owner-spans)) spans) + (with-current-buffer buffer + (and (ebox-buffer--vertical-slot-spans-p spans) + (ebox-buffer--child-reorder-segments-fit-p + segments spans)))) + (list :op 'child-reorder + :owner-id (plist-get owner :node-id) + :owner-type (plist-get owner :ebox-type) + :dirty dirty + :spans spans + :segments segments))))) + +(defun ebox-buffer-prepare-child-reorder (buffer op) + "Prepare one vertical keyed child reorder OP for BUFFER." + (let* ((owner-id (plist-get op :owner-id)) + (dirty (plist-get op :dirty)) + (owner (ebox--buffer-runtime-node buffer owner-id)) + (snapshot (ebox--ensure-layout-snapshot-spans buffer owner-id)) + (old-ids (plist-get dirty :old-child-ids)) + (new-ids (plist-get dirty :new-child-ids)) + (middle (ebox-buffer--child-reorder-middle old-ids new-ids)) + (old-middle (car middle)) + (new-middle (cadr middle)) + (groups (ebox-buffer--child-reorder-span-groups buffer old-middle))) + (when (and groups old-middle new-middle + (ebox-buffer--child-reorder-candidate-p + owner snapshot dirty old-ids new-ids)) + (ebox-buffer--child-reorder-publication + buffer owner snapshot dirty old-middle new-middle groups)))) + +(defun ebox-buffer--child-splice-candidate-p + (owner snapshot dirty old-ids new-ids) + "Return non-nil when OWNER can splice OLD-IDS into NEW-IDS." + (and owner snapshot old-ids new-ids + (plist-get dirty :child-splice-p) + (equal (plist-get dirty :changed-keys) '(:children)) + (equal (plist-get snapshot :child-ids) old-ids) + (equal (mapcar #'ebox--ensure-node-id + (ebox-tree-node-children owner)) + new-ids) + (eq (plist-get owner :ebox-type) 'stack) + (eq (ebox-tree-display-inner owner) 'column) + (not (ebox-tree-node-visible-overflow-p owner)))) + +(defun ebox-buffer--child-by-id (owner child-id) + "Return OWNER's direct child with runtime CHILD-ID." + (cl-find-if + (lambda (child) + (equal (ebox--ensure-node-id child) child-id)) + (ebox-tree-node-children owner))) + +(defun ebox-buffer--child-splice-simple-new-children-p (owner child-ids) + "Return non-nil when OWNER's new CHILD-IDS are simple boxes." + (cl-every + (lambda (child-id) + (let ((child (ebox-buffer--child-by-id owner child-id))) + (and child (eq (plist-get child :ebox-type) 'box)))) + child-ids)) + +(defun ebox-buffer--child-splice-rendered (owner child-ids) + "Return rendered CHILD-IDS from OWNER as one vertical string." + (when child-ids + (ebox-lines-join + (cl-loop for child-id in child-ids + for child = (ebox-buffer--child-by-id owner child-id) + unless child + do (cl-return nil) + collect (ebox-render child))))) + +(defun ebox-buffer--child-splice-bounds (old-spans before-span after-span) + "Return deletion bounds around OLD-SPANS, BEFORE-SPAN, and AFTER-SPAN." + (cond + (old-spans + (let ((start (caar old-spans)) + (end (cdar (last old-spans)))) + (cond + ((and before-span (not after-span) (> start (point-min))) + (cons (1- start) end)) + (after-span + (cons start (min (point-max) (1+ end)))) + (t (cons start end))))) + (before-span + (cons (cdr before-span) (cdr before-span))) + (after-span + (cons (car after-span) (car after-span))))) + +(defun ebox-buffer--child-splice-insert-text + (rendered old-spans before-span after-span) + "Return insertion text for RENDERED at the splice boundary." + (cond + ((not rendered) "") + (old-spans + (if after-span (concat rendered "\n") rendered)) + (before-span (concat "\n" rendered)) + (after-span (concat rendered "\n")) + (t rendered))) + +(defun ebox-buffer--child-splice-context (owner dirty window groups) + "Return OWNER child splice publication context." + (let* ((old-ids (plist-get dirty :old-child-ids)) + (new-ids (plist-get dirty :new-child-ids)) + (old-middle (plist-get window :old-middle)) + (new-middle (plist-get window :new-middle)) + (prefix (plist-get window :prefix-count)) + (before-id (and (> prefix 0) (nth (1- prefix) old-ids))) + (after-id (nth prefix old-ids)) + (old-spans (apply #'append (mapcar (lambda (id) + (gethash id groups)) + old-middle))) + (before-span (car (last (gethash before-id groups)))) + (after-span (car (gethash after-id groups))) + (rendered (ebox-buffer--child-splice-rendered owner new-middle))) + (list :old-spans old-spans + :old-child-ids old-ids + :new-child-ids new-ids + :before-span before-span + :after-span after-span + :rendered rendered))) + +(defun ebox-buffer--child-splice-wrapper-span + (old-spans before-span after-span) + "Return a neighboring span that carries splice wrapper context." + (or (car old-spans) before-span after-span)) + +(defun ebox-buffer--child-splice-rendered-with-wrapper-context + (owner rendered old-child-ids new-child-ids old-spans before-span after-span) + "Return RENDERED with outer wrapper context restored." + (if-let* ((span + (ebox-buffer--child-splice-wrapper-span + old-spans before-span after-span)) + (owners (ebox--span-wrapper-owners span)) + (child-id-set + (ebox--region-id-set + (append old-child-ids new-child-ids))) + (splice-owner-id (plist-get owner :node-id)) + (context-owners + (cl-remove-if-not + (lambda (candidate-owner-id) + (and (not (equal candidate-owner-id splice-owner-id)) + (not (gethash candidate-owner-id child-id-set)))) + owners))) + (ebox--string-with-preserved-wrapper-context + rendered + context-owners + (ebox--string-content-owner-set rendered) + span) + rendered)) + +(defun ebox-buffer--child-splice-prepared (owner dirty context) + "Return prepared child splice plist for OWNER from CONTEXT." + (let* ((old-spans (plist-get context :old-spans)) + (raw-rendered (plist-get context :rendered)) + (old-child-ids (plist-get context :old-child-ids)) + (new-child-ids (plist-get context :new-child-ids)) + (before-span (plist-get context :before-span)) + (after-span (plist-get context :after-span)) + (rendered + (and raw-rendered + (ebox-buffer--child-splice-rendered-with-wrapper-context + owner raw-rendered old-child-ids new-child-ids + old-spans before-span after-span))) + (bounds (ebox-buffer--child-splice-bounds + old-spans before-span after-span)) + (insert (ebox-buffer--child-splice-insert-text + rendered old-spans before-span after-span))) + (when (and bounds (or old-spans rendered)) + (list :op 'child-splice + :owner-id (plist-get owner :node-id) + :owner-type (plist-get owner :ebox-type) + :dirty dirty + :spans old-spans + :bounds bounds + :insert insert + :rendered rendered + :old-refresh-spans (list bounds) + :new-refresh-spans + (list (cons (car bounds) + (+ (car bounds) (length insert)))))))) + +(defun ebox-buffer--child-splice-publication + (buffer owner dirty window groups) + "Return prepared BUFFER splice publication for OWNER child ranges." + (ebox--with-buffer-render-context buffer + (with-current-buffer buffer + (let* ((ebox--defer-scroll-content-index t) + (old-middle (plist-get window :old-middle)) + (context + (ebox-buffer--child-splice-context + owner dirty window groups))) + (when (cl-every #'identity (mapcar (lambda (id) + (gethash id groups)) + old-middle)) + (ebox-buffer--child-splice-prepared + owner dirty context)))))) + +(defun ebox-buffer-prepare-child-splice (buffer op) + "Prepare one vertical keyed child splice OP for BUFFER." + (let* ((owner-id (plist-get op :owner-id)) + (dirty (plist-get op :dirty)) + (owner (ebox--buffer-runtime-node buffer owner-id)) + (snapshot (ebox--ensure-layout-snapshot-spans buffer owner-id)) + (old-ids (plist-get dirty :old-child-ids)) + (new-ids (plist-get dirty :new-child-ids)) + (window (ebox-buffer--child-list-window old-ids new-ids)) + (old-middle (plist-get window :old-middle)) + (new-middle (plist-get window :new-middle)) + (groups (ebox-buffer--child-reorder-span-groups buffer old-ids))) + (when (and groups + (or old-middle new-middle) + (not (and old-middle new-middle)) + (ebox-buffer--child-splice-candidate-p + owner snapshot dirty old-ids new-ids)) + (unless (with-current-buffer buffer + (ebox-buffer--partial-line-slots-p + (plist-get snapshot :buffer-spans))) + (when (ebox-buffer--child-splice-simple-new-children-p + owner new-middle) + (ebox-buffer--child-splice-publication + buffer owner dirty window groups)))))) + +(defun ebox-buffer-publish-prepared-child-reorder (buffer prepared) + "Publish PREPARED child reorder into BUFFER." + (with-current-buffer buffer + (let* ((spans (plist-get prepared :spans)) + (rendered + (ebox-buffer--child-reorder-rendered + (plist-get prepared :segments) + spans + (plist-get prepared :dirty))) + (owner-set + (ebox-buffer--replacement-owner-set + rendered (plist-get prepared :dirty))) + (new-spans + (ebox--replace-line-spans spans rendered owner-set))) + (when new-spans + (list :owner-id (plist-get prepared :owner-id) + :op 'child-reorder + :owner-type (plist-get prepared :owner-type) + :span-count (length spans) + :affected-region-ids + (ebox-buffer--replacement-region-ids spans rendered) + :old-buffer-spans spans + :new-buffer-spans new-spans))))) + +(defun ebox-buffer--inserted-rendered-spans (start rendered insert) + "Return actual rendered spans from START after inserting INSERT." + (let* ((prefix (- (length insert) (length rendered))) + (render-start (if (and (> prefix 0) + (= (aref insert 0) ?\n)) + (1+ start) + start)) + (render-end (+ render-start (length rendered)))) + (ebox--buffer-line-spans-between render-start render-end))) + +(defun ebox-buffer--publish-child-splice-text (prepared) + "Publish PREPARED splice text and return inserted rendered spans." + (let ((bounds (plist-get prepared :bounds)) + (insert (plist-get prepared :insert)) + (rendered (plist-get prepared :rendered)) + inserted-spans) + (ebox--with-preserved-buffer-window-state (current-buffer) + (let ((inhibit-read-only t) + (point-marker (copy-marker (point) t))) + (save-excursion + (goto-char (car bounds)) + (delete-region (car bounds) (cdr bounds)) + (insert insert) + (when rendered + (setq inserted-spans + (ebox-buffer--inserted-rendered-spans + (car bounds) rendered insert)))) + (goto-char (min (marker-position point-marker) (point-max))) + (set-marker point-marker nil))) + inserted-spans)) + +(defun ebox-buffer--child-splice-result + (prepared affected-region-ids inserted-spans) + "Return publication result for PREPARED child splice." + (let ((dirty (plist-get prepared :dirty))) + (list :owner-id (plist-get prepared :owner-id) + :op 'child-splice + :owner-type (plist-get prepared :owner-type) + :span-count (length (plist-get prepared :old-refresh-spans)) + :rendered (plist-get prepared :rendered) + :rendered-buffer-spans inserted-spans + :affected-region-ids affected-region-ids + :old-owner-region-ids (plist-get dirty :old-region-ids) + :new-owner-region-ids (plist-get dirty :new-region-ids) + :old-buffer-spans (plist-get prepared :spans) + :new-buffer-spans inserted-spans + :old-refresh-spans (plist-get prepared :old-refresh-spans) + :new-refresh-spans (plist-get prepared :new-refresh-spans)))) + +(defun ebox-buffer-publish-prepared-child-splice (buffer prepared) + "Publish PREPARED child splice into BUFFER." + (with-current-buffer buffer + (when (plist-get prepared :bounds) + (let* ((affected-region-ids + (ebox-buffer--replacement-region-ids + (plist-get prepared :spans) + (or (plist-get prepared :rendered) ""))) + (inserted-spans + (ebox-buffer--publish-child-splice-text prepared))) + (ebox-buffer--child-splice-result + prepared affected-region-ids inserted-spans))))) + +(defun ebox-buffer-prepare-owner-rerender (buffer op) + "Prepare owner rerender OP for BUFFER without modifying the buffer." + (let* ((owner-id (plist-get op :owner-id)) + (dirty (plist-get op :dirty)) + (snapshot (ebox--ensure-layout-snapshot-spans buffer owner-id)) + (node (ebox--buffer-runtime-node buffer owner-id)) + (snapshot-spans (plist-get snapshot :buffer-spans)) + (spans + (or (and node snapshot-spans + (with-current-buffer buffer + (ebox-buffer--contained-flex-publication-spans + buffer owner-id node snapshot-spans + (append (plist-get dirty :old-region-ids) + (plist-get op :old-owner-region-ids))))) + snapshot-spans)) + (replaced-owner-ids + (with-current-buffer buffer + (ebox-buffer--owners-inside-replacement-boundary + spans (plist-get op :old-owner-region-ids)))) + (requires-owned-overflow-coverage + (plist-get dirty :requires-owned-overflow-coverage)) + (coverage-owner-proven + (or (not requires-owned-overflow-coverage) + (equal owner-id (ebox--buffer-root-node-id buffer)) + (ebox--owned-overflow-coverage-wrapper-node-p + buffer owner-id))) + (partial-slot + (and spans + (with-current-buffer buffer + (ebox-buffer--partial-line-slots-p spans)))) + (coverage-partial-slot-eligible + (or (not requires-owned-overflow-coverage) + (not partial-slot) + (equal (plist-get dirty :changed-keys) '(:overflow)))) + (root-owner-p + (equal owner-id (ebox--buffer-root-node-id buffer)))) + (when (and node spans coverage-owner-proven + coverage-partial-slot-eligible) + (ebox--with-buffer-render-context buffer + (with-current-buffer buffer + (let* ((ebox--defer-scroll-content-index t) + (rendered + (ebox-buffer--render-owner-for-rerender + buffer owner-id node snapshot dirty root-owner-p)) + (root-render-metadata + (and root-owner-p + (fboundp 'ebox--rendered-root-metadata) + (ebox--rendered-root-metadata rendered nil))) + (rendered-owner-set + (and (not root-owner-p) + (ebox-buffer--replacement-owner-set + rendered dirty replaced-owner-ids))) + (slot-rendered + (and partial-slot + (ebox-buffer--rendered-in-existing-slots + spans rendered rendered-owner-set t))) + (partial-slot-compatible + (or (not partial-slot) slot-rendered)) + (coverage-partial-slot-safe + (or (not requires-owned-overflow-coverage) + (not partial-slot) + (and slot-rendered + (equal (plist-get dirty :changed-keys) + '(:overflow)) + (= (ebox-string-height rendered) + (length spans))))) + (slot-preserving (not (null slot-rendered))) + (patched-rendered (or slot-rendered rendered)) + (root-publication + (and root-owner-p + partial-slot-compatible + coverage-partial-slot-safe + (ebox-buffer--prepare-root-property-patches + spans patched-rendered))) + (publishable-p + (and partial-slot-compatible + coverage-partial-slot-safe + (if root-owner-p + root-publication + (ebox-buffer--line-replacement-feasible-p + spans patched-rendered))))) + (when publishable-p + (list :op 'owner-rerender + :owner-id owner-id + :owner-type (plist-get node :ebox-type) + :dirty dirty + :spans spans + :rendered patched-rendered + :raw-rendered rendered + :rendered-owner-set rendered-owner-set + :old-owner-region-ids + (plist-get op :old-owner-region-ids) + :new-owner-region-ids + (ebox--node-all-region-ids node) + :affected-region-ids + (ebox-buffer--replacement-region-ids + spans patched-rendered) + :slot-preserving slot-preserving + :root-owner-p root-owner-p + :root-render-metadata root-render-metadata + :root-publication root-publication)))))))) + +(defun ebox-buffer-publish-prepared-owner-rerender (buffer prepared) + "Publish PREPARED owner rerender into BUFFER." + (with-current-buffer buffer + (let* ((owner-id (plist-get prepared :owner-id)) + (spans (plist-get prepared :spans)) + (root-owner-p (plist-get prepared :root-owner-p)) + (root-publication + (and root-owner-p + (ebox-buffer--publish-prepared-root-property-patches + (plist-get prepared :root-publication)))) + (new-spans + (if root-owner-p + (plist-get root-publication :new-buffer-spans) + (ebox--replace-line-spans + spans + (plist-get prepared :rendered) + (plist-get prepared :rendered-owner-set))))) + (when new-spans + (list :owner-id owner-id + :op 'owner-rerender + :owner-type (plist-get prepared :owner-type) + :span-count (length spans) + :rendered (plist-get prepared :rendered) + :slot-preserving (plist-get prepared :slot-preserving) + :root-render-metadata + (plist-get prepared :root-render-metadata) + :root-rendered + (and root-owner-p (plist-get prepared :raw-rendered)) + :affected-region-ids + (plist-get prepared :affected-region-ids) + :old-owner-region-ids + (plist-get prepared :old-owner-region-ids) + :new-owner-region-ids + (plist-get prepared :new-owner-region-ids) + :publication-scope + (and root-owner-p + (if (plist-get root-publication + :full-root-required-p) + 'full-root + 'minimal-spans)) + :published-buffer-spans + (and root-owner-p + (plist-get root-publication + :published-buffer-spans)) + :full-root-required-p + (and root-owner-p + (plist-get root-publication + :full-root-required-p)) + :old-buffer-spans spans + :new-buffer-spans new-spans))))) + +(defun ebox-buffer-prepare-span-patch (buffer op) + "Prepare verified span OP for BUFFER without modifying the buffer." + (let* ((owner-id (plist-get op :owner-id)) + (dirty (plist-get op :dirty)) + (snapshot (ebox--ensure-layout-snapshot-details buffer owner-id)) + (spans (plist-get snapshot :buffer-spans)) + (old-span-footprint + (plist-get snapshot :span-footprint-signature)) + (old-external-footprint + (plist-get snapshot :external-footprint-signature)) + (old-parent-slot + (plist-get snapshot :parent-slot-signature)) + (node (ebox--buffer-runtime-node buffer owner-id))) + (when (and node spans old-span-footprint old-external-footprint + old-parent-slot + (not (ebox--node-visible-overflow-p node))) + (ebox--with-buffer-render-context buffer + (let* ((ebox--defer-scroll-content-index t) + (rendered + (or (ebox--render-node-in-current-flex-slot + buffer owner-id snapshot + (plist-get dirty :changed-keys)) + (ebox-render node))) + (rendered-owner-set + (ebox-buffer--replacement-owner-set rendered dirty)) + (slot-rendered + (and (ebox-buffer--inline-slot-span-patch-op-p op) + (with-current-buffer buffer + (ebox-buffer--rendered-in-existing-slots + spans rendered)))) + (candidate-rendered (or slot-rendered rendered)) + (new-span-footprint + (ebox--rendered-span-footprint-signature rendered)) + (candidate-span-footprint + (and slot-rendered + (ebox--rendered-span-footprint-signature + candidate-rendered))) + (new-external-footprint + (ebox--external-footprint-signature-from-span-footprint + new-span-footprint)) + (candidate-external-footprint + (and candidate-span-footprint + (ebox--external-footprint-signature-from-span-footprint + candidate-span-footprint))) + (new-parent-slot + (ebox--project-parent-slot-signature + old-parent-slot new-span-footprint)) + (candidate-parent-slot + (and candidate-span-footprint + (ebox--project-parent-slot-signature + old-parent-slot candidate-span-footprint))) + (use-slot + (and candidate-span-footprint + (not (and (ebox--span-footprint-compatible-p + old-span-footprint new-span-footprint) + (ebox--external-footprint-compatible-p + old-external-footprint new-external-footprint) + (ebox--parent-slot-compatible-p + old-parent-slot new-parent-slot))) + (ebox--span-footprint-compatible-p + old-span-footprint candidate-span-footprint) + (ebox--external-footprint-compatible-p + old-external-footprint candidate-external-footprint) + (ebox--parent-slot-compatible-p + old-parent-slot candidate-parent-slot))) + (final-span-footprint + (if use-slot candidate-span-footprint new-span-footprint)) + (final-external-footprint + (if use-slot candidate-external-footprint + new-external-footprint)) + (final-parent-slot + (if use-slot candidate-parent-slot new-parent-slot)) + (final-rendered + (if use-slot candidate-rendered rendered)) + (compatible-p + (or use-slot + (and (ebox--span-footprint-compatible-p + old-span-footprint new-span-footprint) + (ebox--external-footprint-compatible-p + old-external-footprint new-external-footprint) + (ebox--parent-slot-compatible-p + old-parent-slot new-parent-slot))))) + (when (and compatible-p + (ebox-buffer--line-replacement-feasible-p + spans final-rendered)) + (list :op 'span-patch + :owner-id owner-id + :owner-type (plist-get node :ebox-type) + :dirty dirty + :spans spans + :rendered final-rendered + :rendered-owner-set rendered-owner-set + :old-owner-region-ids + (plist-get op :old-owner-region-ids) + :new-owner-region-ids + (ebox--node-all-region-ids node) + :affected-region-ids + (ebox-buffer--replacement-region-ids + spans final-rendered) + :slot-preserving use-slot + :old-span-footprint-signature old-span-footprint + :new-span-footprint-signature final-span-footprint + :old-external-footprint-signature old-external-footprint + :new-external-footprint-signature final-external-footprint + :old-parent-slot-signature old-parent-slot + :new-parent-slot-signature final-parent-slot))))))) + +(defun ebox-buffer-publish-prepared-span-patch (buffer prepared) + "Publish PREPARED verified span patch into BUFFER." + (with-current-buffer buffer + (let* ((spans (plist-get prepared :spans)) + (new-spans + (ebox--replace-line-spans + spans + (plist-get prepared :rendered) + (plist-get prepared :rendered-owner-set)))) + (when new-spans + (list :owner-id (plist-get prepared :owner-id) + :op 'span-patch + :owner-type (plist-get prepared :owner-type) + :span-count (length spans) + :rendered (plist-get prepared :rendered) + :slot-preserving (plist-get prepared :slot-preserving) + :affected-region-ids + (plist-get prepared :affected-region-ids) + :old-owner-region-ids + (plist-get prepared :old-owner-region-ids) + :new-owner-region-ids + (plist-get prepared :new-owner-region-ids) + :old-buffer-spans spans + :new-buffer-spans new-spans + :old-signature + (plist-get + (plist-get prepared :old-span-footprint-signature) + :line-pixel-widths) + :new-signature + (plist-get + (plist-get prepared :new-span-footprint-signature) + :line-pixel-widths) + :old-span-footprint-signature + (plist-get prepared :old-span-footprint-signature) + :new-span-footprint-signature + (plist-get prepared :new-span-footprint-signature) + :old-external-footprint-signature + (plist-get prepared :old-external-footprint-signature) + :new-external-footprint-signature + (plist-get prepared :new-external-footprint-signature) + :old-parent-slot-signature + (plist-get prepared :old-parent-slot-signature) + :new-parent-slot-signature + (plist-get prepared :new-parent-slot-signature)))))) + +(defun ebox--execute-owner-rerender (buffer op) + "Execute owner rerender OP in BUFFER and return patch metadata." + (let* ((owner-id (plist-get op :owner-id)) + (dirty (plist-get op :dirty)) + (snapshot (ebox--ensure-layout-snapshot-spans buffer owner-id)) + (spans (plist-get snapshot :buffer-spans)) + (node (ebox--buffer-runtime-node buffer owner-id)) + (requires-owned-overflow-coverage + (plist-get dirty :requires-owned-overflow-coverage)) + (coverage-owner-proven + (or (not requires-owned-overflow-coverage) + (equal owner-id (ebox--buffer-root-node-id buffer)) + (ebox--owned-overflow-coverage-wrapper-node-p + buffer owner-id))) + (root-owner-p + (equal owner-id (ebox--buffer-root-node-id buffer)))) + (when (and node spans) + (ebox--with-buffer-render-context buffer + (with-current-buffer buffer + (let* ((ebox--defer-scroll-content-index t) + (prepared-root-patch + (and root-owner-p + (fboundp 'ebox--take-prepared-root-patch) + (ebox--take-prepared-root-patch node))) + (rendered + (unless prepared-root-patch + (ebox-buffer--render-owner-for-rerender + buffer owner-id node snapshot dirty root-owner-p))) + (root-render-metadata + (and root-owner-p + (if prepared-root-patch + (plist-get prepared-root-patch + :root-render-metadata) + (and + (fboundp 'ebox--rendered-root-metadata) + ;; Cold visible output starts with a lightweight + ;; descriptor. Scroll roots that cannot use + ;; marker-only recovery upgrade it on demand; + ;; prewarming prepares it before publication. A + ;; declarative candidate has no complete pre-render, + ;; so its root owner prepares exact indexes from the + ;; one root string it already had to render. + (ebox--rendered-root-metadata + rendered + (not + (eq ebox-incremental--declarative-commit-buffer + buffer))))))) + (rendered-owner-set + (and (not root-owner-p) + (ebox-buffer--replacement-owner-set rendered dirty))) + (partial-slot + (ebox-buffer--partial-line-slots-p spans)) + (slot-rendered + (and (not prepared-root-patch) partial-slot + (ebox-buffer--rendered-in-existing-slots + spans rendered rendered-owner-set t))) + (partial-slot-compatible + (or (not partial-slot) slot-rendered)) + (coverage-partial-slot-safe + (or (not requires-owned-overflow-coverage) + (not partial-slot) + (and slot-rendered + (equal (plist-get dirty :changed-keys) + '(:overflow)) + (= (ebox-string-height rendered) + (length spans))))) + (slot-preserving (not (null slot-rendered))) + (patched-rendered (or slot-rendered rendered)) + (root-publication + (and root-owner-p + coverage-owner-proven + partial-slot-compatible + coverage-partial-slot-safe + (if prepared-root-patch + (ebox-buffer--apply-root-property-patches + spans prepared-root-patch) + (ebox-buffer--replace-root-with-property-patches + spans patched-rendered)))) + (new-spans + (and coverage-owner-proven + partial-slot-compatible + coverage-partial-slot-safe + (if root-owner-p + (plist-get root-publication :new-buffer-spans) + (ebox--replace-line-spans + spans patched-rendered rendered-owner-set))))) + (when new-spans + (list :owner-id owner-id + :op 'owner-rerender + :owner-type (plist-get node :ebox-type) + :span-count (length spans) + :slot-preserving slot-preserving + :root-render-metadata root-render-metadata + :root-rendered + (and root-owner-p (not prepared-root-patch) rendered) + :patch-origin + (and prepared-root-patch + (plist-get root-publication :patch-origin)) + :full-frame-bytes + (and prepared-root-patch + (plist-get root-publication :full-frame-bytes)) + :replacement-bytes + (and prepared-root-patch + (plist-get root-publication :replacement-bytes)) + :role-delta + (and prepared-root-patch + (plist-get root-publication :role-delta)) + :affected-region-ids + (and prepared-root-patch + (plist-get root-publication + :affected-region-ids)) + :publication-scope + (and root-owner-p + (if (plist-get root-publication + :full-root-required-p) + 'full-root + 'minimal-spans)) + :published-buffer-spans + (and root-owner-p + (plist-get root-publication + :published-buffer-spans)) + :full-root-required-p + (and root-owner-p + (plist-get root-publication + :full-root-required-p)) + :old-buffer-spans spans + :new-buffer-spans new-spans)))))))) + +(defun ebox--execute-span-patch + (buffer op &optional pre-rendered pre-rendered-owner-set) + "Execute signature-stable span patch OP in BUFFER and return metadata. +PRE-RENDERED and PRE-RENDERED-OWNER-SET reuse an equivalent render already +produced by the caller." + (let* ((owner-id (plist-get op :owner-id)) + (dirty (plist-get op :dirty)) + (snapshot (ebox--ensure-layout-snapshot-details buffer owner-id)) + (spans (plist-get snapshot :buffer-spans)) + (old-span-footprint + (plist-get snapshot :span-footprint-signature)) + (old-external-footprint + (plist-get snapshot :external-footprint-signature)) + (old-parent-slot + (plist-get snapshot :parent-slot-signature)) + (node (ebox--buffer-runtime-node buffer owner-id))) + (when (and node spans old-span-footprint old-external-footprint + old-parent-slot + (not (ebox--node-visible-overflow-p node))) + (ebox--with-buffer-render-context buffer + (let* ((ebox--defer-scroll-content-index t) + (rendered + (or pre-rendered + (ebox--render-node-in-current-flex-slot + buffer owner-id snapshot + (plist-get dirty :changed-keys)) + (ebox-render node))) + (rendered-owner-set + (or pre-rendered-owner-set + (ebox-buffer--replacement-owner-set rendered dirty))) + (slot-rendered + (and (ebox-buffer--inline-slot-span-patch-op-p op) + (with-current-buffer buffer + (ebox-buffer--rendered-in-existing-slots + spans rendered)))) + (candidate-rendered (or slot-rendered rendered)) + (new-span-footprint + (ebox--rendered-span-footprint-signature rendered)) + (candidate-span-footprint + (and slot-rendered + (ebox--rendered-span-footprint-signature + candidate-rendered))) + (new-external-footprint + (ebox--external-footprint-signature-from-span-footprint + new-span-footprint)) + (candidate-external-footprint + (and candidate-span-footprint + (ebox--external-footprint-signature-from-span-footprint + candidate-span-footprint))) + (new-parent-slot + (ebox--project-parent-slot-signature + old-parent-slot new-span-footprint)) + (candidate-parent-slot + (and candidate-span-footprint + (ebox--project-parent-slot-signature + old-parent-slot candidate-span-footprint))) + (use-slot + (and candidate-span-footprint + (not (and (ebox--span-footprint-compatible-p + old-span-footprint new-span-footprint) + (ebox--external-footprint-compatible-p + old-external-footprint new-external-footprint) + (ebox--parent-slot-compatible-p + old-parent-slot new-parent-slot))) + (ebox--span-footprint-compatible-p + old-span-footprint candidate-span-footprint) + (ebox--external-footprint-compatible-p + old-external-footprint candidate-external-footprint) + (ebox--parent-slot-compatible-p + old-parent-slot candidate-parent-slot))) + (final-span-footprint + (if use-slot candidate-span-footprint new-span-footprint)) + (final-external-footprint + (if use-slot candidate-external-footprint + new-external-footprint)) + (final-parent-slot + (if use-slot candidate-parent-slot new-parent-slot)) + (final-rendered + (if use-slot candidate-rendered rendered))) + (when (or use-slot + (and (ebox--span-footprint-compatible-p + old-span-footprint new-span-footprint) + (ebox--external-footprint-compatible-p + old-external-footprint new-external-footprint) + (ebox--parent-slot-compatible-p + old-parent-slot new-parent-slot))) + (with-current-buffer buffer + (when-let ((new-spans + (ebox--replace-line-spans + spans final-rendered rendered-owner-set))) + (list :owner-id owner-id + :op 'span-patch + :owner-type (plist-get node :ebox-type) + :span-count (length spans) + :slot-preserving use-slot + :old-buffer-spans spans + :new-buffer-spans new-spans + :old-signature + (plist-get old-span-footprint :line-pixel-widths) + :new-signature + (plist-get final-span-footprint :line-pixel-widths) + :old-span-footprint-signature old-span-footprint + :new-span-footprint-signature final-span-footprint + :old-external-footprint-signature old-external-footprint + :new-external-footprint-signature + final-external-footprint + :old-parent-slot-signature old-parent-slot + :new-parent-slot-signature + final-parent-slot))))))))) + +(defun ebox-buffer-apply-paint-patch (buffer op) + "Apply paint patch OP to BUFFER." + (ebox--execute-paint-patch buffer op)) + +(defun ebox-buffer-apply-span-patch + (buffer op &optional pre-rendered pre-rendered-owner-set) + "Apply signature-stable span patch OP to BUFFER. +PRE-RENDERED and PRE-RENDERED-OWNER-SET avoid repeating caller-owned render +work when they describe the same owner and render context as OP." + (ebox--execute-span-patch + buffer op pre-rendered pre-rendered-owner-set)) + +(defun ebox-buffer-apply-owner-rerender (buffer op) + "Apply owner rerender OP to BUFFER." + (or (and (not (plist-get (plist-get op :dirty) + :requires-owned-overflow-coverage)) + (ebox--execute-flex-line-rerender buffer op)) + (ebox--execute-owner-rerender buffer op))) + +(defun ebox--region-find (region-id type) + "Find all regions of TYPE for REGION-ID in current buffer. +TYPE is a symbol: content, pt, pb, pl, pr, mt, mb, ml, mr, bt, bb, bl, br. +Returns list of (start . end) cons cells." + (if-let ((table (ebox-buffer--region-role-span-table))) + (copy-sequence + (gethash (ebox-buffer--region-role-key region-id type) table)) + (ebox-buffer--scan-region-role-spans region-id type))) + +(defun ebox--patch-region-props (region-id type fn) + "Call FN on every buffer span of TYPE belonging to REGION-ID. +FN receives (start end) and should update text properties in place. +Runs inside `save-excursion' with `inhibit-read-only' set." + (let ((spans (ebox--region-find region-id type)) + (inhibit-read-only t)) + (save-excursion + (dolist (span spans) + (funcall fn (car span) (cdr span)))))) + +(defun ebox--region-replace-line (region-id idx string) + "Replace content at line IDX in REGION-ID with STRING. +Internal primitive used by the scroll fast-path." + (let ((inhibit-read-only t)) + (save-excursion + (goto-char (point-min)) + (catch 'done + (while (< (point) (point-max)) + (when (and (eql (get-text-property (point) 'ebox-content) region-id) + (eql (get-text-property (point) 'ebox-content-idx) idx)) + (let ((end (next-single-property-change + (point) 'ebox-content nil (point-max))) + (start (point)) + (props (copy-sequence (text-properties-at (point))))) + (setq props (plist-put props 'ebox-content region-id)) + (setq props (plist-put props 'ebox-content-idx idx)) + (delete-region (point) end) + (insert (apply #'propertize string props)) + (throw 'done (cons start (point))))) + (goto-char (next-single-property-change + (point) 'ebox-content nil (point-max)))))))) + +(defun ebox--full-rerender-buffer (buffer) + "Re-render BUFFER from the root node stored in its render state. +Called after any geometry change so that stack row-pads are recalculated +against the new box sizes." + (ebox--with-render-gc + (when-let ((root (ebox--buffer-root-node buffer))) + (ebox--with-buffer-render-context buffer + (ebox--with-preserved-buffer-window-state buffer + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (ebox--clear-buffer-extents buffer) + (erase-buffer) + (let ((start (point))) + (insert (ebox-render root)) + (ebox--register-box-extents-in-range start (point)) + (when (fboundp 'ebox--refresh-buffer-scroll-content-markers) + (ebox--refresh-buffer-scroll-content-markers buffer)) + (ebox-buffer-refresh-region-role-spans buffer) + (ebox--refresh-buffer-layout-snapshots buffer)) + (goto-char (point-min))))))))) + +(defun ebox-buffer-apply-root-rerender (buffer) + "Apply a root rerender to BUFFER." + (ebox--full-rerender-buffer buffer)) + +(defun ebox--rerender-box (region-id) + "Rerender REGION-ID by replacing its whole contiguous buffer slice. +Currently this only works for boxes whose rendered output is contiguous in the +buffer, e.g. standalone boxes or purely vertical stacks." + (let* ((box (or (gethash region-id ebox--region-box-table) + (error "No box found for region %S" region-id))) + (extents (or (gethash region-id ebox--box-extents) + (error "No contiguous extents found for region %S" region-id))) + (start-marker (car extents)) + (end-marker (cdr extents)) + (buffer (marker-buffer start-marker))) + (unless (and buffer (eq buffer (marker-buffer end-marker))) + (error "Dead extents for region %S" region-id)) + (with-current-buffer buffer + (ebox--with-preserved-buffer-window-state buffer + (let* ((start (marker-position start-marker)) + (end (marker-position end-marker)) + (inhibit-read-only t) + (pt (point)) + (pt-offset (when (and (>= pt start) (< pt end)) + (- pt start))) + rendered) + (ebox--with-render-gc + (save-excursion + (goto-char start) + (delete-region start end) + ;; BOX already owns its stable :region-id. A dynamic override + ;; here would leak through recursive child rendering and assign + ;; every descendant border/content span to REGION-ID. + (setq rendered (ebox-render box)) + (insert rendered) + (ebox--set-box-extents region-id start (point))) + (when pt-offset + (goto-char (min (+ start pt-offset) (point-max)))) + rendered)))))) + +(provide 'ebox-buffer-backend) + +;;; ebox-buffer-backend.el ends here diff --git a/ebox-cache.el b/ebox-cache.el new file mode 100644 index 0000000..6f1f7d7 --- /dev/null +++ b/ebox-cache.el @@ -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 diff --git a/ebox-dsl.el b/ebox-dsl.el new file mode 100644 index 0000000..801dff2 --- /dev/null +++ b/ebox-dsl.el @@ -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 diff --git a/ebox-flex.el b/ebox-flex.el new file mode 100644 index 0000000..972656a --- /dev/null +++ b/ebox-flex.el @@ -0,0 +1,2334 @@ +;;; ebox-flex.el --- Flex formatting context for Ebox -*- lexical-binding: t; -*- + +;;; Commentary: +;; Owns flex container and item normalization, line breaking, sizing, and +;; rendering decisions. It does not own generic property expansion or buffer +;; patch execution. + +;;; Code: + +(require 'cl-lib) +(require 'subr-x) +(require 'ebox-layout) + +(defvar ebox--propertize-private-content-line-p) +(defvar ebox--flex-content-min-width-table) + +(defconst ebox--flex-item-prop-keys + '(:order :flex :flex-grow :flex-shrink :flex-basis :align-self) + "Properties that belong to flex item metadata.") + +(defconst ebox--flex-container-layout-prop-keys + '(:flex-direction :flex-wrap :flex-flow + :justify-content :align-items :align-content + :gap :row-gap :column-gap) + "Properties that belong only to flex container layout.") + +(defconst ebox--flex-container-neutral-box-prop-keys + '(:width :height :box-sizing) + "Flex box properties that do not create visible wrapping by themselves.") + +(defun ebox--flex-visual-box-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 ebox--flex-container-neutral-box-prop-keys)))) + +(defun ebox--preformatted-box-props (props) + "Return PROPS with wrapping disabled unless explicitly set. +Use this when a box receives already-rendered layout text as its content. +That text is preformatted and must not be sent through the default wrapper." + (if (plist-member props :wrap-mode) + props + (append props (list :wrap-mode nil)))) + +(defun ebox--flex-wrap-node-with-box-props (tag node props) + "Wrap NODE in an `ebox-create' box using PROPS for DSL/API TAG." + (if (null props) + node + (when (plist-member props :content) + (error "ebox-build: %S cannot combine :content with child nodes" tag)) + (let ((box (apply #'ebox-create + (append (ebox--preformatted-box-props props) + (list :display '(block flow) + :ebox-content-node node))))) + (when (listp node) + (dolist (key ebox--flex-item-prop-keys) + (when (plist-member node key) + (plist-put box key (plist-get node key))))) + box))) + +(defun ebox--flex-make-wrapper-box (props) + "Return a flex container wrapper box for PROPS, or nil." + (when (and props (ebox--flex-visual-box-props-p props)) + (when (plist-member props :content) + (error "ebox-flex: flex cannot combine :content with child nodes")) + (apply #'ebox-create + (append (ebox--preformatted-box-props props) + (list :content ""))))) + +(defun ebox--flex-container-content-props (props raw-props wrapper-box) + "Return flex layout PROPS adjusted to WRAPPER-BOX's content area." + (if (null wrapper-box) + props + (let ((props (copy-sequence props))) + (when (or (plist-member raw-props :width) + (and (null (plist-get props :width)) + (ebox--viewport-pixel-width nil))) + (when-let ((content-viewport + (ebox--wrapper-content-viewport-pixel wrapper-box))) + (plist-put props :width (list content-viewport)))) + (when (and (plist-member raw-props :height) + (ebox--resolve-size-content-height + wrapper-box (ebox-get wrapper-box :height) nil)) + (plist-put props :height (ebox--content-height wrapper-box 1))) + props))) + +(defconst ebox--preferred-size-keywords + '(auto min-content max-content fit-content stretch contain) + "Supported CSS preferred-size keywords.") + +(defconst ebox--min-size-keywords + '(auto min-content max-content fit-content stretch contain) + "Supported CSS min-size keywords.") + +(defconst ebox--max-size-keywords + '(none min-content max-content fit-content stretch contain) + "Supported CSS max-size keywords.") + +(defun ebox--normalize-horizontal-size-value (value allowed-keywords) + "Normalize VALUE as a width-like size. +ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed." + (cond + ((null value) nil) + ((ebox--viewport-size-value-p value) 'viewport) + ((and (consp value) + (null (cdr value)) + (symbolp (car value)) + (not (eq (car value) 'viewport))) + (ebox--normalize-horizontal-size-value (car value) allowed-keywords)) + ((memq value allowed-keywords) value) + ((and (consp value) (eq (car value) 'fit-content)) + (if-let ((limit (cadr value))) + (let ((pixels (if (ebox--viewport-size-value-p limit) + 'viewport + (ebox--nonnegative-horizontal-size-pixels limit nil)))) + (unless pixels + (error "ebox: invalid fit-content width limit: %S" limit)) + (list 'fit-content pixels)) + (unless (memq 'fit-content allowed-keywords) + (error "ebox: fit-content is not valid here")) + 'fit-content)) + (t + (let ((pixels (ebox--nonnegative-horizontal-size-pixels value nil))) + (unless pixels + (error "ebox: unsupported width value: %S" value)) + pixels)))) + +(defun ebox--flex-horizontal-value (value &optional default) + "Return VALUE as pixels using Ebox horizontal units." + (cond + ((null value) (or default (ebox--viewport-pixel-width nil))) + ((memq value '(auto stretch)) + (or default (ebox--viewport-pixel-width nil))) + (t (ebox--nonnegative-horizontal-size-pixels value default)))) + +(defun ebox--flex-line-value (value &optional default) + "Return VALUE as line count using Ebox vertical units." + (cond + ((null value) default) + ((eq value 'auto) default) + ((and (consp value) (numberp (car value))) + (let ((lines (floor (car value)))) + (when (< lines 0) + (error "ebox: flex line values cannot be negative: %S" value)) + lines)) + ((numberp value) + (let ((lines (floor value))) + (when (< lines 0) + (error "ebox: flex line values cannot be negative: %S" value)) + lines)) + (t default))) + +(defun ebox--flex-main-value (axis value &optional default) + "Return VALUE in AXIS units." + (if (eq axis 'row) + (ebox--flex-horizontal-value value default) + (ebox--flex-line-value value default))) + +(defun ebox--flex-cross-value (axis value &optional default) + "Return VALUE in cross-axis units for AXIS." + (if (eq axis 'row) + (ebox--flex-line-value value default) + (ebox--flex-horizontal-value value default))) + +(defun ebox--flex-gap-pair (gap) + "Return (ROW-GAP . COLUMN-GAP) raw values from GAP shorthand." + (cond + ((and (listp gap) + (= (length gap) 2) + (not (keywordp (car gap)))) + (cons (nth 0 gap) (nth 1 gap))) + (t (cons gap gap)))) + +(defun ebox--flex-normalize-container-props (props) + "Normalize flex container PROPS to explicit fields." + (let* ((flow (plist-get props :flex-flow)) + (flow-values (if (listp flow) flow (list flow))) + (flow-direction (cl-find-if + (lambda (value) + (memq value '(row row-reverse column column-reverse))) + flow-values)) + (flow-wrap (cl-find-if + (lambda (value) + (memq value '(nowrap wrap wrap-reverse))) + flow-values)) + (gap-pair (ebox--flex-gap-pair (plist-get props :gap))) + (direction (or (plist-get props :flex-direction) + flow-direction + 'row)) + (wrap (or (plist-get props :flex-wrap) + flow-wrap + 'nowrap))) + (list :flex-direction direction + :flex-wrap wrap + :justify-content (or (plist-get props :justify-content) 'flex-start) + :align-items (or (plist-get props :align-items) 'stretch) + :align-content (or (plist-get props :align-content) 'stretch) + :row-gap (if (plist-member props :row-gap) + (plist-get props :row-gap) + (car gap-pair)) + :column-gap (if (plist-member props :column-gap) + (plist-get props :column-gap) + (cdr gap-pair)) + :width (plist-get props :width) + :height (plist-get props :height)))) + +(defun ebox--flex-axis (props) + "Return main axis for normalized flex PROPS." + (if (memq (plist-get props :flex-direction) '(column column-reverse)) + 'column + 'row)) + +(defun ebox--flex-reverse-p (props) + "Return non-nil when normalized flex PROPS reverse visual order." + (memq (plist-get props :flex-direction) '(row-reverse column-reverse))) + +(defun ebox--flex-nonnegative-factor (value property) + "Return VALUE as a non-negative flex factor for PROPERTY." + (unless (numberp value) + (error "ebox: %S must be a non-negative number: %S" property value)) + (when (< value 0) + (error "ebox: %S cannot be negative: %S" property value)) + value) + +(defun ebox--flex-apply-shorthand (props) + "Return normalized item props from raw flex item PROPS." + (let ((order 0) + (grow 0) + (shrink 1) + (basis 'auto) + (align-self 'auto)) + (when (plist-member props :flex) + (let ((flex (plist-get props :flex))) + (cond + ((numberp flex) + (setq grow flex shrink 1 basis 0)) + ((eq flex 'none) + (setq grow 0 shrink 0 basis 'auto)) + ((eq flex 'auto) + (setq grow 1 shrink 1 basis 'auto)) + ((eq flex 'initial) + (setq grow 0 shrink 1 basis 'auto)) + ((listp flex) + (setq grow (or (nth 0 flex) grow) + shrink (or (nth 1 flex) shrink) + basis (if (> (length flex) 2) (nth 2 flex) basis)))))) + (when (plist-member props :order) + (setq order (plist-get props :order))) + (when (plist-member props :flex-grow) + (setq grow (plist-get props :flex-grow))) + (when (plist-member props :flex-shrink) + (setq shrink (plist-get props :flex-shrink))) + (when (plist-member props :flex-basis) + (setq basis (plist-get props :flex-basis))) + (when (plist-member props :align-self) + (setq align-self (plist-get props :align-self))) + (setq grow (ebox--flex-nonnegative-factor grow :flex-grow) + shrink (ebox--flex-nonnegative-factor shrink :flex-shrink)) + (list :order (or order 0) + :flex-grow grow + :flex-shrink shrink + :flex-basis (or basis 'auto) + :align-self (or align-self 'auto)))) + +(defun ebox--flex-item-props (item) + "Return normalized flex item metadata for ITEM." + (ebox--flex-apply-shorthand (ebox--flex-participation-props item))) + +(defun ebox--flex-string-width (string) + "Return max pixel width among STRING lines." + (ebox--string-max-pixel-width string)) + +(defun ebox--flex-string-main (string axis) + "Return STRING main-axis size for AXIS." + (if (eq axis 'row) + (ebox--flex-string-width string) + (ebox-string-height string))) + +(defun ebox--flex-string-cross (string axis) + "Return STRING cross-axis size for AXIS." + (if (eq axis 'row) + (ebox-string-height string) + (ebox--flex-string-width string))) + +(defun ebox--flex-inline-viewport (axis main-size cross-size) + "Return the inline viewport available to a flex item." + (if (eq axis 'row) + main-size + cross-size)) + +(defun ebox--flex-composite-source-p (source) + "Return non-nil when SOURCE is a layout subtree rather than a single box." + (and (listp source) + (not (stringp source)) + (plist-get source :ebox-type) + (not (eq (plist-get source :ebox-type) 'box)))) + +(defun ebox--flex-bind-source-viewport (source viewport thunk) + "Call THUNK with VIEWPORT bound for composite flex item SOURCE." + (if (and viewport (ebox--flex-composite-source-p source)) + (let ((ebox-viewport-width viewport)) + (funcall thunk)) + (funcall thunk))) + +(defvar ebox--flex-sized-render-observation-table + (make-hash-table :test 'eq :weakness 'key) + "Weak map from render caches to repeated composite sized-render contexts.") + +(defconst ebox--flex-sized-render-observation-limit 2048 + "Maximum observed composite sized-render contexts retained per cache.") + +(defun ebox--flex-scoped-sized-render-signature + (source axis main cross align viewport) + "Return a cache signature after SOURCE repeats in one sized VIEWPORT. +AXIS, MAIN, CROSS, and ALIGN identify the computed flex item geometry. A +composite source is rendered with VIEWPORT bound locally, so the ambient root +width cannot affect that final output. The first occurrence is only observed; +this avoids signature and retained-cost work for one-off responsive sizes." + (when (and ebox--render-cache-table + (numberp viewport) + (ebox--flex-composite-source-p source)) + (let* ((observations + (or (gethash ebox--render-cache-table + ebox--flex-sized-render-observation-table) + (let ((table (make-hash-table :test 'equal))) + (puthash ebox--render-cache-table table + ebox--flex-sized-render-observation-table) + table))) + (context + (list (ebox--ensure-node-id source) + axis main cross align viewport + ebox--intrinsic-layout-measurement))) + (if (gethash context observations) + (let ((ebox-viewport-width viewport)) + (list (ebox--current-display-signature) + viewport ebox-viewport-height + ebox--intrinsic-layout-measurement + ebox--render-region-id + ebox--scroll-window-initial-lookahead-lines-override + ebox--scroll-window-render-disabled + (ebox--render-cache-node-body-signature source))) + (when (>= (hash-table-count observations) + ebox--flex-sized-render-observation-limit) + (clrhash observations)) + (puthash context t observations) + nil)))) + +(defun ebox--flex-render-source-for-measurement (source viewport) + "Render SOURCE for flex base-size measurement under VIEWPORT. +Direct box flex items are measured intrinsically. Composite items receive +VIEWPORT for descendants so auto and max-width layouts can resolve against +their containing block, while stack/column containers do not stretch their own +outer measurement width to VIEWPORT." + (if (ebox--flex-composite-source-p source) + (let ((ebox-viewport-width viewport) + (ebox--intrinsic-layout-measurement t)) + (ebox--render-with-cache source)) + (let ((ebox-viewport-width nil)) + (ebox--render-with-cache source)))) + +(defun ebox--flex-measured-render-context-compatible-p + (source measured-viewport final-viewport) + "Return non-nil when SOURCE's measured render fits FINAL-VIEWPORT." + (or (equal measured-viewport final-viewport) + (not (ebox--viewport-dependent-subtree-p source)))) + +(defun ebox--flex-box-min-main (box rendered axis) + "Return BOX's minimum renderable main-axis size for AXIS. +RENDERED is BOX rendered at its natural size. No-wrap content and +preformatted wrapper boxes cannot be safely compressed below that rendered +size; wrapping content may shrink down to its declared minimum and widest +grapheme." + (if (eq axis 'row) + (let ((declared-min (or (ebox--resolve-size-content-pixel + box (ebox-get box :min-width) 0) + 0))) + (if (null (ebox-get box :wrap-mode)) + (max (ebox--flex-string-main rendered axis) + (+ (ebox--side-pixel box) declared-min)) + (let ((content-min (ebox--content-min-pixel box))) + (when (hash-table-p ebox--flex-content-min-width-table) + (puthash box content-min + ebox--flex-content-min-width-table)) + (+ (ebox--side-pixel box) + (max declared-min content-min))))) + (+ (ebox--side-height box) + (max 1 + (or (ebox--box-sizing-content-height + box (ebox-get box :min-height)) + 0))))) + +(defun ebox--flex-box-main-constraint (box axis property) + "Return BOX's total main-axis size constraint from PROPERTY." + (when (plist-member box property) + (if (eq axis 'row) + (when-let ((content-size + (ebox--resolve-size-content-pixel + box (ebox-get box property) nil))) + (+ (ebox--side-pixel box) content-size)) + (when-let ((block-size + (ebox--box-sizing-content-height + box (ebox-get box property)))) + (+ (ebox--side-height box) block-size))))) + +(defun ebox--flex-min-main (source rendered axis) + "Return SOURCE's minimum main-axis size for flex layout on AXIS." + (if (and (listp source) (eq (plist-get source :ebox-type) 'box)) + (ebox--flex-box-min-main source rendered axis) + (ebox--flex-string-main rendered axis))) + +(defun ebox--flex-max-main (source axis) + "Return SOURCE's maximum main-axis size for flex layout on AXIS." + (when (and (listp source) (eq (plist-get source :ebox-type) 'box)) + (ebox--flex-box-main-constraint + source axis (if (eq axis 'row) :max-width :max-height)))) + +(defun ebox--flex-box-content-basis-main (box rendered axis) + "Return BOX's flex-basis:content main size for AXIS." + (if (eq axis 'row) + (+ (ebox--side-pixel box) + (ebox--content-max-pixel box)) + (ebox--flex-string-main rendered axis))) + +(defun ebox--flex-basis-main (source rendered axis basis) + "Return SOURCE's flex base size for AXIS from BASIS." + (cond + ((eq basis 'auto) + (ebox--flex-string-main rendered axis)) + ((eq basis 'content) + (if (and (listp source) (eq (plist-get source :ebox-type) 'box)) + (ebox--flex-box-content-basis-main source rendered axis) + (ebox--flex-string-main rendered axis))) + ((and (eq axis 'row) + (listp source) + (eq (plist-get source :ebox-type) 'box)) + (let* ((normalized + (ebox--normalize-horizontal-size-value + basis ebox--preferred-size-keywords)) + (content-size + (ebox--resolve-size-content-pixel + source normalized (ebox--content-max-pixel source)))) + (+ (ebox--side-pixel source) content-size))) + (t + (ebox--flex-main-value axis basis 0)))) + +(defun ebox--flex-clamp-main (size min-main max-main) + "Clamp SIZE by MIN-MAIN and MAX-MAIN using CSS min-wins ordering." + (let ((min-main (max 0 (or min-main 0))) + (size (max 0 (or size 0)))) + (max min-main + (min (or max-main 999999999) size)))) + +(defun ebox--flex-recache-source-boxes (node) + "Map rendered region ids back to source boxes in NODE." + (when (listp node) + (unless (and ebox--render-recached-source-node-cache + (gethash node ebox--render-recached-source-node-cache)) + (when ebox--render-recached-source-node-cache + (puthash node t ebox--render-recached-source-node-cache)) + (pcase (plist-get node :ebox-type) + ('box + (when-let ((region-id (ebox-get node :region-id))) + (puthash region-id node ebox--region-box-table)) + (ebox--flex-recache-source-boxes + (plist-get node :ebox-content-node))) + ('concat + (dolist (child (ebox--layout-children node)) + (ebox--flex-recache-source-boxes child))) + ('stack + (dolist (child (ebox--layout-children node)) + (ebox--flex-recache-source-boxes child))) + ('flex + (dolist (child (plist-get node :children)) + (ebox--flex-recache-source-boxes child))) + ('flex-item + (ebox--flex-recache-source-boxes (plist-get node :node))))))) + +(defun ebox--flex-box-main-margin (box axis) + "Return BOX's total margin on AXIS." + (if (eq axis 'row) + (+ (ebox-get box :margin-left-pixel) + (ebox-get box :margin-right-pixel)) + (+ (floor (ebox-get box :margin-top-height)) + (floor (ebox-get box :margin-bottom-height))))) + +(defun ebox--flex-box-main-property (box axis target) + "Return a box width/height property for margin-box TARGET on AXIS." + (let* ((horizontal (eq axis 'row)) + (outer-size + (if (eq (ebox-get box :box-sizing) 'border-box) + (ebox--flex-box-main-margin box axis) + (if horizontal + (ebox--side-pixel box) + (ebox--side-height box))))) + (max (if horizontal 0 1) + (- target outer-size)))) + +(defun ebox--flex-copy-node-for-size (node axis main cross stretch) + "Return NODE or a copy with flex-computed MAIN/CROSS size." + (if (and (listp node) (eq (plist-get node :ebox-type) 'box)) + (let ((copy (copy-sequence node))) + (ebox--ensure-region-id node) + (plist-put copy :region-id (ebox-get node :region-id)) + (when main + (if (eq axis 'row) + (plist-put copy :width + (ebox--flex-box-main-property node axis main)) + (plist-put copy :height + (ebox--flex-box-main-property node axis main)))) + (when (and cross stretch) + (if (eq axis 'row) + (plist-put copy :height + (ebox--flex-box-main-property node 'column cross)) + (plist-put copy :width + (ebox--flex-box-main-property node 'row cross)))) + copy) + node)) + +(defun ebox--flex-measure-cacheable-p (source props) + "Return non-nil when SOURCE and PROPS have stable flex measurements." + (and (ebox--render-cacheable-node-p source) + (not (ebox--viewport-dependent-size-value-p + (plist-get props :flex-basis))))) + +(defun ebox--flex-render-cache-signature (node) + "Return NODE's flex cache signature after stabilizing runtime identity." + (ebox--runtime-node-ids node) + (ebox--node-all-region-ids node) + (ebox--render-cache-node-signature node)) + +(defun ebox--flex-cached-measurement (source props axis) + "Return SOURCE's warm persistent flex measurement, or nil." + (when (and ebox--render-cache-table + (ebox--flex-measure-cacheable-p source props)) + (let* ((cache-key (list (ebox--ensure-node-id source) + 'flex-measure axis props)) + (signature (ebox--flex-render-cache-signature source)) + (cached (ebox--render-cache-lookup cache-key signature))) + (when cached + (copy-sequence cached))))) + +(defun ebox--flex-measure-source (source props axis &optional viewport) + "Return intrinsic flex measurement plist for SOURCE on AXIS." + (let* ((persistent-cache-key + (and (ebox--flex-measure-cacheable-p source props) + (list (ebox--ensure-node-id source) + 'flex-measure axis props))) + (persistent-signature + (and persistent-cache-key + (ebox--flex-render-cache-signature source))) + (persistent-cached + (and persistent-cache-key + (ebox--render-cache-lookup persistent-cache-key + persistent-signature)))) + (if persistent-cached + (copy-sequence persistent-cached) + (let* ((rendered (progn + (ebox-region-ids source) + (prog1 + (ebox--flex-render-source-for-measurement + source viewport) + (ebox--flex-recache-source-boxes source)))) + (rendered-main (ebox--flex-string-main rendered axis)) + (rendered-cross (ebox--flex-string-cross rendered axis)) + (basis (plist-get props :flex-basis)) + (min-main (ebox--flex-min-main source rendered axis)) + (max-main (ebox--flex-max-main source axis)) + (base (ebox--flex-basis-main source rendered axis basis)) + (hypothetical (ebox--flex-clamp-main + base min-main max-main)) + (measurement (list :rendered rendered + :render-main rendered-main + :render-cross rendered-cross + :render-viewport viewport + :min-main min-main + :max-main max-main + :hypothetical hypothetical + :base (max 0 base) + :target hypothetical))) + (when persistent-cache-key + (ebox--render-cache-store persistent-cache-key + persistent-signature + measurement)) + (copy-sequence measurement))))) + +(defun ebox--flex-fixed-basis-value-p (basis) + "Return non-nil when BASIS is a context-free numeric flex size." + (or (numberp basis) + (and (consp basis) + (numberp (car basis)) + (null (cdr basis))))) + +(defun ebox--flex-unrendered-measurement (source props axis) + "Return SOURCE flex metrics that do not require intrinsic rendering. +An explicit numeric basis fixes the base size. Wrapped boxes on a row axis +derive their minimum from declared constraints and the widest grapheme, while +box heights use declared line constraints. In those cases the final sized +render can be the first render instead of rendering the whole subtree once for +measurement and immediately rendering it again at the computed size." + (let ((basis (plist-get props :flex-basis))) + (when (and (listp source) + (eq (plist-get source :ebox-type) 'box) + (ebox--flex-fixed-basis-value-p basis) + (or (not (eq axis 'row)) + (ebox-get source :wrap-mode))) + (let* ((min-main (ebox--flex-min-main source "" axis)) + (max-main (ebox--flex-max-main source axis)) + (base (ebox--flex-basis-main source "" axis basis)) + (hypothetical + (ebox--flex-clamp-main base min-main max-main))) + (list :min-main min-main + :max-main max-main + :hypothetical hypothetical + :base (max 0 base) + :target hypothetical))))) + +(defun ebox--flex-item-measurement (source props axis &optional viewport) + "Return flex item metrics for SOURCE and PROPS on AXIS. +Prefer an existing complete measurement, then metadata-only fixed-basis +metrics, and render intrinsically only when layout semantics require it." + (or (ebox--flex-cached-measurement source props axis) + (ebox--flex-unrendered-measurement source props axis) + (ebox--flex-measure-source source props axis viewport))) + +(defun ebox--flex-pad-width (string width &optional align) + "Pad every line of STRING to WIDTH pixels." + (let ((align (or align 'flex-start))) + (ebox--maplines + (lambda (line) + (let* ((line-width (ebox--string-pixel-width line)) + (extra (max 0 (- width line-width))) + (left (pcase align + ((or 'flex-end 'end 'right 'self-end) extra) + ('center (/ extra 2)) + (_ 0)))) + (concat (ebox-pixel-space left) + line + (ebox-pixel-space (- extra left))))) + string))) + +(defun ebox--flex-pad-height (string height offset width) + "Pad STRING vertically to HEIGHT using blank lines of WIDTH pixels." + (let* ((lines (ebox-string-lines string)) + (current (length lines)) + (extra (max 0 (- height current))) + (top (max 0 (min extra offset))) + (bottom (- extra top)) + (blank (ebox-pixel-space width))) + (ebox-lines-join + (append (make-list top blank) + lines + (make-list bottom blank))))) + +(defun ebox--flex-sized-entry (rendered main cross) + "Return a sized flex render entry for RENDERED." + (list :rendered rendered :main main :cross cross)) + +(defun ebox--flex-normalize-sized-entry (entry axis) + "Return ENTRY as a sized render entry for AXIS." + (if (and (listp entry) (plist-member entry :rendered)) + entry + (ebox--flex-sized-entry + entry + (ebox--flex-string-main entry axis) + (ebox--flex-string-cross entry axis)))) + +(defun ebox--flex-sized-entry-rendered (entry) + "Return rendered string from sized ENTRY." + (plist-get entry :rendered)) + +(defun ebox--flex-sized-entry-cross (entry) + "Return cross-axis size from sized ENTRY." + (plist-get entry :cross)) + +(defun ebox--flex-horizontal-sized-lines (rendered width height) + "Return RENDERED lines padded to HEIGHT using known WIDTH." + (let* ((lines (ebox-string-lines rendered)) + (current (length lines))) + (if (>= current height) + lines + (append lines + (make-list (- height current) + (ebox-pixel-space width)))))) + +(defun ebox--flex-lines-concat-horizontal-sized (parts &optional target-height) + "Concatenate PARTS horizontally using known part widths. +Each item in PARTS is a cons cell (RENDERED . WIDTH)." + (setq parts (cl-remove-if-not #'car parts)) + (when parts + (let* ((height + (max (or target-height 0) + (apply #'max + (mapcar (lambda (part) + (ebox-string-height (car part))) + parts)))) + (line-lists + (mapcar (lambda (part) + (ebox--flex-horizontal-sized-lines + (car part) (cdr part) height)) + parts))) + (ebox-lines-join + (apply #'cl-mapcar #'concat line-lists))))) + +(defun ebox--flex-cross-offset (align extra) + "Return cross-axis offset for ALIGN and EXTRA space." + (pcase align + ((or 'flex-end 'end 'self-end 'right 'bottom) extra) + ('center (/ extra 2)) + (_ 0))) + +(defun ebox--flex-resolved-align (item container-align) + "Return ITEM align-self resolved against CONTAINER-ALIGN." + (let ((align (plist-get (plist-get item :props) :align-self))) + (if (or (null align) (eq align 'auto)) + container-align + align))) + +(defun ebox--flex-pad-sized-entry-cross (entry axis main cross align) + "Return sized ENTRY padded to CROSS for AXIS and ALIGN." + (let ((rendered (ebox--flex-sized-entry-rendered entry)) + (entry-main (plist-get entry :main)) + (entry-cross (ebox--flex-sized-entry-cross entry))) + (if (eq axis 'row) + (let* ((rendered-width (ebox--flex-string-width rendered)) + (width (max (or main rendered-width) rendered-width)) + (extra (max 0 (- cross entry-cross))) + (offset (ebox--flex-cross-offset align extra))) + (ebox--flex-sized-entry + (ebox--flex-pad-height rendered cross offset width) + (or main entry-main) + cross)) + (ebox--flex-sized-entry + (ebox--flex-pad-width rendered cross align) + (or main entry-main) + cross)))) + +(defun ebox--flex-render-sized-entry (item axis main cross container-align) + "Render flex ITEM with MAIN and optional CROSS size. +Return a sized render entry containing the rendered string and dimensions." + (let* ((source (plist-get item :source)) + (source-box-p + (eq (and (listp source) + (plist-get source :ebox-type)) + 'box)) + (align (ebox--flex-resolved-align item container-align)) + (stretch (memq align '(stretch normal))) + (render-main (plist-get item :render-main)) + (render-cross (plist-get item :render-cross)) + (render-viewport (plist-get item :render-viewport)) + (item-viewport (ebox--flex-inline-viewport axis main cross)) + (can-use-measured + (and (plist-member item :rendered) + (or (null main) (= main render-main)) + (or (not (and cross stretch)) + (null cross) + (= cross render-cross)) + (ebox--flex-measured-render-context-compatible-p + source render-viewport item-viewport))) + (cache-key (list axis main cross align)) + (render-cache (plist-get item :render-cache)) + (cached (assoc cache-key render-cache)) + (natural-cached + (and cross + (assoc (list axis main nil align) render-cache))) + (natural-entry + (and natural-cached + (ebox--flex-normalize-sized-entry + (cdr natural-cached) axis))) + (natural-viewport + (and natural-entry + (ebox--flex-inline-viewport axis main nil))) + (natural-context-compatible-p + (and natural-entry + (ebox--flex-measured-render-context-compatible-p + source natural-viewport item-viewport))) + (local-entry + (or (and cached + (ebox--flex-normalize-sized-entry (cdr cached) axis)) + (and natural-context-compatible-p + (= cross (ebox--flex-sized-entry-cross natural-entry)) + (or (not stretch) + (not source-box-p)) + natural-entry))) + (persistent-signature + (and (not local-entry) + (or (and (ebox--render-cacheable-node-p source) + (ebox--flex-render-cache-signature source)) + (ebox--flex-scoped-sized-render-signature + source axis main cross align item-viewport)))) + (persistent-cache-key + (and persistent-signature + (list (ebox--ensure-node-id source) + 'flex-sized axis main cross align + (and (ebox--flex-composite-source-p source) + ebox--intrinsic-layout-measurement)))) + (persistent-cached + (and persistent-cache-key + (ebox--render-cache-lookup persistent-cache-key + persistent-signature)))) + (cond + (local-entry + (prog1 local-entry + (ebox--flex-recache-source-boxes source))) + (persistent-cached + (let ((entry + (ebox--flex-normalize-sized-entry persistent-cached axis))) + (plist-put item :render-cache + (cons (cons cache-key entry) + (plist-get item :render-cache))) + (prog1 entry + (ebox--flex-recache-source-boxes source)))) + ((and natural-context-compatible-p + (> cross (ebox--flex-sized-entry-cross natural-entry)) + (or (not stretch) + (not source-box-p))) + (let ((entry + (ebox--flex-pad-sized-entry-cross + natural-entry axis main cross align))) + (ebox--flex-recache-source-boxes source) + (plist-put item :render-cache + (cons (cons cache-key entry) + (plist-get item :render-cache))) + (when persistent-cache-key + (ebox--render-cache-store persistent-cache-key + persistent-signature + entry)) + entry)) + (t + (let* ((node (unless can-use-measured + (ebox--flex-copy-node-for-size + source axis main cross stretch))) + (rendered (if can-use-measured + (plist-get item :rendered) + (ebox--flex-bind-source-viewport + source item-viewport + (lambda () (ebox-render (or node source))))))) + (ebox--flex-recache-source-boxes source) + (when (and main + (not (and can-use-measured (= main render-main)))) + (if (eq axis 'row) + (setq rendered (ebox--flex-pad-width rendered main)) + (setq rendered + (ebox--flex-pad-height + rendered main 0 (ebox--flex-string-width rendered))))) + (when (and cross + (not (and can-use-measured (= cross render-cross)))) + (if (eq axis 'row) + (let* ((rendered-width + (if can-use-measured + render-main + (ebox--flex-string-width rendered))) + (width (max (or main rendered-width) rendered-width)) + (extra (max 0 (- cross (ebox-string-height rendered)))) + (offset (ebox--flex-cross-offset align extra))) + (setq rendered (ebox--flex-pad-height rendered cross offset width))) + (setq rendered (ebox--flex-pad-width rendered cross align)))) + (let ((entry + (ebox--flex-sized-entry + rendered + (if (eq axis 'row) + (or main (if can-use-measured + render-main + (ebox--flex-string-width rendered))) + (or main (ebox-string-height rendered))) + (if (eq axis 'row) + (or cross (if can-use-measured + render-cross + (ebox-string-height rendered))) + (or cross (if can-use-measured + render-cross + (ebox--flex-string-width rendered))))))) + (plist-put item :render-cache + (cons (cons cache-key entry) + (plist-get item :render-cache))) + (when persistent-cache-key + (ebox--render-cache-store persistent-cache-key + persistent-signature + entry)) + entry)))))) + +(defun ebox--flex-render-sized-item (item axis main cross container-align) + "Render flex ITEM with MAIN and optional CROSS size." + (ebox--flex-sized-entry-rendered + (ebox--flex-render-sized-entry item axis main cross container-align))) + +(defun ebox--flex-distribute (amount weights) + "Distribute integer AMOUNT across WEIGHTS, preserving total exactly." + (let* ((amount (max 0 (floor amount))) + (positive (cl-remove-if-not (lambda (weight) (> weight 0)) weights)) + (total (apply #'+ (or positive '(0))))) + (if (or (<= amount 0) (<= total 0)) + (make-list (length weights) 0) + (let* ((shares + (mapcar (lambda (weight) + (if (> weight 0) + (floor (/ (* amount (float weight)) total)) + 0)) + weights)) + (remaining (- amount (apply #'+ shares)))) + (while (> remaining 0) + (cl-loop for weight in weights + for cell on shares + while (> remaining 0) + when (> weight 0) + do (cl-incf (car cell)) + and do (cl-decf remaining))) + shares)))) + +(defun ebox--flex-sum (numbers) + "Return the sum of NUMBERS." + (apply #'+ (or numbers '(0)))) + +(defun ebox--flex-factor (item mode) + "Return ITEM's raw flex factor for MODE." + (plist-get (plist-get item :props) + (if (eq mode 'grow) :flex-grow :flex-shrink))) + +(defun ebox--flex-distribution-weight (item mode) + "Return ITEM's flex free-space distribution weight for MODE." + (if (eq mode 'grow) + (ebox--flex-factor item mode) + (* (plist-get item :base) + (ebox--flex-factor item mode)))) + +(defun ebox--flex-effective-free-space (initial-free free factor-total) + "Return FREE adjusted by the Flexbox partial-fill rule." + (if (and (> factor-total 0) (< factor-total 1)) + (let ((partial (* initial-free factor-total))) + (if (< (abs partial) (abs free)) + partial + free)) + free)) + +(defun ebox--flex-line-free-space (line targets frozen available) + "Return free space in LINE using flex bases for unfrozen items." + (- available + (cl-loop for item in line + for index from 0 + sum (if (aref frozen index) + (aref targets index) + (plist-get item :base))))) + +(defun ebox--flex-collect-items (children axis &optional viewport) + "Return sorted flex item metadata from CHILDREN for AXIS." + (let ((items + (cl-loop for child in children + for index from 0 + for source = (ebox--flex-item-source-node child) + for props = (ebox--flex-item-props child) + for measurement = (ebox--flex-item-measurement + source props axis viewport) + collect (append (list :source source + :props props + :index index) + measurement)))) + (sort items + (lambda (a b) + (let ((ao (plist-get (plist-get a :props) :order)) + (bo (plist-get (plist-get b :props) :order))) + (if (= ao bo) + (< (plist-get a :index) (plist-get b :index)) + (< ao bo))))))) + +(defun ebox--flex-default-order-p (children) + "Return non-nil when CHILDREN use source order without item ordering." + (cl-every (lambda (child) + (= (plist-get (ebox--flex-item-props child) :order) 0)) + children)) + +(defun ebox--flex-collect-items-for-source-ids + (children source-ids axis &optional viewport) + "Return flex item metadata for SOURCE-IDS only, preserving child order." + (let ((source-set (make-hash-table :test 'equal)) + items) + (dolist (source-id source-ids) + (puthash source-id t source-set)) + (cl-loop for child in children + for index from 0 + for source = (ebox--flex-item-source-node child) + for source-id = (plist-get source :node-id) + when (gethash source-id source-set) + do (let* ((props (ebox--flex-item-props child)) + (measurement (ebox--flex-item-measurement + source props axis viewport))) + (push (append (list :source source + :props props + :index index) + measurement) + items))) + (nreverse items))) + +(defun ebox--flex-item-source-ids (items) + "Return source node ids for flex ITEMS." + (mapcar (lambda (item) + (plist-get (plist-get item :source) :node-id)) + items)) + +(defun ebox--flex-break-lines (items main-limit main-gap wrap) + "Break ITEMS into flex lines using MAIN-LIMIT, MAIN-GAP, and WRAP." + (if (or (eq wrap 'nowrap) (null main-limit)) + (list items) + (let (lines current current-size) + (dolist (item items) + (let* ((base (plist-get item :hypothetical)) + (next-size (+ (or current-size 0) + (if current main-gap 0) + base))) + (if (and current (> next-size main-limit)) + (progn + (push (nreverse current) lines) + (setq current (list item) + current-size base)) + (push item current) + (setq current-size next-size)))) + (when current + (push (nreverse current) lines)) + (nreverse lines)))) + +(defun ebox--flex-size-line (line main-limit main-gap) + "Return LINE items with computed target main sizes." + (let* ((count (length line)) + (gap-total (* main-gap (max 0 (1- count)))) + (available (and main-limit (- main-limit gap-total))) + (targets (vconcat (mapcar (lambda (item) + (plist-get item :hypothetical)) + line)))) + (when available + (let* ((hypothetical-total + (ebox--flex-sum (mapcar (lambda (item) + (plist-get item :hypothetical)) + line))) + (mode (if (< hypothetical-total available) 'grow 'shrink)) + (frozen (make-vector count nil))) + (cl-loop for item in line + for index from 0 + for base = (plist-get item :base) + for hypothetical = (plist-get item :hypothetical) + for factor = (ebox--flex-factor item mode) + do (aset targets index base) + when (or (<= (or factor 0) 0) + (and (eq mode 'grow) (> base hypothetical)) + (and (eq mode 'shrink) (< base hypothetical))) + do (progn + (aset targets index hypothetical) + (aset frozen index t))) + (let ((initial-free + (ebox--flex-line-free-space line targets frozen available))) + (let ((done nil)) + (while (not done) + (let* ((free (ebox--flex-line-free-space + line targets frozen available)) + (active-indices + (cl-loop for index below count + unless (aref frozen index) + collect index)) + (weights + (mapcar + (lambda (index) + (ebox--flex-distribution-weight (nth index line) mode)) + active-indices)) + (weight-total (ebox--flex-sum weights)) + (factor-total + (ebox--flex-sum + (mapcar (lambda (index) + (ebox--flex-factor (nth index line) mode)) + active-indices))) + (effective-free + (ebox--flex-effective-free-space + initial-free free factor-total))) + (cond + ((or (null active-indices) + (<= weight-total 0) + (and (eq mode 'grow) (< effective-free 0)) + (and (eq mode 'shrink) (> effective-free 0))) + (setq done t)) + (t + (let ((deltas (ebox--flex-distribute + (abs effective-free) weights)) + min-violations + max-violations + (total-violation 0)) + (cl-mapc + (lambda (index delta) + (let* ((item (nth index line)) + (base (plist-get item :base)) + (candidate (if (eq mode 'grow) + (+ base delta) + (- base delta))) + (clamped (ebox--flex-clamp-main + candidate + (plist-get item :min-main) + (plist-get item :max-main))) + (adjustment (- clamped candidate))) + (aset targets index clamped) + (cond + ((> adjustment 0) + (push index min-violations)) + ((< adjustment 0) + (push index max-violations))) + (cl-incf total-violation adjustment))) + active-indices deltas) + (cond + ((= total-violation 0) + (dolist (index active-indices) + (aset frozen index t))) + ((> total-violation 0) + (dolist (index (or min-violations active-indices)) + (aset frozen index t))) + (t + (dolist (index (or max-violations active-indices)) + (aset frozen index t))))))))))))) + (cl-loop for item in line + for index from 0 + collect (let ((copy (copy-sequence item))) + (plist-put copy :target (aref targets index)) + copy)))) + +(defun ebox--flex-line-main-size (line main-gap) + "Return total main size for LINE." + (+ (apply #'+ (mapcar (lambda (item) + (plist-get item :target)) + line)) + (* main-gap (max 0 (1- (length line)))))) + +(defun ebox--flex-spacing (mode leftover count base-gap) + "Return (LEADING BETWEEN TRAILING) spacing for MODE." + (setq leftover (max 0 leftover)) + (pcase mode + ((or 'flex-end 'end 'right 'bottom) + (list leftover base-gap 0)) + ('center + (let ((lead (/ leftover 2))) + (list lead base-gap (- leftover lead)))) + ('space-between + (if (> count 1) + (list 0 (+ base-gap (/ leftover (1- count))) 0) + (list 0 base-gap leftover))) + ('space-around + (let* ((unit (if (> count 0) (/ leftover count) 0)) + (lead (/ unit 2))) + (list lead (+ base-gap unit) (- leftover lead (* unit (max 0 (1- count))))))) + ('space-evenly + (let ((unit (if (> count 0) (/ leftover (1+ count)) 0))) + (list unit (+ base-gap unit) unit))) + (_ (list 0 base-gap leftover)))) + +(defun ebox--flex-line-cross (line axis container-align) + "Return natural cross size for LINE." + (let ((sizes + (mapcar + (lambda (item) + (ebox--flex-sized-entry-cross + (ebox--flex-render-sized-entry + item axis (plist-get item :target) nil container-align))) + line))) + (max 1 (apply #'max (or sizes '(1)))))) + +(defun ebox--flex-layout-cross (crosses container-cross cross-gap align single-line) + "Return plist describing cross-axis line placement." + (let* ((count (length crosses)) + (natural (+ (apply #'+ crosses) + (* cross-gap (max 0 (1- count))))) + (leftover (if container-cross (- container-cross natural) 0))) + (cond + ((and container-cross single-line (= count 1)) + (list :crosses (list container-cross) + :leading 0 :between cross-gap :trailing 0)) + ((and container-cross (> leftover 0) (memq align '(stretch normal))) + (let ((extra (ebox--flex-distribute leftover (make-list count 1)))) + (list :crosses (cl-mapcar #'+ crosses extra) + :leading 0 :between cross-gap :trailing 0))) + (t + (let ((spacing (ebox--flex-spacing align leftover count cross-gap))) + (list :crosses crosses + :leading (nth 0 spacing) + :between (nth 1 spacing) + :trailing (nth 2 spacing))))))) + +(defun ebox--flex-render-row-line (line line-cross main-size main-gap justify align) + "Render one row-axis flex LINE." + (let* ((line-main (ebox--flex-line-main-size line main-gap)) + (target-main (or main-size line-main)) + (leftover (- target-main line-main)) + (spacing (ebox--flex-spacing justify leftover (length line) main-gap)) + (leading (nth 0 spacing)) + (between (nth 1 spacing)) + (trailing (nth 2 spacing)) + (parts (list (cons (ebox-pixel-space leading) leading)))) + (cl-loop for item in line + for tail on line + for main = (plist-get item :target) + for entry = (ebox--flex-render-sized-entry + item 'row main line-cross align) + do (push (cons (ebox--flex-sized-entry-rendered entry) + main) + parts) + unless (null (cdr tail)) + do (push (cons (ebox-pixel-space between) between) parts)) + (when (> trailing 0) + (push (cons (ebox-pixel-space trailing) trailing) parts)) + (ebox--flex-lines-concat-horizontal-sized (nreverse parts) line-cross))) + +(defun ebox--flex-render-column-line (line line-cross main-size main-gap justify align) + "Render one column-axis flex LINE." + (let* ((line-main (ebox--flex-line-main-size line main-gap)) + (target-main (or main-size line-main)) + (leftover (- target-main line-main)) + (spacing (ebox--flex-spacing justify leftover (length line) main-gap)) + (leading (nth 0 spacing)) + (between (nth 1 spacing)) + pieces) + (when (> leading 0) + (push (ebox--pixel-blank line-cross leading) pieces)) + (cl-loop for item in line + for tail on line + do (push (ebox--flex-render-sized-item + item 'column (plist-get item :target) line-cross align) + pieces) + unless (null (cdr tail)) + do (when (> between 0) + (push (ebox--pixel-blank line-cross between) pieces))) + (let ((result (apply #'ebox--lines-stack-vertical (nreverse pieces)))) + (ebox--flex-pad-height result target-main 0 line-cross)))) + +(defun ebox--render-flex-row (lines props main-size cross-size main-gap cross-gap) + "Render flex LINES for a row-axis container." + (let* ((align (plist-get props :align-items)) + (justify (plist-get props :justify-content)) + (align-content (plist-get props :align-content)) + (single-line (eq (plist-get props :flex-wrap) 'nowrap)) + (sized (mapcar (lambda (line) + (ebox--flex-size-line line main-size main-gap)) + lines)) + (line-mains (mapcar (lambda (line) + (ebox--flex-line-main-size line main-gap)) + sized)) + (container-main (or main-size (apply #'max (or line-mains '(0))))) + (crosses (if (and cross-size single-line) + (list cross-size) + (mapcar (lambda (line) + (ebox--flex-line-cross line 'row align)) + sized))) + (cross-layout (ebox--flex-layout-cross + crosses cross-size cross-gap align-content + single-line)) + (adjusted-crosses (plist-get cross-layout :crosses)) + (leading (plist-get cross-layout :leading)) + (between (plist-get cross-layout :between)) + (rendered-lines + (cl-mapcar (lambda (line cross) + (ebox--flex-render-row-line + line cross container-main main-gap justify align)) + sized adjusted-crosses)) + pieces) + (when (> leading 0) + (push (ebox--pixel-blank container-main leading) pieces)) + (cl-loop for rendered in rendered-lines + for tail on rendered-lines + do (push rendered pieces) + unless (null (cdr tail)) + do (when (> between 0) + (push (ebox--pixel-blank container-main between) pieces))) + (let ((result (apply #'ebox--lines-stack-vertical (nreverse pieces)))) + (if cross-size + (ebox--flex-pad-height result cross-size 0 container-main) + result)))) + +(defun ebox--render-flex-column (lines props main-size cross-size main-gap cross-gap) + "Render flex LINES for a column-axis container." + (let* ((align (plist-get props :align-items)) + (justify (plist-get props :justify-content)) + (align-content (plist-get props :align-content)) + (single-line (eq (plist-get props :flex-wrap) 'nowrap)) + (sized (mapcar (lambda (line) + (ebox--flex-size-line line main-size main-gap)) + lines)) + (crosses (if (and cross-size single-line) + (list cross-size) + (mapcar (lambda (line) + (ebox--flex-line-cross line 'column align)) + sized))) + (cross-layout (ebox--flex-layout-cross + crosses cross-size cross-gap align-content + single-line)) + (adjusted-crosses (plist-get cross-layout :crosses)) + (leading (plist-get cross-layout :leading)) + (between (plist-get cross-layout :between)) + (rendered-columns + (cl-mapcar (lambda (line cross) + (ebox--flex-render-column-line + line cross main-size main-gap justify align)) + sized adjusted-crosses)) + (parts (list (ebox-pixel-space leading)))) + (cl-loop for rendered in rendered-columns + for tail on rendered-columns + do (push rendered parts) + unless (null (cdr tail)) + do (push (ebox-pixel-space between) parts)) + (let ((result (apply #'ebox--lines-concat-horizontal (nreverse parts)))) + (if cross-size + (ebox--flex-pad-width result cross-size) + result)))) + +;;;###autoload +(defun ebox-flex-item (node &rest plist) + "Wrap NODE with flex item metadata and optional box properties from PLIST." + (let ((item-props (ebox--plist-keep-keys plist ebox--flex-item-prop-keys)) + (box-props (ebox--plist-remove-keys plist ebox--flex-item-prop-keys))) + (list :ebox-type 'flex-item + :display '(block flow) + :node (ebox--flex-wrap-node-with-box-props + 'ebox-flex-item node box-props) + :props item-props))) + +;;;###autoload +(defun ebox-flex (&rest items) + "Return a lazy flex layout node from ITEMS. + +Container properties include flex layout properties +\(:flex-direction, :flex-wrap, :flex-flow, :justify-content, :align-items, +:align-content, :gap, :row-gap, :column-gap) plus normal `ebox-create' +box properties. When visual box properties are present, :width and :height +size the outer box and the inner flex layout uses its content area. + +Item properties can be stored with `ebox-flex-item' or directly on a +child box: :order, :flex-grow, :flex-shrink, :flex-basis, :flex, and +:align-self. `ebox-flex-item' can also carry normal box properties around +its child." + (let* ((split (ebox--build-split-attrs items)) + (raw-props (car split)) + (box-props (ebox--plist-remove-keys + raw-props ebox--flex-container-layout-prop-keys)) + (wrapper-box (ebox--flex-make-wrapper-box box-props)) + (props (ebox--flex-normalize-container-props raw-props)) + (children (delq nil (cdr split)))) + (list :ebox-type 'flex + :display '(block flex) + :props props + :raw-props raw-props + :box wrapper-box + :children children))) + +(defun ebox--render-flex-box (node rendered) + "Render flex NODE's container box around RENDERED, when present." + (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-flex (node) + "Render flex NODE to a propertized string." + (let* ((props (ebox--flex-container-content-props + (plist-get node :props) + (plist-get node :raw-props) + (plist-get node :box))) + (axis (ebox--flex-axis props)) + (children (plist-get node :children)) + (main-size (if (eq axis 'row) + (ebox--flex-horizontal-value (plist-get props :width)) + (ebox--flex-line-value (plist-get props :height)))) + (cross-size (if (eq axis 'row) + (ebox--flex-line-value (plist-get props :height)) + (ebox--flex-horizontal-value (plist-get props :width)))) + (inline-viewport (ebox--flex-inline-viewport + axis main-size cross-size)) + (items (ebox--flex-collect-items children axis inline-viewport)) + (items (if (ebox--flex-reverse-p props) + (nreverse items) + items)) + (row-gap (or (ebox--flex-line-value (plist-get props :row-gap) 0) 0)) + (column-gap (or (ebox--flex-horizontal-value + (plist-get props :column-gap) 0) + 0)) + (main-gap (if (eq axis 'row) column-gap row-gap)) + (cross-gap (if (eq axis 'row) row-gap column-gap)) + (lines (ebox--flex-break-lines + items main-size main-gap (plist-get props :flex-wrap)))) + (when (eq (plist-get props :flex-wrap) 'wrap-reverse) + (setq lines (nreverse lines))) + (ebox--render-flex-box + node + (if (eq axis 'row) + (ebox--render-flex-row lines props main-size cross-size + main-gap cross-gap) + (ebox--render-flex-column lines props main-size cross-size + main-gap cross-gap))))) + +(defun ebox--flex-prewarm-sized-item (node _target-width) + "Prepare one fixed-cross flex item phase and yield to the event loop. +Intrinsic measurement and final sized rendering each process one child per +slice. The prefix-local state retains geometry between slices, while sized +renders enter the ordinary persistent cache so the final full flex render only +assembles already-rendered items." + (let ((entry (gethash node ebox--scroll-window-prewarm-state))) + (unless entry + (let* ((props (ebox--flex-container-content-props + (plist-get node :props) + (plist-get node :raw-props) + (plist-get node :box))) + (axis (ebox--flex-axis props)) + (children (plist-get node :children)) + (main-size (ebox--flex-main-value + axis (plist-get props + (if (eq axis 'row) + :width :height)))) + (cross-size (ebox--flex-cross-value + axis (plist-get props + (if (eq axis 'row) + :height :width)))) + (wrap (plist-get props :flex-wrap))) + (when (and main-size cross-size + (eq wrap 'nowrap) + (> (length children) 1) + (not (ebox--flex-reverse-p props)) + (ebox--flex-default-order-p children)) + (let* ((row-gap (or (ebox--flex-line-value + (plist-get props :row-gap) 0) 0)) + (column-gap (or (ebox--flex-horizontal-value + (plist-get props :column-gap) 0) 0))) + (setq entry + (list :children children + :measure-index 0 + :measured nil + :items nil + :render-index 0 + :axis axis + :main-size main-size + :main-gap (if (eq axis 'row) column-gap row-gap) + :cross-size cross-size + :align (plist-get props :align-items) + :viewport + (ebox--flex-inline-viewport + axis main-size cross-size))) + (puthash node entry ebox--scroll-window-prewarm-state))))) + (when entry + (let* ((children (plist-get entry :children)) + (measure-index (plist-get entry :measure-index))) + (cond + ((< measure-index (length children)) + (let* ((child (nth measure-index children)) + (source (ebox--flex-item-source-node child)) + (props (ebox--flex-item-props child)) + (measurement + (ebox--flex-item-measurement + source props (plist-get entry :axis) + (plist-get entry :viewport)))) + (plist-put entry :measured + (cons (append (list :source source + :props props + :index measure-index) + measurement) + (plist-get entry :measured))) + (plist-put entry :measure-index (1+ measure-index)) + t)) + (t + (unless (plist-get entry :items) + (plist-put entry :items + (ebox--flex-size-line + (nreverse (plist-get entry :measured)) + (plist-get entry :main-size) + (plist-get entry :main-gap)))) + (let* ((items (plist-get entry :items)) + (render-index (plist-get entry :render-index))) + (when (< render-index (length items)) + (let ((item (nth render-index items))) + (ebox--flex-render-sized-entry + item (plist-get entry :axis) (plist-get item :target) + (plist-get entry :cross-size) (plist-get entry :align))) + (plist-put entry :render-index (1+ render-index)) + t)))))))) + +(defun ebox--flex-window-wrapper-supported-p (box) + "Return non-nil when BOX can safely wrap an incomplete flex prefix." + (or (null box) + (and (null (ebox-get box :height)) + (or (null (ebox-get box :min-height)) + (equal (ebox-get box :min-height) 0)) + (null (ebox-get box :max-height)) + (null (ebox-get box :wrap-mode)) + (eq (ebox-get box :text-align) 'left) + (eq (ebox-get box :vertical-align) 'top)))) + +(defun ebox--flex-window-wrapper-copy (box complete) + "Return BOX copy adjusted for a flex prefix. +When COMPLETE is nil, trailing vertical chrome is suppressed so the result is +a prefix of the eventual full wrapper render." + (let ((copy (copy-sequence box))) + (unless complete + (plist-put copy :padding-bottom-height 0) + (plist-put copy :margin-bottom-height 0) + (plist-put copy :border-bottom-p nil)) + copy)) + +(defun ebox--flex-window-complete-prefix-supported-p (box) + "Return non-nil when BOX completion can patch an existing prefix." + (or (null box) + (and (= (floor (ebox-get box :padding-bottom-height)) 0) + (= (floor (ebox-get box :margin-bottom-height)) 0)))) + +(defun ebox--flex-window-complete-tail-line (box line) + "Return LINE with BOX's completion-only bottom border applied." + (if (not box) + line + (let ((region-id (ebox--ensure-region-id box))) + (puthash region-id box ebox--region-box-table) + (if (ebox-get box :border-bottom-p) + (propertize + (ebox--propertize-underline + line (ebox-get box :border-bottom-color)) + 'ebox-bb region-id) + line)))) + +(defun ebox--flex-window-complete-prefix-lines (box lines &optional line-tail) + "Return complete wrapper LINES by adding bottom border chrome for BOX." + (if (not box) + lines + (progn + (when lines + (let ((tail (or line-tail (last lines)))) + (setcar tail + (ebox--flex-window-complete-tail-line + box (car tail))))) + lines))) + +(defun ebox--flex-window-render-box-lines (node content complete limit) + "Return NODE flex wrapper lines around CONTENT up to LIMIT." + (let* ((rendered + (if-let ((box (plist-get node :box))) + (let ((copy (ebox--flex-window-wrapper-copy box complete)) + (region-id (ebox--ensure-region-id box))) + (plist-put copy :region-id region-id) + (plist-put copy :content content) + (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))) + content)) + (lines (ebox-string-lines rendered))) + (seq-take lines limit))) + +(defun ebox--flex-window-render-wrapper-chunk-lines + (box content-lines start-index include-top) + "Render BOX wrapper around newly appended CONTENT-LINES. +START-INDEX is the first content-line index inside BOX. INCLUDE-TOP means +the chunk begins at the wrapper top and should include top chrome." + (if (not box) + content-lines + (let* ((region-id (ebox--ensure-region-id box)) + (content-pixel (ebox--content-pixel box)) + (padding-line-filler (ebox-pixel-space content-pixel)) + (padding-left (ebox-get box :padding-left-pixel)) + (padding-right (ebox-get box :padding-right-pixel)) + (padding-top (if include-top + (floor (ebox-get box :padding-top-height)) + 0)) + (margin-left (ebox-get box :margin-left-pixel)) + (margin-right (ebox-get box :margin-right-pixel)) + (margin-top (if include-top + (floor (ebox-get box :margin-top-height)) + 0)) + (border-left-pixel (ebox-get box :border-left-pixel)) + (border-left-color (ebox-get box :border-left-color)) + (border-right-pixel (ebox-get box :border-right-pixel)) + (border-right-color (ebox-get box :border-right-color)) + (border-top-p (and include-top + (ebox-get box :border-top-p))) + (border-top-color (ebox-get box :border-top-color)) + (color (ebox-get box :color)) + (bgcolor (ebox-get box :bgcolor)) + (inner-height (+ (length content-lines) padding-top)) + result) + (puthash region-id box ebox--region-box-table) + (if (and (= margin-left 0) + (= margin-right 0) + (= margin-top 0)) + (ebox--window-render-flat-wrapper-chunk-lines + box content-lines start-index region-id padding-line-filler + include-top) + (progn + (setq result + (ebox-lines-join + (cl-loop for line in content-lines + for idx from start-index + collect (ebox--propertize-content-line + line region-id idx padding-line-filler)))) + (setq result + (ebox--maplines + (lambda (line) + (if (and (string-empty-p (string-trim line)) + (not (ebox--line-has-non-content-properties-p line))) + (let ((content-id + (get-text-property 0 'ebox-content line)) + (content-idx + (get-text-property 0 'ebox-content-idx line))) + (if content-id + (propertize padding-line-filler + 'ebox-content content-id + 'ebox-content-idx content-idx + 'ebox-content-owner region-id) + (propertize padding-line-filler + 'ebox-content region-id + 'ebox-content-idx 0 + 'ebox-content-owner region-id))) + line)) + result)) + (when (> padding-top 0) + (setq result + (ebox--lines-stack-vertical + (ebox--propertize-region + (ebox--pixel-blank content-pixel padding-top) + 'ebox-pt region-id) + result))) + (let ((pl-str (when (> padding-left 0) + (ebox--propertize-region + (ebox--pixel-blank padding-left inner-height) + 'ebox-pl region-id))) + (pr-str (when (> padding-right 0) + (ebox--propertize-region + (ebox--pixel-blank padding-right inner-height) + 'ebox-pr region-id)))) + (when (or pl-str pr-str) + (setq result + (ebox--lines-concat-horizontal pl-str result pr-str)))) + (when (or color bgcolor) + (setq result + (ebox--maplines + (lambda (line) + (ebox--propertize-colors line color bgcolor)) + result))) + (when (or (> border-left-pixel 0) (> border-right-pixel 0)) + (let ((bl-str (when (> border-left-pixel 0) + (ebox--propertize-region + (ebox--pixel-border border-left-pixel + inner-height border-left-color) + 'ebox-bl region-id))) + (br-str (when (> border-right-pixel 0) + (ebox--propertize-region + (ebox--pixel-border border-right-pixel + inner-height border-right-color) + 'ebox-br region-id)))) + (setq result + (ebox--lines-concat-horizontal bl-str result br-str)))) + (when border-top-p + (let ((lines (ebox-string-lines result))) + (setf (car lines) + (propertize + (ebox--propertize-overline (car lines) border-top-color) + 'ebox-bt region-id)) + (setq result (ebox-lines-join lines)))) + (ebox--apply-surface-properties + result (ebox-get box :surface-properties)) + (when (or (> margin-left 0) (> margin-right 0)) + (let* ((box-height (ebox-string-height result)) + (ml-str (when (> margin-left 0) + (ebox--propertize-region + (ebox--pixel-blank margin-left box-height) + 'ebox-ml region-id))) + (mr-str (when (> margin-right 0) + (ebox--propertize-region + (ebox--pixel-blank margin-right box-height) + 'ebox-mr region-id)))) + (setq result + (ebox--lines-concat-horizontal ml-str result mr-str)))) + (when (> margin-top 0) + (setq result + (ebox--lines-stack-vertical + (ebox--propertize-region + (ebox--pixel-blank (ebox--total-pixel box) margin-top) + 'ebox-mt region-id) + result))) + (ebox-string-lines result)))))) + +(defun ebox--flex-window-definite-main (source axis) + "Return SOURCE's definite main size for prefix flex rendering." + (when (and (eq axis 'row) + (listp source) + (eq (plist-get source :ebox-type) 'box) + (plist-member source :width)) + (ebox--flex-box-main-constraint source axis :width))) + +(defun ebox--flex-window-cheap-item (child axis index) + "Return cheap flex item metadata for CHILD, or nil when unsupported." + (let* ((source (ebox--flex-item-source-node child)) + (props (ebox--flex-item-props child)) + (basis (plist-get props :flex-basis)) + (main (ebox--flex-window-definite-main source axis)) + (measurement + (and (null main) + (ebox--flex-cached-measurement source props axis))) + (main (or main (plist-get measurement :hypothetical)))) + (when (and source + main + (= (or (plist-get props :order) 0) 0) + (= (or (plist-get props :flex-grow) 0) 0) + (eq basis 'auto)) + (if measurement + (append (list :source source :props props :index index) + measurement) + ;; The frozen definite width is only legal while its line does + ;; not overflow: a wrapping box with nonzero flex-shrink would + ;; compress in the full renderer, so mark it for the line + ;; assembler's overflow check. + (list :source source + :props props + :index index + :min-main main + :max-main main + :hypothetical main + :base main + :target main + :window-frozen-shrinkable + (and (ebox-get source :wrap-mode) + (not (zerop (or (plist-get props :flex-shrink) 1))) + t)))))) + +(defun ebox--flex-window-supported-p + (node props axis main-size cross-size children) + "Return non-nil when NODE can use prefix flex rendering." + (and (eq axis 'row) + main-size + (null cross-size) + (memq (plist-get props :flex-wrap) '(wrap nowrap)) + (not (ebox--flex-reverse-p props)) + (ebox--flex-default-order-p children) + (ebox--flex-window-wrapper-supported-p (plist-get node :box)))) + +(defun ebox--flex-window-lines-from-pieces (pieces) + "Return flex content string from already rendered row PIECES." + (if pieces + (apply #'ebox--lines-stack-vertical (reverse pieces)) + "")) + +(defun ebox--flex-window-top-chrome-lines (box) + "Return wrapper top chrome line count for partial flex rendering." + (if box + (+ (floor (ebox-get box :margin-top-height)) + (floor (ebox-get box :padding-top-height))) + 0)) + +(defun ebox--flex-window-prefix-line-count (node pieces) + "Return visible line count for NODE prefix PIECES without wrapper rerender." + (+ (ebox--flex-window-top-chrome-lines (plist-get node :box)) + (if pieces + (ebox-string-height + (ebox--flex-window-lines-from-pieces pieces)) + 0))) + +(defun ebox--flex-window-cacheable-p (node) + "Return non-nil when NODE flex window output can use render cache." + (and ebox--render-cache-table + (ebox--render-cacheable-node-p node))) + +(defun ebox--flex-window-cache-context (node) + "Return render cache context for NODE's flex window output." + (when (ebox--flex-window-cacheable-p node) + (list :node-id (ebox--ensure-node-id node) + :signature (ebox--flex-render-cache-signature node)))) + +(defun ebox--flex-window-cache-lookup (node limit &optional cache-context) + "Return cached flex window render for NODE and LIMIT, or nil." + (when-let ((cache-context + (or cache-context (ebox--flex-window-cache-context node)))) + (let* ((node-id (plist-get cache-context :node-id)) + (signature (plist-get cache-context :signature)) + (complete-key (list node-id 'flex-window 'complete)) + (complete (ebox--render-cache-lookup complete-key signature))) + (or (when complete + (let* ((lines (plist-get complete :lines)) + (line-count (or (plist-get complete :line-count) + (length lines))) + (result-lines (if (<= line-count limit) + lines + (seq-take lines limit))) + (result-count (min line-count limit))) + (list :lines result-lines + :line-count result-count + :line-tail (if (= result-count line-count) + (or (plist-get complete :line-tail) + (last lines)) + (last result-lines)) + :complete (<= line-count limit) + :completion-prefix-tail-rewrite-count + (plist-get + complete :completion-prefix-tail-rewrite-count)))) + (when-let ((prefix (ebox--render-cache-lookup + (list node-id 'flex-window 'prefix) + signature))) + (let* ((lines (plist-get prefix :lines)) + (line-count (or (plist-get prefix :line-count) + (length lines)))) + (when (>= line-count limit) + (let ((result-lines + (if (= line-count limit) + lines + (seq-take lines limit)))) + (list :lines result-lines + :line-count limit + :line-tail (if (= line-count limit) + (or (plist-get prefix :line-tail) + (last lines)) + (last result-lines)) + :complete nil))))) + (when-let ((exact + (ebox--render-cache-lookup + (list node-id 'flex-window limit) + signature))) + (let* ((lines (plist-get exact :lines)) + (line-count (or (plist-get exact :line-count) + (length lines)))) + ;; An incomplete result that did not fill LIMIT must resume + ;; from the prefix seed instead of becoming a permanent + ;; no-progress exact-limit hit. + (when (or (plist-get exact :complete) + (>= line-count limit)) + exact))))))) + +(defun ebox--flex-window-prefix-cache-lookup (node &optional cache-context) + "Return NODE's cached incomplete flex prefix state, or nil." + (when-let ((cache-context + (or cache-context (ebox--flex-window-cache-context node)))) + (let* ((node-id (plist-get cache-context :node-id)) + (signature (plist-get cache-context :signature))) + (ebox--render-cache-lookup + (list node-id 'flex-window 'prefix) + signature)))) + +(defun ebox--flex-window-prefix-cache-store + (node pieces next-index result &optional cache-context) + "Store NODE incomplete flex prefix PIECES through NEXT-INDEX." + (when-let ((cache-context + (and result + (or cache-context + (ebox--flex-window-cache-context node))))) + (let* ((node-id (plist-get cache-context :node-id)) + (signature (plist-get cache-context :signature))) + (ebox--render-cache-store + (list node-id 'flex-window 'prefix) + signature + (list :pieces pieces + :piece-count (length pieces) + :next-index next-index + :lines (plist-get result :all-lines) + :line-count (plist-get result :all-line-count) + :line-tail (plist-get result :all-line-tail) + :content-lines (plist-get result :content-lines) + :content-line-count + (plist-get result :content-line-count) + :content-line-tail + (plist-get result :content-line-tail) + :prefix-line-count + (plist-get result :all-line-count))))) + result) + +(defun ebox--flex-window-cache-store + (node limit result &optional cache-context) + "Store NODE flex window RESULT for LIMIT when possible." + (when-let ((cache-context + (and result + (or cache-context + (ebox--flex-window-cache-context node))))) + (let* ((node-id (plist-get cache-context :node-id)) + (signature (plist-get cache-context :signature)) + (key (if (plist-get result :source-complete) + (list node-id 'flex-window 'complete) + (list node-id 'flex-window limit)))) + (ebox--render-cache-store + key signature + (list :lines (if (plist-get result :source-complete) + (plist-get result :all-lines) + (plist-get result :lines)) + :line-count (if (plist-get result :source-complete) + (plist-get result :all-line-count) + (plist-get result :line-count)) + :line-tail (if (plist-get result :source-complete) + (plist-get result :all-line-tail) + (plist-get result :line-tail)) + :complete (plist-get result :source-complete) + :completion-prefix-tail-rewrite-count + (plist-get result + :completion-prefix-tail-rewrite-count))))) + result) + +(defun ebox--flex-window-completion-prefix-tail-rewrite-count (node) + "Return how many cached prefix tail lines completion rewrites for NODE." + (let ((box (plist-get node :box))) + (if (and box + (ebox-get box :border-bottom-p) + (= (floor (ebox-get box :padding-bottom-height)) 0)) + 1 + 0))) + +(defun ebox--flex-window-result (node pieces source-complete limit + &optional seed) + "Return flex window result for NODE prefix PIECES." + (let* ((cached-lines (plist-get seed :lines)) + (cached-line-count + (or (plist-get seed :line-count) + (length cached-lines))) + (cached-line-tail + (or (plist-get seed :line-tail) + (last cached-lines))) + (cached-content-lines (plist-get seed :content-lines)) + (cached-content-count + (or (plist-get seed :content-line-count) + (length cached-content-lines))) + (cached-content-tail + (or (plist-get seed :content-line-tail) + (last cached-content-lines))) + (cached-piece-count + (or (plist-get seed :piece-count) + (length (plist-get seed :pieces)))) + (piece-count (length pieces)) + (new-piece-count (- piece-count cached-piece-count)) + (can-append + (and cached-lines + cached-content-lines + (>= new-piece-count 0))) + (seedless-chunk-p + (and (null seed) + (> piece-count 0))) + (chunk-wrapper-p + (or can-append seedless-chunk-p)) + (new-content-lines + (when (and chunk-wrapper-p (> new-piece-count 0)) + (ebox-string-lines + (ebox--flex-window-lines-from-pieces + (seq-take pieces new-piece-count))))) + (content + (unless chunk-wrapper-p + (ebox--flex-window-lines-from-pieces pieces))) + (prospective-content-count + (if chunk-wrapper-p + (+ cached-content-count (length new-content-lines)) + (length (ebox-string-lines content)))) + (prefix-line-count + (+ (ebox--flex-window-top-chrome-lines (plist-get node :box)) + prospective-content-count)) + (source-complete-visible-p + (and source-complete + (> prefix-line-count limit))) + (effective-source-complete + (and source-complete + (not source-complete-visible-p))) + (wrapper-lines + (and chunk-wrapper-p new-content-lines + (ebox--flex-window-render-wrapper-chunk-lines + (plist-get node :box) + new-content-lines cached-content-count seedless-chunk-p))) + (prospective-line-count + (+ cached-line-count (length wrapper-lines))) + (completion-prefix-supported-p + (and chunk-wrapper-p effective-source-complete + (ebox--flex-window-complete-prefix-supported-p + (plist-get node :box)))) + (exact-append-p + (or completion-prefix-supported-p + (and chunk-wrapper-p (not effective-source-complete)))) + (fallback-content + (unless exact-append-p + (or content (ebox--flex-window-lines-from-pieces pieces)))) + (fallback-content-lines + (and fallback-content (ebox-string-lines fallback-content))) + (fallback-lines + (unless exact-append-p + (ebox--flex-window-render-box-lines + node fallback-content effective-source-complete + most-positive-fixnum))) + (prospective-tail + (or (last wrapper-lines) cached-line-tail)) + (completed-tail-line + (and completion-prefix-supported-p prospective-tail + (ebox--flex-window-complete-tail-line + (plist-get node :box) (car prospective-tail)))) + content-lines + content-line-count + content-line-tail + all-lines + all-line-count + all-line-tail + appended-lines) + ;; Wrapper rendering, full fallback rendering, and completion face + ;; construction above can signal. Mutate cached prefixes only after all + ;; of them have succeeded so cache count/tail metadata remains atomic. + (if exact-append-p + (let ((inhibit-quit t)) + (pcase-let ((`(,new-lines ,new-tail) + (ebox--line-list-append! + cached-content-lines cached-content-tail + new-content-lines))) + (setq content-lines new-lines + content-line-tail new-tail + content-line-count prospective-content-count)) + (pcase-let ((`(,new-lines ,new-tail) + (ebox--line-list-append! + cached-lines cached-line-tail wrapper-lines))) + (setq all-lines new-lines + all-line-tail new-tail + all-line-count prospective-line-count + appended-lines wrapper-lines)) + (when completed-tail-line + (setcar all-line-tail completed-tail-line))) + (setq content-lines fallback-content-lines + content-line-count (length fallback-content-lines) + content-line-tail (last fallback-content-lines) + all-lines fallback-lines + all-line-count (length fallback-lines) + all-line-tail (last fallback-lines) + appended-lines nil)) + (let* ((result-lines (if (<= all-line-count limit) + all-lines + (seq-take all-lines limit))) + (result-count (min all-line-count limit)) + (result-tail (if (= result-count all-line-count) + all-line-tail + (last result-lines))) + (rewrite-count + (if (and completion-prefix-supported-p + (= result-count cached-line-count)) + (ebox--flex-window-completion-prefix-tail-rewrite-count + node) + 0)) + (rewrite-range + (and (> rewrite-count 0) + (cons (- cached-line-count rewrite-count) + cached-line-count))) + (event-append-count (- result-count cached-line-count)) + (event-appended-lines + (and (>= event-append-count 0) + (eq result-lines all-lines) + (seq-take appended-lines + event-append-count))) + (prefix-exact-p + (and can-append + (eq result-lines cached-lines) + (<= cached-line-count result-count)))) + (list :lines result-lines + :line-count result-count + :line-tail result-tail + :all-lines all-lines + :all-line-count all-line-count + :all-line-tail all-line-tail + :content-lines content-lines + :content-line-count content-line-count + :content-line-tail content-line-tail + :source-complete effective-source-complete + :complete (and effective-source-complete + (<= all-line-count limit)) + :completion-prefix-tail-rewrite-count rewrite-count + :prefix-delta + (list :exact-p prefix-exact-p + :base-count cached-line-count + :rewrite-range rewrite-range + :rewrite-lines + (and rewrite-range + (if (= rewrite-count 1) + (list (car result-tail)) + (seq-subseq result-lines + (car rewrite-range) + (cdr rewrite-range)))) + :append-lines event-appended-lines))))) + +(defun ebox--render-flex-window-lines (node limit) + "Render NODE's row-wrap flex prefix up to LIMIT lines. +Return nil when the flex container needs the normal full renderer." + (let ((cache-context (ebox--flex-window-cache-context node))) + (or (ebox--flex-window-cache-lookup node limit cache-context) + (ebox--flex-window-cache-store + node limit + (catch 'unsupported + (let* ((props (ebox--flex-container-content-props + (plist-get node :props) + (plist-get node :raw-props) + (plist-get node :box))) + (axis (ebox--flex-axis props)) + (children (plist-get node :children)) + (main-size (if (eq axis 'row) + (ebox--flex-horizontal-value + (plist-get props :width)) + (ebox--flex-line-value + (plist-get props :height)))) + (cross-size (if (eq axis 'row) + (ebox--flex-line-value + (plist-get props :height)) + (ebox--flex-horizontal-value + (plist-get props :width)))) + (wrap (plist-get props :flex-wrap))) + (unless (ebox--flex-window-supported-p + node props axis main-size cross-size children) + (throw 'unsupported nil)) + (let* ((align (plist-get props :align-items)) + (justify (plist-get props :justify-content)) + (row-gap (or (ebox--flex-line-value + (plist-get props :row-gap) 0) + 0)) + (column-gap (or (ebox--flex-horizontal-value + (plist-get props :column-gap) 0) + 0)) + (main-gap column-gap) + (cross-gap row-gap) + current + current-size + (seed (ebox--flex-window-prefix-cache-lookup + node cache-context)) + (pieces (copy-sequence (plist-get seed :pieces))) + (index (or (plist-get seed :next-index) 0)) + (visible-line-count + (or (plist-get seed :prefix-line-count) + (ebox--flex-window-top-chrome-lines + (plist-get node :box)))) + result + complete) + (cl-labels + ((flush-line () + (when current + (let ((add-gap (and pieces (> cross-gap 0)))) + (when add-gap + (push (ebox--pixel-blank main-size cross-gap) + pieces)) + (let* ((line (nreverse current)) + (line-main + (+ (cl-reduce + #'+ line + :key (lambda (item) + (plist-get item :base)) + :initial-value 0) + (* main-gap + (max 0 (1- (length line)))))) + (sized + (progn + ;; A frozen definite width cannot + ;; shrink here, but the full renderer + ;; would compress it on an overflowing + ;; line; publishing the frozen + ;; geometry would diverge, so use the + ;; full-render fallback instead. + (when (and (> line-main main-size) + (cl-some + (lambda (item) + (plist-get + item + :window-frozen-shrinkable)) + line)) + (throw 'unsupported nil)) + (ebox--flex-size-line + line main-size main-gap))) + (line-cross (ebox--flex-line-cross + sized 'row align)) + (rendered + (ebox--flex-render-row-line + sized line-cross main-size main-gap justify + align))) + (push rendered pieces) + (setq visible-line-count + (+ visible-line-count + (if add-gap cross-gap 0) + (ebox-string-height rendered))))) + (setq current nil + current-size nil)))) + (catch 'enough + (while (< index (length children)) + (let* ((child (nth index children)) + (item (or (ebox--flex-window-cheap-item + child axis index) + (throw 'unsupported nil))) + (base (plist-get item :hypothetical)) + (next-size (+ (or current-size 0) + (if current main-gap 0) + base))) + (if (and current + (not (eq wrap 'nowrap)) + (> next-size main-size)) + (progn + (flush-line) + (when (>= visible-line-count limit) + (setq result + (ebox--flex-window-result + node pieces nil limit seed)) + (ebox--flex-window-prefix-cache-store + node pieces index result cache-context) + (throw 'enough nil)) + (setq current (list item) + current-size base) + (setq index (1+ index))) + (push item current) + (setq current-size next-size) + (setq index (1+ index))))) + (flush-line) + (setq complete t)) + (or result + (setq result + (ebox--flex-window-result + node pieces complete limit seed))) + (if (plist-get result :source-complete) + (ebox--flex-window-cache-store + node limit result cache-context) + (ebox--flex-window-prefix-cache-store + node pieces index result cache-context)))))) + cache-context)))) + +(ebox--register-window-line-renderer 'flex #'ebox--render-flex-window-lines) +(ebox--register-window-line-prewarmer 'flex #'ebox--flex-prewarm-sized-item) + +(defun ebox--flex-render-row-line-for-child-ids + (node child-ids &optional min-item-main) + "Return a row-line render plist for CHILD-IDS inside flex NODE. +MIN-ITEM-MAIN is the old row's summed item footprint. When supplied, the +known-row render is used only if the new item footprint is not smaller." + (let* ((props (ebox--flex-container-content-props + (plist-get node :props) + (plist-get node :raw-props) + (plist-get node :box))) + (axis (ebox--flex-axis props)) + (wrap (plist-get props :flex-wrap)) + (main-size (ebox--flex-horizontal-value (plist-get props :width))) + (cross-size nil)) + (when (and child-ids + main-size + (eq axis 'row) + (memq wrap '(wrap nowrap)) + (null (ebox--flex-line-value (plist-get props :height))) + (not (ebox--flex-reverse-p props))) + (let* ((children (plist-get node :children)) + (inline-viewport (ebox--flex-inline-viewport + axis main-size cross-size)) + (row-gap (or (ebox--flex-line-value + (plist-get props :row-gap) 0) + 0)) + (column-gap (or (ebox--flex-horizontal-value + (plist-get props :column-gap) 0) + 0)) + (main-gap column-gap)) + (when (ebox--flex-default-order-p children) + (let* ((items (ebox--flex-collect-items-for-source-ids + children child-ids axis inline-viewport)) + (item-ids (ebox--flex-item-source-ids items)) + (gap-total (* main-gap (max 0 (1- (length items))))) + (line-main (ebox--flex-line-main-size items main-gap)) + (item-main (- line-main gap-total))) + (when (and (= (length items) (length child-ids)) + (equal item-ids child-ids) + (or (= (length items) 1) + (<= line-main main-size)) + (or (null min-item-main) + (>= item-main min-item-main))) + (let* ((sized (ebox--flex-size-line items main-size main-gap)) + (align (plist-get props :align-items)) + (justify (plist-get props :justify-content)) + (line-cross (ebox--flex-line-cross sized 'row align)) + (rendered + (ebox--flex-render-row-line + sized line-cross main-size main-gap justify align))) + (list :rendered rendered + :item-ids (ebox--flex-item-source-ids sized) + :main main-size + :cross line-cross + :row-gap row-gap + :known-row-rerender t))))))))) + +(defun ebox--flex-render-row-line-for-child (node child-id) + "Return a row-line render plist for CHILD-ID inside flex NODE. +Return nil when NODE's flex configuration needs a full rerender." + (let* ((props (ebox--flex-container-content-props + (plist-get node :props) + (plist-get node :raw-props) + (plist-get node :box))) + (axis (ebox--flex-axis props)) + (wrap (plist-get props :flex-wrap)) + (main-size (ebox--flex-horizontal-value (plist-get props :width)))) + (when (and (eq axis 'row) + (memq wrap '(wrap nowrap)) + main-size + (null (ebox--flex-line-value (plist-get props :height)))) + (let* ((children (plist-get node :children)) + (cross-size nil) + (inline-viewport (ebox--flex-inline-viewport + axis main-size cross-size)) + (items (ebox--flex-collect-items children axis inline-viewport)) + (items (if (ebox--flex-reverse-p props) + (nreverse items) + items)) + (row-gap (or (ebox--flex-line-value + (plist-get props :row-gap) 0) + 0)) + (column-gap (or (ebox--flex-horizontal-value + (plist-get props :column-gap) 0) + 0)) + (main-gap column-gap) + (lines (ebox--flex-break-lines items main-size main-gap wrap))) + (when (eq wrap 'wrap-reverse) + (setq lines (nreverse lines))) + (when-let ((line + (cl-find-if + (lambda (candidate) + (cl-some + (lambda (item) + (equal (plist-get + (plist-get item :source) + :node-id) + child-id)) + candidate)) + lines))) + (let* ((sized (ebox--flex-size-line line main-size main-gap)) + (line-mains + (mapcar (lambda (candidate) + (ebox--flex-line-main-size + (ebox--flex-size-line candidate main-size main-gap) + main-gap)) + lines)) + (container-main + (or main-size (apply #'max (or line-mains '(0))))) + (align (plist-get props :align-items)) + (justify (plist-get props :justify-content)) + (line-cross (ebox--flex-line-cross sized 'row align)) + (rendered + (ebox--flex-render-row-line + sized line-cross container-main main-gap justify align))) + (list :rendered rendered + :item-ids + (mapcar (lambda (item) + (plist-get (plist-get item :source) :node-id)) + sized) + :main container-main + :cross line-cross + :row-gap row-gap))))))) + +(provide 'ebox-flex) + +;;; ebox-flex.el ends here diff --git a/ebox-fragment.el b/ebox-fragment.el new file mode 100644 index 0000000..1ce0135 --- /dev/null +++ b/ebox-fragment.el @@ -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 diff --git a/ebox-grid.el b/ebox-grid.el new file mode 100644 index 0000000..48be643 --- /dev/null +++ b/ebox-grid.el @@ -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 diff --git a/ebox-incremental.el b/ebox-incremental.el new file mode 100644 index 0000000..a9b3900 --- /dev/null +++ b/ebox-incremental.el @@ -0,0 +1,8821 @@ +;;; ebox-incremental.el --- Incremental update planner for Ebox -*- lexical-binding: t; -*- + +;;; Commentary: +;; Owns dirty-set construction, patch-set planning, viewport invalidation, and +;; update reports. It does not own DSL parsing or low-level buffer spans. + +;;; Code: + +(require 'cl-lib) +(require 'ebox-cache) +(require 'ebox-fragment) +(require 'ebox-flex) +(require 'ebox-buffer-backend) +(require 'ebox-render-context) + +(defvar ebox-region-types) +(declare-function ebox--clear-buffer-extents + "ebox" (&optional buffer preserve-template)) +(declare-function ebox--clear-box-extents-for-region-ids + "ebox" (region-ids)) +(declare-function ebox--install-box-extent-template + "ebox" (buffer template start)) +(declare-function ebox--materialize-box-extent-template + "ebox" (buffer)) +(declare-function ebox--refresh-box-extents-for-region-ids-in-spans + "ebox" (region-ids spans)) +(declare-function ebox--refresh-box-extents-from-role-spans + "ebox" (region-ids spans &optional active-segments)) +(declare-function ebox--register-box-extents-in-range + "ebox" (start end &optional region-id-allow-set)) +(declare-function ebox--live-box-extents + "ebox" (region-id)) +(declare-function ebox--refresh-buffer-scroll-content-markers + "ebox" (buffer)) +(declare-function ebox--refresh-patched-scroll-markers + "ebox" + (buffer affected-region-ids published-spans + &optional scroll-content-template root-start)) +(declare-function ebox--publish-scoped-owner-scroll-windows + "ebox" + (buffer results source-node-ids + &optional pre-containing-region-ids + rebuilt-region-ids final-spans-by-result)) +(declare-function ebox--scroll-state-region-ids-containing-node-ids + "ebox" (buffer node-ids)) +(declare-function ebox--scroll-cache-side-effects-for-region-ids + "ebox" (region-id-set)) +(declare-function ebox--scroll-cache-actions-replayable-p + "ebox" (actions &optional portable)) +(declare-function ebox--scroll-cache-actions-portable-p + "ebox" (actions)) +(declare-function ebox--replay-scroll-cache-actions + "ebox" (actions &optional portable)) +(declare-function ebox-buffer--region-role-key + "ebox-buffer-backend" (region-id role)) +(declare-function ebox-buffer--region-role-span-table + "ebox-buffer-backend" ()) +(declare-function ebox-buffer-refresh-region-role-spans + "ebox-buffer-backend" (buffer)) +(declare-function ebox-buffer--copy-frozen-region-role-span-table + "ebox-buffer-backend" (table)) +(declare-function ebox-buffer--install-region-role-span-template + "ebox-buffer-backend" (buffer template start)) +(declare-function ebox-buffer-refresh-region-role-spans-for-region-ids + "ebox-buffer-backend" + (buffer region-ids spans &optional old-spans rendered-spans)) +(declare-function ebox-buffer-apply-role-delta + "ebox-buffer-backend" (buffer delta)) +(declare-function ebox-buffer-publish-prepared-root + "ebox-buffer-backend" (buffer rendered)) +(declare-function ebox-buffer--preflight-native-root-property-patches + "ebox-buffer-backend" (spans native-patch)) +(declare-function ebox-buffer--publish-native-root-property-patch-text + "ebox-buffer-backend" (plan)) +(declare-function ebox-buffer-prepare-child-splice + "ebox-buffer-backend" (buffer op)) +(declare-function ebox-buffer-publish-prepared-child-splice + "ebox-buffer-backend" (buffer prepared)) +(declare-function ebox-tree-validate-declarative-root + "ebox-tree" (root)) +(declare-function ebox-tree-clear-runtime-identities + "ebox-tree" (root)) +(declare-function ebox-tree-copy-node-structure + "ebox-tree" (root)) +(declare-function ebox-tree-reconcile-runtime + "ebox-tree" (old new)) +(declare-function ebox-tree-transfer-runtime-identity + "ebox-tree" (old new)) +(declare-function ebox-tree-runtime-identity-snapshot + "ebox-tree" (root)) +(declare-function ebox--rendered-root-metadata + "ebox" (rendered &optional lightweight-only)) +(declare-function ebox-string-lines "ebox" (string)) +(declare-function ebox-get "ebox" (box key)) +(declare-function ebox--ensure-node-id "ebox" (node)) +(declare-function ebox--ensure-region-id "ebox" (box)) +(declare-function ebox--refresh-buffer-scroll-content-markers + "ebox" (buffer)) +(declare-function ebox--schedule-buffer-runtime-prewarm + "ebox" (buffer)) +(declare-function ebox--cancel-buffer-runtime-prewarm + "ebox" (buffer)) +(declare-function ebox--scroll-clear-state + "ebox" (region-id)) +(declare-function ebox--scroll-clear-content-markers + "ebox" (state)) +(declare-function ebox--scroll-clear-rendered-window-markers + "ebox" (state)) +(declare-function ebox--scroll-cancel-idle-prefetch + "ebox" (region-id)) +(declare-function ebox--scroll-schedule-idle-prefetch + "ebox" (region-id &optional delay)) +(declare-function ebox--scroll-window-rebind-state-producers + "ebox-layout" (state box)) +(declare-function ebox--smooth-scroll-stop + "ebox" (region-id)) +(declare-function ebox--install-buffer-scroll-content-span-template + "ebox" (buffer template start)) +(declare-function ebox--node-children + "ebox" (node)) +(declare-function ebox--root-region-box + "ebox" (root region-id)) +(declare-function ebox--literal-root-pixel-width + "ebox" (box)) +(declare-function ebox-put "ebox" (box key value)) +(declare-function ebox--confirm-reflow-prewarm-scratch + "ebox" (buffer region-id revision)) +(declare-function ebox-cache-publish-report + "ebox-cache" (buffer report)) +(declare-function ebox-cache-snapshot-report + "ebox-cache" (buffer)) +(declare-function ebox-cache-restore-report + "ebox-cache" (buffer snapshot)) + +(defvar ebox-viewport-width) +(defvar ebox-viewport-height) +(defvar ebox--region-id-counter) +(defvar ebox--runtime-node-id-counter) +(defvar ebox--region-box-table) +(defvar ebox--scroll-global-state) +(defvar ebox--scroll-idle-prefetch-timers) +(defvar ebox--smooth-scroll-state-table) +(defvar ebox--box-extent-template) +(defvar ebox--box-extents) +(defvar ebox--render-region-id) +(defvar ebox--defer-scroll-content-index) +(defvar ebox--flex-content-min-width-table) + +;;; Render Cache + +(defvar ebox--render-cache-table nil + "Dynamic buffer-local render cache used during incremental rerenders.") + +(defvar ebox--prepared-root-render nil + "Dynamically bound validated root render for one visible transaction.") + +(defvar ebox--collect-rebuilt-scroll-state-region-ids nil + "Non-nil while one update records scroll states rebuilt by rendering.") + +(defvar ebox--rebuilt-scroll-state-region-ids nil + "Scroll region ids rebuilt in the current recorded render transaction.") + +(defvar ebox--render-cache-scroll-state-region-ids nil + "Dynamically bound set of scroll region ids touched by one cached render.") + +(defvar ebox--render-cache-scroll-state-restorable-p t + "Non-nil when cached scroll state belongs to the live runtime tree. +Isolated prewarming binds this to nil because its prefix renderers capture a +copied tree and must never be published into the live buffer runtime.") + +(defvar ebox--render-cache-scroll-state-retained-cost-cache nil + "Dynamic identity cache for scroll snapshot costs in one root render.") + +(defvar ebox-incremental-before-runtime-mutation-hook nil + "Functions run before an existing buffer runtime is mutated. +Each function receives the target buffer and a mutation kind. Hooks may +invalidate speculative work, but must not mutate the pending operation's +arguments.") + +(defvar ebox-incremental--runtime-mutation-hooks-inhibited-p nil + "Non-nil while a validated prepared-root transaction owns its mutation.") + +(defvar ebox-incremental--declarative-commit-buffer nil + "Buffer whose declarative root commit currently owns publication.") + +(cl-defstruct + (ebox-candidate + (:constructor ebox-incremental--make-candidate) + (:conc-name ebox-candidate--)) + "One-shot logical declarative transaction based on a published runtime." + buffer + base-state + base-root + base-revision + base-buffer-tick + replacements + sealed-p) + +(cl-defstruct + (ebox-incremental--candidate-replacement + (:constructor ebox-incremental--make-candidate-replacement) + (:conc-name ebox-incremental--candidate-replacement-)) + "One logical anchor replacement and its optional semantic transition." + anchor-id + subtree + old-semantic-key + new-semantic-key) + +(cl-defstruct + (ebox-incremental--detached-history + (:constructor ebox-incremental--make-detached-history) + (:conc-name ebox-incremental--detached-history-)) + "One buffer-owned bounded detached runtime identity history." + table + order + node-count) + +(defvar ebox-incremental--candidate-proof-node-table nil + "Dynamic `(BUFFER . TABLE)' for proof-local owner subtree copies.") + +(defvar ebox-incremental--candidate-base-state nil + "Dynamic `(BUFFER . STATE)' holding proof-time published runtime nodes.") + +(defvar ebox-incremental--candidate-path-copy-trace nil + "Dynamic hash table receiving logical candidate path-copy facts. +Each key is a base anchor node id. Values describe the final copied node, +its candidate parent, whether it is a replacement anchor, and whether a +direct child runtime id changed. The trace lets index preparation skip every +untouched sibling without exposing paths outside Ebox.") + +(defvar ebox-incremental--candidate-path-copy-origin-table nil + "Dynamic current-node-id to base-origin-id map for candidate path copies. +Type-changing ancestor replacements receive fresh runtime ids. Later +descendant refinements still use those fresh ids while copying their path, so +this transaction-local map keeps every successive copy recorded under the +original published identity. Candidate-only nodes with no base origin map to +themselves.") + +(defvar ebox-incremental--after-declarative-publication nil + "Internal callback run inside declarative publication's quit-free boundary. + +Framework adapters may dynamically bind this to a one-argument, pointer-only +promotion function receiving the candidate update report. It must not call +user code or signal after mutation.") + +(defun ebox-incremental--notify-before-runtime-mutation (buffer kind) + "Notify speculative consumers before BUFFER runtime mutation KIND." + (unless ebox-incremental--runtime-mutation-hooks-inhibited-p + (run-hook-with-args + 'ebox-incremental-before-runtime-mutation-hook buffer kind))) + +(defvar ebox--buffer-render-state-table) + +(defvar ebox-render-cache-max-entries 2048 + "Maximum persistent render cache entries retained per buffer. +The public customization is declared by the `ebox' facade after internal +modules have loaded.") + +(defvar ebox-render-cache-max-bytes (* 32 1024 1024) + "Approximate persistent render cache byte budget per buffer. +The public customization is declared by the `ebox' facade after internal +modules have loaded.") + +(defvar ebox-render-root-cache-max-entries 16 + "Maximum complete root render outputs retained per buffer. +The public customization is declared by the `ebox' facade after internal +modules have loaded.") + +(defvar ebox-render-root-cache-max-bytes (* 8 1024 1024) + "Approximate complete root render output budget per buffer. +The public customization is declared by the `ebox' facade after internal +modules have loaded.") + +(defvar ebox-incremental--detached-history-entry-limit 16 + "Maximum semantic subtree identities retained per buffer.") + +(defvar ebox-incremental--detached-history-node-limit 8192 + "Maximum identity skeleton nodes retained per buffer.") + +(defvar ebox--render-cache-ring-table + (make-hash-table :test 'eq :weakness 'key) + "Weak map from render cache tables to fixed-capacity eviction rings.") + +(defvar ebox--render-root-cache-table-table + (make-hash-table :test 'eq :weakness 'key) + "Weak map from subtree caches to their bounded complete-root caches.") + +(defvar ebox--render-cache-entry-side-effects-table + (make-hash-table :test 'eq :weakness 'key) + "Weak cache-entry map for render-time scroll state side effects.") + +(defvar ebox--render-cache-signature-cache nil + "Dynamic render-pass cache for recursive render signatures.") + +(defvar ebox--scroll-prefetch-delay-override nil) +(defvar ebox--scroll-window-initial-lookahead-lines-override nil) + +(defvar ebox--root-reflow-gc-deferred-p nil + "Non-nil while an outer root reflow already defers garbage collection.") + +(defvar ebox--prepared-root-defer-index-refresh nil + "Prepared animation index policy. +Nil uses ordinary root refresh, `incremental' splices exact Rust patch spans, +and any other non-nil value defers broad indexes for a display-only frame.") + +(defconst ebox--root-reflow-scroll-lookahead-lines 13 + "Lazy scroll lines retained by latency-sensitive root reflows. +The scroll window already renders one completion sentinel, so this leaves a +14-line published runway; an immediate exact-prefix extension fills the rest +of a default smooth-wheel step without rebuilding the rendered prefix.") + +(defconst ebox--latency-sensitive-scroll-node-limit 512 + "Runtime size above which lazy scroll rows stay strictly demand-driven. +Large indexed runtimes can hide a composite flex subtree behind one logical +scroll line. Bounding work by requested lines alone is therefore insufficient +unless root reflow and idle prefetch also avoid crossing several such rows.") + +(defun ebox--buffer-latency-sensitive-scroll-prefix-p (buffer) + "Return non-nil when BUFFER's lazy scroll rows need atomic scheduling." + ;; A logical candidate may deliberately defer rebuilding selector paths + ;; until the first selector query. Scroll policy needs only the retained + ;; type membership hint and must not turn proof rendering into a full-tree + ;; selector-index rebuild. + (let* ((state (ebox--buffer-render-state buffer)) + (type-table (plist-get state :selector-type-table)) + (node-table (plist-get state :node-table))) + (or (plist-get state :selector-index-stale-p) + (and (hash-table-p type-table) + (gethash 'item type-table)) + (and (hash-table-p node-table) + (>= (hash-table-count node-table) + ebox--latency-sensitive-scroll-node-limit))))) + +(defun ebox--root-reflow-scroll-lookahead-lines-for-buffer (buffer) + "Return the root-reflow lazy scroll runway for BUFFER. +Explicit flex items and large runtimes can render an expensive composite row +for one requested line. Keep those rows demand-driven; other sources retain +the short runway that makes immediate post-resize smooth scrolling cheap." + (if (ebox--buffer-latency-sensitive-scroll-prefix-p buffer) + 0 + ebox--root-reflow-scroll-lookahead-lines)) + +(defvar ebox--viewport-dependent-node-ids-cache nil + "Dynamic render-pass cache for viewport dependency discovery.") + +(defvar ebox--viewport-dependent-subtree-cache nil + "Dynamic render-pass cache for viewport dependency existence checks.") + +(defvar ebox--viewport-height-dependent-subtree-cache nil + "Dynamic render-pass cache for viewport-height dependency checks.") + +(defvar ebox--render-cache-allow-viewport-dependent nil + "Non-nil allows caching viewport-dependent nodes for the current context.") + +(defun ebox--render-cache-contained-viewport-node-p (node) + "Return non-nil when NODE bounds descendant viewport-dependent layout." + (and (eq (and (listp node) (plist-get node :ebox-type)) 'box) + (ebox--span-patch-definite-size-value-p (ebox-get node :width)))) + +(defun ebox--render-cacheable-node-p (node) + "Return non-nil when NODE can reuse rendered output in the current context." + (and ebox--render-cache-table + (listp node) + (not (stringp node)) + (plist-get node :ebox-type) + (or ebox--render-cache-allow-viewport-dependent + (not (ebox--viewport-dependent-subtree-p node)) + (ebox--render-cache-contained-viewport-node-p node)))) + +(defun ebox--render-cacheable-box-p (node) + "Return non-nil when NODE is a cacheable box. +Kept as a narrow predicate for callers that need box-only sizing semantics." + (and (eq (and (listp node) (plist-get node :ebox-type)) 'box) + (ebox--render-cacheable-node-p node))) + +(defun ebox--render-cache-value-signature (value) + "Return VALUE normalized for render cache signatures." + (cond + ((and (listp value) + (not (stringp value)) + (plist-get value :ebox-type)) + (ebox--render-cache-node-body-signature value)) + ((consp value) + (cons (ebox--render-cache-value-signature (car value)) + (ebox--render-cache-value-signature (cdr value)))) + (t value))) + +(defun ebox--render-cache-plist-signature (node) + "Return NODE's render-relevant plist signature." + (let ((plist node) + signature) + (while plist + (let ((key (pop plist)) + (value (pop plist))) + (unless (eq key :render-cache) + (push key signature) + (push (ebox--render-cache-value-signature value) signature)))) + (nreverse signature))) + +(defun ebox--render-cache-node-body-signature (node) + "Return NODE's recursive body signature, memoized within a render pass." + (if (not (and ebox--render-cache-signature-cache + (listp node) + (not (stringp node)))) + (ebox--render-cache-plist-signature node) + (let* ((missing (make-symbol "ebox-render-signature-missing")) + (cached (gethash node ebox--render-cache-signature-cache + missing))) + (if (not (eq cached missing)) + cached + (puthash node + (ebox--render-cache-plist-signature node) + ebox--render-cache-signature-cache))))) + +(defun ebox--render-cache-node-signature (node) + "Return a cache signature for NODE render output." + (list (ebox--current-display-signature) + (when (and (ebox--viewport-dependent-subtree-p node) + (not (ebox--render-cache-contained-viewport-node-p + node))) + ebox-viewport-width) + (when (ebox--viewport-height-dependent-subtree-p node) + ebox-viewport-height) + (ebox--render-cache-node-body-signature node))) + +(defun ebox--render-cache-box-signature (box) + "Return a cache signature for BOX render output." + (ebox--render-cache-node-signature box)) + +(defsubst ebox--render-cache-effective-key (cache-key signature) + "Return CACHE-KEY namespaced by the complete render SIGNATURE. +The complete signature keeps distinct values in one `sxhash-equal' bucket." + (list cache-key signature)) + +(defun ebox--render-cache-retained-cost (object &optional limit) + "Return a conservative retained-byte estimate for OBJECT up to LIMIT. +Rendered Ebox strings encode most geometry in text-property intervals rather +than character bytes. Charging every character as one possible interval keeps +the estimate cheap enough for cache misses while bounding those hidden values. +When LIMIT is non-nil, stop once the estimate exceeds it; callers only need to +know that an oversized value cannot be retained." + (let ((seen (make-hash-table :test 'eq)) + (stack (list object)) + (total 0)) + (while (and stack + (or (null limit) (<= total limit))) + (let ((current (pop stack))) + (unless (or (null current) + (numberp current) + (symbolp current) + (gethash current seen)) + (puthash current t seen) + (cond + ((stringp current) + (cl-incf total (+ 64 (string-bytes current) + (* 128 (length current))))) + ((consp current) + (cl-incf total 16) + (push (car current) stack) + (push (cdr current) stack)) + ((vectorp current) + (cl-incf total (+ 32 (* 8 (length current)))) + (dotimes (index (length current)) + (push (aref current index) stack))) + ((hash-table-p current) + (cl-incf total 64) + (maphash (lambda (key value) + (push key stack) + (push value stack)) + current)))))) + total)) + +(defun ebox--render-cache-side-effect-string-cost (string) + "Return retained bytes for one scroll metadata STRING. +The snapshot owns line strings separately from the cached output. Charge a +bounded property-bearing character estimate without walking each interval; +doing so here would put cache accounting itself on the resize hot path." + (+ 64 (string-bytes string) (* 16 (length string)))) + +(defun ebox--render-cache-side-effects-retained-cost + (metadata &optional limit) + "Return bounded retained bytes for scroll side-effect METADATA. +Live boxes are already owned by the runtime and renderer closures capture that +same tree, so this charges them shallowly. Snapshot line strings and their +text-property interval spines are charged exactly once by identity." + (let ((seen (make-hash-table :test 'eq)) + (total 64)) + (cl-labels + ((room-p () (or (null limit) (<= total limit))) + (charge-lines (lines) + (while (and lines (room-p)) + (cl-incf total 16) + (let ((line (pop lines))) + (when (and (stringp line) (not (gethash line seen))) + (puthash line t seen) + (cl-incf total + (ebox--render-cache-side-effect-string-cost + line))))))) + (dolist (action (plist-get metadata :scroll-actions)) + (when (room-p) + (cl-incf total 48) + (when (eq (car action) 'set) + (let ((template (nth 2 action))) + (cl-incf total (* 16 (length template))) + (charge-lines (plist-get template :content-lines)) + (charge-lines (plist-get template :rendered-content-lines)) + (when (plist-get template :render-content-prefix) + (cl-incf total 512)) + (when (plist-get template :materialize-content-lines) + (cl-incf total 256))))))) + total)) + +(defun ebox--render-cache-entry-retained-cost (entry &optional limit) + "Return retained bytes for ENTRY's value and metadata, up to LIMIT." + (let* ((rendered-cost + (ebox--render-cache-retained-cost (cdr entry) limit)) + (metadata + (gethash entry ebox--render-cache-entry-side-effects-table))) + (if (or (null metadata) + (and limit (> rendered-cost limit))) + rendered-cost + (+ rendered-cost + (or (plist-get metadata :retained-cost) + (ebox--render-cache-side-effects-retained-cost + metadata (and limit (max 0 (- limit rendered-cost))))))))) + +(defsubst ebox--render-cache-entry-limit () + "Return the positive configured render cache entry limit." + (max 1 ebox-render-cache-max-entries)) + +(defsubst ebox--render-cache-byte-limit () + "Return the positive configured render cache byte limit." + (max 1 ebox-render-cache-max-bytes)) + +(defsubst ebox--render-cache-ring-reset (ring entry-limit byte-limit) + "Reset RING metadata for ENTRY-LIMIT and BYTE-LIMIT." + (aset ring 0 (make-vector entry-limit nil)) + (aset ring 1 (make-vector entry-limit 0)) + (aset ring 2 0) + (aset ring 3 0) + (aset ring 4 entry-limit) + (aset ring 5 0) + (aset ring 6 byte-limit)) + +(defsubst ebox--render-cache-ring-evict-oldest (ring) + "Evict and forget RING's oldest entry." + (when (> (aref ring 3) 0) + (let* ((keys (aref ring 0)) + (costs (aref ring 1)) + (start (aref ring 2)) + (cost (aref costs start)) + (effective-key (aref keys start)) + (entry (gethash effective-key ebox--render-cache-table))) + (remhash effective-key ebox--render-cache-table) + (when entry + (remhash entry ebox--render-cache-entry-side-effects-table)) + (aset keys start nil) + (aset costs start 0) + (aset ring 2 (% (1+ start) (aref ring 4))) + (aset ring 3 (1- (aref ring 3))) + (aset ring 5 (max 0 (- (aref ring 5) cost)))))) + +(defsubst ebox--render-cache-ring-enforce-byte-limit (ring) + "Evict oldest RING entries until its byte budget is satisfied." + (while (and (> (aref ring 3) 0) + (> (aref ring 5) (aref ring 6))) + (ebox--render-cache-ring-evict-oldest ring))) + +(defsubst ebox--render-cache-ring-append (ring effective-key cost) + "Append EFFECTIVE-KEY with retained COST to cache eviction RING." + (when (= (aref ring 3) (aref ring 4)) + (ebox--render-cache-ring-evict-oldest ring)) + (let* ((keys (aref ring 0)) + (costs (aref ring 1)) + (index (% (+ (aref ring 2) (aref ring 3)) (aref ring 4)))) + (aset keys index effective-key) + (aset costs index cost) + (aset ring 3 (1+ (aref ring 3))) + (aset ring 5 (+ (aref ring 5) cost))) + (ebox--render-cache-ring-enforce-byte-limit ring)) + +(defun ebox--render-cache-ring () + "Return eviction ring owned by the current render cache table." + (or (gethash ebox--render-cache-table ebox--render-cache-ring-table) + (let* ((entry-limit (ebox--render-cache-entry-limit)) + (byte-limit (ebox--render-cache-byte-limit)) + (ring (vector (make-vector entry-limit nil) + (make-vector entry-limit 0) + 0 0 entry-limit 0 byte-limit))) + (puthash ebox--render-cache-table ring ebox--render-cache-ring-table) + ring))) + +(defun ebox--render-cache-ring-rebuild (ring entry-limit byte-limit) + "Rebuild RING from the current cache with ENTRY-LIMIT and BYTE-LIMIT. +This is reserved for exceptional private mutations that bypass cache stores." + (let (entries) + (maphash (lambda (key entry) + (push (cons key + (ebox--render-cache-entry-retained-cost + entry byte-limit)) + entries)) + ebox--render-cache-table) + (ebox--render-cache-ring-reset ring entry-limit byte-limit) + (dolist (entry (nreverse entries)) + (when (gethash (car entry) ebox--render-cache-table) + (ebox--render-cache-ring-append ring (car entry) (cdr entry)))))) + +(defun ebox--render-cache-ring-resize (ring entry-limit byte-limit) + "Resize RING while retaining its newest entries within both limits." + (let ((old-keys (aref ring 0)) + (old-costs (aref ring 1)) + (old-start (aref ring 2)) + (old-count (aref ring 3)) + (old-limit (aref ring 4)) + entries) + (dotimes (offset old-count) + (let ((index (% (+ old-start offset) old-limit))) + (push (cons (aref old-keys index) (aref old-costs index)) entries))) + (ebox--render-cache-ring-reset ring entry-limit byte-limit) + (dolist (entry (nreverse entries)) + (when (gethash (car entry) ebox--render-cache-table) + (ebox--render-cache-ring-append ring (car entry) (cdr entry)))))) + +(defun ebox--render-cache-ring-update-cost (ring effective-key cost) + "Update EFFECTIVE-KEY retained COST in RING, rebuilding if it is absent." + (let ((keys (aref ring 0)) + (costs (aref ring 1)) + (start (aref ring 2)) + (count (aref ring 3)) + (limit (aref ring 4)) + found) + (dotimes (offset count) + (let ((index (% (+ start offset) limit))) + (when (equal (aref keys index) effective-key) + (aset ring 5 (+ (aref ring 5) (- cost (aref costs index)))) + (aset costs index cost) + (setq found t)))) + (if found + (ebox--render-cache-ring-enforce-byte-limit ring) + (ebox--render-cache-ring-rebuild + ring (ebox--render-cache-entry-limit) (ebox--render-cache-byte-limit))))) + +(defun ebox--render-cache-ring-sync (ring) + "Synchronize RING with the current render cache table. +This normally does no work. It rebuilds metadata only after a defensive +`clrhash' or another exceptional private cache mutation." + (let ((entry-limit (ebox--render-cache-entry-limit)) + (byte-limit (ebox--render-cache-byte-limit))) + (cond + ((/= (aref ring 3) (hash-table-count ebox--render-cache-table)) + (ebox--render-cache-ring-rebuild ring entry-limit byte-limit)) + ((or (/= (aref ring 4) entry-limit) + (/= (aref ring 6) byte-limit)) + (ebox--render-cache-ring-resize ring entry-limit byte-limit))))) + +(defun ebox--render-cache-lookup-entry (cache-key signature) + "Return the raw cache entry for CACHE-KEY matching SIGNATURE." + (let ((entry (gethash (ebox--render-cache-effective-key + cache-key signature) + ebox--render-cache-table))) + (and entry + (equal-including-properties (car entry) signature) + entry))) + +(defun ebox--render-cache-lookup (cache-key signature) + "Return cached render output for CACHE-KEY matching SIGNATURE." + (let ((entry (ebox--render-cache-lookup-entry cache-key signature))) + (if (and entry + (equal-including-properties (car entry) signature)) + (prog1 (cdr entry) + (when ebox-cache-report-buffer + (ebox-cache-record-hit ebox-cache-report-buffer 'render-body))) + (when ebox-cache-report-buffer + (ebox-cache-record-miss ebox-cache-report-buffer 'render-body)) + nil))) + +(defun ebox--render-cache-store + (cache-key signature rendered &optional side-effects) + "Store RENDERED for CACHE-KEY and SIGNATURE with SIDE-EFFECTS metadata." + (let* ((effective-key + (ebox--render-cache-effective-key cache-key signature)) + (old-entry (gethash effective-key ebox--render-cache-table)) + (new-entry-p + (null old-entry)) + (ring (ebox--render-cache-ring)) + (entry (cons signature rendered))) + (ebox--render-cache-ring-sync ring) + (when old-entry + (remhash old-entry ebox--render-cache-entry-side-effects-table)) + (puthash effective-key entry ebox--render-cache-table) + (when side-effects + (puthash entry side-effects ebox--render-cache-entry-side-effects-table)) + (let ((cost (ebox--render-cache-entry-retained-cost entry (aref ring 6)))) + (cond + ((> cost (aref ring 6)) + ;; The budget contract: an oversized value stays renderable for + ;; this caller but is never retained. Appending it first would + ;; make byte enforcement evict every healthy older entry before + ;; discarding the oversized entry itself. + (remhash effective-key ebox--render-cache-table) + (remhash entry ebox--render-cache-entry-side-effects-table) + (unless new-entry-p + (ebox--render-cache-ring-rebuild + ring (ebox--render-cache-entry-limit) + (ebox--render-cache-byte-limit)))) + (new-entry-p + (ebox--render-cache-ring-append ring effective-key cost)) + (t + (ebox--render-cache-ring-update-cost ring effective-key cost))))) + rendered) + +(defun ebox--render-cache-scroll-actions-stateful-p (actions) + "Return non-nil when ACTIONS contain a scroll state installation." + (cl-some (lambda (action) (eq (car action) 'set)) actions)) + +(defun ebox--render-cache-entry-reusable-p (entry) + "Return non-nil when ENTRY can safely replay in the current render tree." + (let ((metadata + (gethash entry ebox--render-cache-entry-side-effects-table))) + (or (null metadata) + (not (plist-get metadata :stateful-p)) + (and (plist-get metadata :restorable-p) + (or ebox--render-cache-scroll-state-restorable-p + (plist-get metadata :portable-p)) + (ebox--scroll-cache-actions-replayable-p + (plist-get metadata :scroll-actions) + (plist-get metadata :portable-p)))))) + +(defun ebox--render-cache-replay-entry-side-effects (entry) + "Replay ENTRY's scroll side effects before its rendered output is reused." + (when-let ((metadata + (gethash entry ebox--render-cache-entry-side-effects-table))) + (ebox--replay-scroll-cache-actions + (plist-get metadata :scroll-actions) + (plist-get metadata :portable-p)))) + +(defun ebox--render-cache-merge-scroll-region-ids (source target) + "Merge scroll region ids from SOURCE hash table into TARGET." + (when (and (hash-table-p source) (hash-table-p target)) + (maphash (lambda (region-id _) + (puthash region-id t target)) + source))) + +(defun ebox--render-cache-render-with-scroll-actions (node) + "Render NODE and return its output plus complete scroll side effects." + (let ((parent-region-ids ebox--render-cache-scroll-state-region-ids) + (region-ids (make-hash-table :test 'equal)) + (ebox--render-cache-scroll-state-retained-cost-cache + (or ebox--render-cache-scroll-state-retained-cost-cache + (make-hash-table :test 'eq))) + rendered) + (let ((ebox--render-cache-scroll-state-region-ids region-ids)) + (setq rendered (ebox-render node))) + (ebox--render-cache-merge-scroll-region-ids + region-ids parent-region-ids) + (list rendered + (ebox--scroll-cache-side-effects-for-region-ids region-ids)))) + +(defun ebox--render-root-cache-table (subtree-cache) + "Return the bounded complete-root cache paired with SUBTREE-CACHE." + (or (gethash subtree-cache ebox--render-root-cache-table-table) + (let ((root-cache (make-hash-table :test 'equal))) + (puthash subtree-cache root-cache ebox--render-root-cache-table-table) + root-cache))) + +(defun ebox--prepared-root-render-matches-p (node prepared) + "Return non-nil when PREPARED is exact for NODE's current transaction." + (let* ((state (gethash (current-buffer) ebox--buffer-render-state-table)) + (kind (plist-get prepared :kind)) + (region-id (plist-get prepared :region-id))) + (and state + (eq state (plist-get prepared :render-state)) + (eq node (plist-get state :root-node)) + (equal (ebox--ensure-node-id node) + (plist-get prepared :node-id)) + (equal (plist-get state :runtime-revision) + (plist-get prepared :runtime-revision)) + (equal (ebox--current-display-signature) + (plist-get prepared :display-signature)) + (equal (plist-get state :viewport-width) + (plist-get prepared :viewport-width)) + (equal (plist-get state :viewport-height) + (plist-get prepared :viewport-height)) + (or (stringp (plist-get prepared :rendered)) + (plist-get (plist-get prepared :native-patch) :native-patch)) + (pcase kind + ('viewport t) + ('root-width + (when-let ((box (ebox--root-region-box node region-id))) + (equal (ebox--literal-root-pixel-width box) + (plist-get prepared :root-width)))) + (_ nil))))) + +(defun ebox--take-prepared-root-render (node) + "Consume and return a validated dynamic root render for NODE." + (when (and ebox--prepared-root-render + (stringp (plist-get ebox--prepared-root-render :rendered))) + (let ((prepared ebox--prepared-root-render)) + (setq ebox--prepared-root-render nil) + (when (ebox--prepared-root-render-matches-p node prepared) + (plist-put prepared :consumed-p t) + (plist-get prepared :rendered))))) + +(defun ebox--take-prepared-root-patch (node) + "Consume and return a validated Rust-origin root patch for NODE." + (when (and ebox--prepared-root-render + (plist-get (plist-get ebox--prepared-root-render :native-patch) + :native-patch)) + (let ((prepared ebox--prepared-root-render)) + (setq ebox--prepared-root-render nil) + (when (and (not (eq (plist-get prepared :publication-owner) + 'prepared-root-native)) + (ebox--prepared-root-render-matches-p node prepared)) + (plist-put prepared :consumed-p t) + (prog1 (plist-get prepared :native-patch) + (ebox--flex-recache-source-boxes node)))))) + +(defun ebox--render-cache-context-signature + (node force &optional external-signature) + "Return NODE's render signature for FORCE and EXTERNAL-SIGNATURE." + (let ((signature + (if (not force) + (ebox--render-cache-node-signature node) + (let ((ebox--viewport-dependent-node-ids-cache + (make-hash-table :test 'eq)) + (ebox--viewport-dependent-subtree-cache + (make-hash-table :test 'eq)) + (ebox--viewport-height-dependent-subtree-cache + (make-hash-table :test 'eq))) + (ebox--render-cache-node-signature node))))) + (if external-signature + (list signature external-signature) + signature))) + +(defun ebox--render-cache-context + (node force &optional external-signature) + "Return exact cache context for NODE, FORCE, and EXTERNAL-SIGNATURE." + (when (or (and (or force external-signature) ebox--render-cache-table) + (ebox--render-cacheable-node-p node)) + (let* ((subtree-cache ebox--render-cache-table) + (root-cache-p (and force t))) + (list + :cache-key (list (ebox--ensure-node-id node) 'natural) + :signature + (ebox--render-cache-context-signature + node force external-signature) + :cache (if root-cache-p + (ebox--render-root-cache-table subtree-cache) + subtree-cache) + :entry-limit (if root-cache-p + ebox-render-root-cache-max-entries + ebox-render-cache-max-entries) + :byte-limit (if root-cache-p + ebox-render-root-cache-max-bytes + ebox-render-cache-max-bytes))))) + +(defun ebox--render-cache-probe + (node &optional force external-signature) + "Return NODE's exact cache context, with `:rendered' when reusable. +FORCE includes the complete viewport context in the cache signature. +EXTERNAL-SIGNATURE may prove a viewport-dependent non-root context." + (when-let ((context + (ebox--render-cache-context + node force external-signature))) + (let* ((ebox--render-cache-table (plist-get context :cache)) + (entry + (ebox--render-cache-lookup-entry + (plist-get context :cache-key) + (plist-get context :signature)))) + (if (and entry (ebox--render-cache-entry-reusable-p entry)) + (progn + (when ebox-cache-report-buffer + (ebox-cache-record-hit ebox-cache-report-buffer 'render-body)) + (ebox--render-cache-replay-entry-side-effects entry) + (ebox--flex-recache-source-boxes node) + (plist-put context :rendered (cdr entry))) + (when ebox-cache-report-buffer + (ebox-cache-record-miss ebox-cache-report-buffer 'render-body)) + context)))) + +(defun ebox--render-cache-store-context + (context rendered &optional metadata) + "Store RENDERED with METADATA in exact cache CONTEXT." + (let ((ebox--render-cache-table (plist-get context :cache)) + (ebox-render-cache-max-entries (plist-get context :entry-limit)) + (ebox-render-cache-max-bytes (plist-get context :byte-limit))) + (ebox--render-cache-store + (plist-get context :cache-key) + (plist-get context :signature) + rendered metadata))) + +(defun ebox--render-with-cache (node &optional force cache-probe) + "Render NODE, reusing stable subtree output inside buffer render contexts. +When FORCE is non-nil, cache NODE even when its output is viewport-dependent; +the complete viewport context remains part of the render signature. +CACHE-PROBE may supply a prior exact miss from an accelerator boundary." + (if-let ((prepared (and force + (ebox--take-prepared-root-render node)))) + (prog1 prepared + (ebox--flex-recache-source-boxes node)) + (let ((probe (or cache-probe + (ebox--render-cache-probe node force)))) + (cond + ((null probe) + (ebox-render node)) + ((plist-member probe :rendered) + (plist-get probe :rendered)) + (t + (pcase-let* ((`(,rendered ,side-effects) + (ebox--render-cache-render-with-scroll-actions node)) + (actions (plist-get side-effects :scroll-actions)) + (stateful-p + (ebox--render-cache-scroll-actions-stateful-p actions)) + (portable-p + (and stateful-p + (not ebox--render-cache-scroll-state-restorable-p) + (ebox--scroll-cache-actions-portable-p actions))) + (metadata + (append + side-effects + (list :stateful-p stateful-p + :portable-p portable-p + :restorable-p + (or ebox--render-cache-scroll-state-restorable-p + portable-p))))) + ;; An isolated copy may still warm pure descendant entries, but a + ;; cached node that created scroll state owns closures over that + ;; copy and cannot safely cross into the live runtime. + (unless (and stateful-p + (not (or ebox--render-cache-scroll-state-restorable-p + portable-p))) + (ebox--render-cache-store-context probe rendered metadata)) + rendered)))))) + +;;; Buffer Runtime State + +(defvar ebox--buffer-render-state-table (make-hash-table :test 'eq) + "Maps buffer objects to render state plists. +Each state contains :root-node, viewport dimensions, and derived layout +snapshots so every incremental rerender can use the same layout context as the +original buffer render.") + +(defvar ebox-incremental--buffer-render-state-override nil + "Dynamically bound `(BUFFER . STATE)' used while preparing a candidate.") + +(defvar ebox-incremental--batch-table (make-hash-table :test 'eq) + "Buffer-keyed explicit incremental batch state.") + +(defvar ebox-incremental--after-successful-batch-flush-hook nil + "Functions called after a non-empty explicit batch flush succeeds. +Each function receives BUFFER, the original PENDING entries, and the final +update REPORT.") + +(defvar ebox--scroll-global-state (make-hash-table :test 'equal) + "Global hash table storing scroll state. +Keys are region-id, values are plists with :scroll-offset, :content-lines, etc.") + +(defun ebox--refresh-buffer-box-extents (buffer) + "Rebuild live box extents for BUFFER from its current text properties." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (ebox--clear-buffer-extents buffer) + (ebox--register-box-extents-in-range (point-min) (point-max))))) + +(defun ebox--buffer-render-state (buffer) + "Return render state for BUFFER." + (if (and ebox-incremental--buffer-render-state-override + (eq buffer + (car ebox-incremental--buffer-render-state-override))) + (cdr ebox-incremental--buffer-render-state-override) + (gethash buffer ebox--buffer-render-state-table))) + +(defun ebox--buffer-update-report (buffer) + "Return the update report stored in BUFFER's render state." + (plist-get (ebox--buffer-render-state buffer) :last-update-report)) + +(defun ebox--set-buffer-update-report (buffer report) + "Store REPORT in BUFFER's render state and return REPORT." + (let ((state (ebox--buffer-render-state buffer))) + (unless state + (error "Ebox cannot store an update report without render state: %S" + buffer)) + (plist-put state :last-update-report report) + report)) + +(defun ebox--buffer-root-node (buffer) + "Return the render root node stored for BUFFER." + (plist-get (ebox--buffer-render-state buffer) :root-node)) + +(defun ebox--buffer-root-node-id (buffer) + "Return BUFFER's root runtime node id." + (when-let ((root (ebox--buffer-root-node buffer))) + (ebox--ensure-node-id root))) + +(defun ebox--runtime-region-id-conflict (region-id-set target-buffer) + "Return `(REGION-ID . BUFFER)' for REGION-ID-SET outside TARGET-BUFFER." + (ebox--prune-dead-buffer-render-states) + (let ((target (and target-buffer (get-buffer target-buffer)))) + (catch 'conflict + (maphash + (lambda (buffer state) + (unless (eq buffer target) + (when-let ((owner-set (plist-get state :region-id-set))) + (maphash + (lambda (region-id _present) + (when (gethash region-id owner-set) + (throw 'conflict (cons region-id buffer)))) + region-id-set)))) + ebox--buffer-render-state-table) + nil))) + +(defun ebox--prepare-buffer-runtime-root (source-root target-buffer) + "Return a runtime-preserving copy of SOURCE-ROOT for TARGET-BUFFER. +Signal `user-error' before target mutation when SOURCE-ROOT reuses a region id +owned by another live buffer." + (let* ((index (ebox--runtime-index source-root)) + (conflict + (ebox--runtime-region-id-conflict + (plist-get index :region-id-set) target-buffer))) + (when conflict + (user-error "Ebox region id %S is already owned by buffer %S" + (car conflict) (cdr conflict))) + (ebox-tree-copy-node-structure source-root))) + +(defun ebox--runtime-index (root-node &optional collect-region-boxes) + "Return persistent runtime indexes for ROOT-NODE. +When COLLECT-REGION-BOXES is non-nil, collect `:region-box-table' during the +same traversal in complete-render overwrite order." + (let ((node-table (make-hash-table :test 'equal)) + (parent-table (make-hash-table :test 'equal)) + (region-id-set (make-hash-table :test 'equal)) + (region-node-table (make-hash-table :test 'equal)) + (region-box-table + (and collect-region-boxes (make-hash-table :test 'equal))) + (region-box-count-table (make-hash-table :test 'equal)) + (host-ref-table (make-hash-table :test 'equal)) + (selector-id-table (make-hash-table :test 'equal)) + (selector-class-table (make-hash-table :test 'equal)) + (selector-type-table (make-hash-table :test 'eq)) + native-node-postorder) + (cl-labels ((index-selector-id (node path) + (when-let ((id (ebox-tree-node-id node))) + (puthash id + (cons (cons node path) + (gethash id selector-id-table)) + selector-id-table))) + (index-selector-classes (node path) + (dolist (class (ebox-tree-node-classes node)) + (puthash class + (cons (cons node path) + (gethash class selector-class-table)) + selector-class-table))) + (index-selector-type (node path) + (when-let ((type (ebox-tree-node-selector-type node))) + (puthash type + (cons (cons node path) + (gethash type selector-type-table)) + selector-type-table))) + (index-host-ref (node node-id) + (when-let ((host-ref (plist-get node :host-ref))) + (let ((count (hash-table-count host-ref-table))) + (puthash host-ref node-id host-ref-table) + (when (= count (hash-table-count host-ref-table)) + (error + "Ebox declarative tree uses duplicate host ref %S" + host-ref))))) + (index-region-id (node node-id) + (pcase (plist-get node :ebox-type) + ('box + (let ((region-id (ebox--ensure-region-id node))) + (puthash region-id t region-id-set) + (puthash region-id + (1+ (or (gethash region-id + region-box-count-table) + 0)) + region-box-count-table) + ;; A flex wrapper is visited after its flex owner. Keep + ;; that earlier render-owner mapping instead of + ;; replacing it with the non-renderable source box. + (unless (gethash region-id region-node-table) + (puthash region-id node-id region-node-table)))) + ('flex + (when-let ((box (plist-get node :box))) + (let ((region-id (ebox--ensure-region-id box))) + (puthash region-id t region-id-set) + ;; Preserve the recursive lookup's first-match + ;; semantics when malformed trees reuse a region id. + (unless (gethash region-id region-node-table) + (puthash region-id node-id + region-node-table))))))) + (index-region-box (node) + ;; Box rendering records region ownership after rendering + ;; child content. Preserve that postorder overwrite order + ;; so duplicate legacy ids resolve exactly as they do after + ;; a complete render, including flex container wrappers. + (when region-box-table + (pcase (plist-get node :ebox-type) + ('box + (puthash (ebox--ensure-region-id node) + node region-box-table)) + ('flex + (when-let ((box (plist-get node :box))) + (puthash (ebox--ensure-region-id box) + box region-box-table)))))) + (visit (node parent-id path native-layout-p) + (when (and (listp node) + (not (stringp node))) + (let ((node-id (ebox--ensure-node-id node)) + (type (plist-get node :ebox-type))) + (puthash node-id node node-table) + (when parent-id + (puthash node-id parent-id parent-table)) + (let ((current-path (append path (list node)))) + (index-region-id node node-id) + (index-host-ref node node-id) + (index-selector-id node current-path) + (index-selector-classes node current-path) + (index-selector-type node current-path) + (dolist (child (ebox--node-children node)) + (visit + child node-id current-path + (and native-layout-p + (not (and (eq type 'flex) + (eq child + (plist-get node :box))))))) + (index-region-box node) + ;; This is the same child-before-parent order used by + ;; the recursive Layout IR compiler. Flex wrapper + ;; source boxes and `flex-item' metadata nodes are not + ;; independent native nodes, so retain their runtime + ;; indexes without adding duplicate compiler work. + (when (and native-layout-p + (not (eq type 'flex-item))) + (push node native-node-postorder))))))) + (visit root-node nil nil t)) + (maphash (lambda (id entries) + (puthash id (nreverse entries) selector-id-table)) + selector-id-table) + (maphash (lambda (class entries) + (puthash class (nreverse entries) selector-class-table)) + selector-class-table) + (maphash (lambda (type entries) + (puthash type (nreverse entries) selector-type-table)) + selector-type-table) + (list :node-table node-table + :parent-table parent-table + :region-id-set region-id-set + :region-node-table region-node-table + :region-box-table region-box-table + :region-box-count-table region-box-count-table + :host-ref-table host-ref-table + :selector-id-table selector-id-table + :selector-class-table selector-class-table + :selector-type-table selector-type-table + :native-node-postorder + (vconcat (nreverse native-node-postorder))))) + +(defun ebox-incremental--selector-index (root-node) + "Return selector-only indexes for ROOT-NODE. + +Logical candidates keep their core runtime indexes current immediately but +may defer selector path rebuilding. This focused traversal is therefore used +only by the first selector lookup after publication; it does not rebuild the +node, parent, region, Host, or native-layout indexes." + (let ((selector-id-table (make-hash-table :test 'equal)) + (selector-class-table (make-hash-table :test 'equal)) + (selector-type-table (make-hash-table :test 'eq))) + (cl-labels + ((prepend (table key entry) + (puthash key (cons entry (gethash key table)) table)) + (visit (node path) + (when (and (listp node) (not (stringp node))) + (let* ((current-path (append path (list node))) + (entry (cons node current-path))) + (when-let ((id (ebox-tree-node-id node))) + (prepend selector-id-table id entry)) + (dolist (class (ebox-tree-node-classes node)) + (prepend selector-class-table class entry)) + (when-let ((type (ebox-tree-node-selector-type node))) + (prepend selector-type-table type entry)) + (dolist (child (ebox--node-children node)) + (visit child current-path)))))) + (visit root-node nil)) + (dolist (table (list selector-id-table + selector-class-table + selector-type-table)) + (maphash (lambda (key entries) + (puthash key (nreverse entries) table)) + table)) + (list :selector-id-table selector-id-table + :selector-class-table selector-class-table + :selector-type-table selector-type-table))) + +(defun ebox-incremental--ensure-buffer-selector-indexes (buffer) + "Return BUFFER's state after materializing deferred selector indexes." + (let ((state (ebox--buffer-render-state buffer))) + (when (and state (plist-get state :selector-index-stale-p)) + (let ((index + (ebox-incremental--selector-index + (plist-get state :root-node)))) + (dolist (key '(:selector-id-table + :selector-class-table + :selector-type-table)) + (plist-put state key (plist-get index key))) + (plist-put state :selector-index-stale-p nil))) + state)) + +(defun ebox--set-buffer-render-state (buffer root-node) + "Store ROOT-NODE and current render context for BUFFER." + (let ((index (ebox--runtime-index root-node t))) + (puthash buffer + (list :root-node root-node + :viewport-width ebox-viewport-width + :viewport-height ebox-viewport-height + :display-signature (ebox--current-display-signature) + :layout-snapshots (make-hash-table :test 'equal) + :layout-snapshots-complete-p nil + :layout-snapshot-detail-generation 0 + :runtime-revision 0 + :last-update-report nil + :render-cache (make-hash-table :test 'equal) + :detached-identity-history + (ebox-incremental--make-detached-history + :table (make-hash-table :test 'equal) + :order nil + :node-count 0) + :render-signature-cache (make-hash-table :test 'eq) + :flex-content-min-widths (make-hash-table :test 'eq) + :viewport-height-dependent-subtree-cache + (make-hash-table :test 'eq) + :region-role-span-table nil + :node-table (plist-get index :node-table) + :parent-table (plist-get index :parent-table) + :region-id-set (plist-get index :region-id-set) + :region-node-table (plist-get index :region-node-table) + :region-box-count-table + (plist-get index :region-box-count-table) + :region-box-table + (plist-get index :region-box-table) + :host-ref-table (plist-get index :host-ref-table) + :selector-id-table + (plist-get index :selector-id-table) + :selector-class-table + (plist-get index :selector-class-table) + :selector-type-table + (plist-get index :selector-type-table) + :native-node-postorder + (plist-get index :native-node-postorder)) + ebox--buffer-render-state-table) + (ebox--buffer-viewport-dependent-node-id-axes buffer))) + +(defun ebox--buffer-node-table (buffer) + "Return BUFFER's runtime node table." + (plist-get (ebox--buffer-render-state buffer) :node-table)) + +(defun ebox--buffer-parent-table (buffer) + "Return BUFFER's runtime parent table." + (plist-get (ebox--buffer-render-state buffer) :parent-table)) + +(defun ebox--buffer-region-id-set (buffer) + "Return BUFFER's runtime region id set." + (plist-get (ebox--buffer-render-state buffer) :region-id-set)) + +(defun ebox--buffer-region-node-table (buffer) + "Return BUFFER's region id to render-owner node id table." + (plist-get (ebox--buffer-render-state buffer) :region-node-table)) + +(defun ebox--buffer-region-box-table (buffer) + "Return BUFFER's region id to runtime box table." + (plist-get (ebox--buffer-render-state buffer) :region-box-table)) + +(defun ebox--buffer-host-ref-table (buffer) + "Return BUFFER's opaque host reference to runtime node id table." + (plist-get (ebox--buffer-render-state buffer) :host-ref-table)) + +(defun ebox--buffer-region-render-owner-node-id (buffer region-id) + "Return REGION-ID's smallest render-owner node id in BUFFER. +Use the persistent runtime index when available. Legacy runtime states that +lack the index, or an indexed entry, fall back to a recursive tree lookup." + (or (when-let ((table (ebox--buffer-region-node-table buffer))) + (gethash region-id table)) + (when-let* ((root (ebox--buffer-root-node buffer)) + (path (ebox--node-path-to-region root region-id))) + (ebox--ensure-node-id (car path))))) + +(defun ebox--buffer-region-render-owner-node (buffer region-id) + "Return REGION-ID's smallest render-owner node in BUFFER. +Use both persistent runtime indexes in the normal path. Fall back to the +recursive region path only for legacy or incomplete runtime state." + (when-let ((node-id + (ebox--buffer-region-render-owner-node-id buffer region-id))) + (ebox--buffer-runtime-node buffer node-id))) + +(defun ebox--buffer-region-role-span-table (buffer) + "Return BUFFER's runtime region role span table." + (plist-get (ebox--buffer-render-state buffer) :region-role-span-table)) + +(defun ebox--set-buffer-region-role-span-table (buffer table) + "Store TABLE as BUFFER's runtime region role span table." + (when-let ((state (ebox--buffer-render-state buffer))) + (plist-put state :region-role-span-table table))) + +(defun ebox--buffer-selector-id-table (buffer) + "Return BUFFER's runtime selector id index." + (plist-get (ebox-incremental--ensure-buffer-selector-indexes buffer) + :selector-id-table)) + +(defun ebox--buffer-selector-class-table (buffer) + "Return BUFFER's runtime selector class index." + (plist-get (ebox-incremental--ensure-buffer-selector-indexes buffer) + :selector-class-table)) + +(defun ebox--buffer-selector-type-table (buffer) + "Return BUFFER's runtime selector type index." + (plist-get (ebox-incremental--ensure-buffer-selector-indexes buffer) + :selector-type-table)) + +(defun ebox--buffer-render-cache (buffer) + "Return BUFFER's persistent render cache table." + (plist-get (ebox--buffer-render-state buffer) :render-cache)) + +(defun ebox--buffer-render-signature-cache (buffer) + "Return BUFFER's persistent node body-signature cache." + (plist-get (ebox--buffer-render-state buffer) + :render-signature-cache)) + +(defun ebox--buffer-viewport-height-subtree-cache (buffer) + "Return BUFFER's persistent viewport-height subtree cache." + (plist-get (ebox--buffer-render-state buffer) + :viewport-height-dependent-subtree-cache)) + +(defun ebox--bump-buffer-runtime-revision + (buffer &optional preserve-reflow-prewarm-scratch) + "Increment and return BUFFER's runtime mutation revision. +Discard the isolated reflow scratch tree unless +PRESERVE-REFLOW-PREWARM-SCRATCH is non-nil. Callers may preserve it only +while validating one predicted root-width mutation against the new revision." + (when-let ((state (ebox--buffer-render-state buffer))) + (let ((revision + (1+ (or (plist-get state :runtime-revision) 0)))) + (plist-put state :runtime-revision revision) + (unless preserve-reflow-prewarm-scratch + (plist-put state :reflow-prewarm-scratch nil)) + revision))) + +(defun ebox--buffer-viewport-dependent-node-id-axes (buffer) + "Return cached viewport-dependent node ids by axis for BUFFER." + (when-let ((state (ebox--buffer-render-state buffer))) + (if (and (plist-get state :viewport-dependent-node-ids-ready) + (plist-member state :viewport-dependent-node-id-axes)) + (plist-get state :viewport-dependent-node-id-axes) + (let* ((root (plist-get state :root-node)) + (axes (and root + (let ((ebox--viewport-dependent-node-ids-cache + (make-hash-table :test 'eq)) + (ebox--viewport-height-dependent-subtree-cache + (or (ebox--buffer-viewport-height-subtree-cache + buffer) + (make-hash-table :test 'eq)))) + (ebox--viewport-dependent-node-id-axes root))))) + (plist-put state :viewport-dependent-node-id-axes axes) + (plist-put state :viewport-dependent-node-ids + (and axes + (delete-dups + (copy-sequence + (append (car axes) (cdr axes)))))) + (plist-put state :viewport-dependent-node-ids-ready t) + axes)))) + +(defun ebox--buffer-viewport-dependent-node-ids (buffer) + "Return cached viewport-dependent node ids for BUFFER." + (when-let ((state (ebox--buffer-render-state buffer))) + (ebox--buffer-viewport-dependent-node-id-axes buffer) + (plist-get state :viewport-dependent-node-ids))) + +(defun ebox--invalidate-buffer-viewport-dependencies (buffer) + "Invalidate BUFFER's cached viewport dependency list." + (when-let ((state (ebox--buffer-render-state buffer))) + (plist-put state :viewport-dependent-node-ids nil) + (plist-put state :viewport-dependent-node-id-axes nil) + (plist-put state :viewport-dependent-node-ids-ready nil))) + +(defun ebox--ensure-buffer-layout-snapshots (buffer) + "Return BUFFER's layout snapshot table, creating it when needed." + (let ((state (ebox--buffer-render-state buffer))) + (when state + (or (plist-get state :layout-snapshots) + (let ((snapshots (make-hash-table :test 'equal))) + (plist-put state :layout-snapshots snapshots) + snapshots))))) + +(defconst ebox--layout-snapshot-detail-keys + '(:buffer-span :buffer-spans :line-signature + :span-footprint-signature :external-footprint-signature + :parent-slot-signature :role-topology-signature + :overflow-signature :detail-generation) + "Expensive layout snapshot detail keys that depend on current buffer spans.") + +(defun ebox--buffer-layout-snapshot-detail-generation (buffer) + "Return BUFFER's current layout snapshot detail generation." + (or (plist-get (ebox--buffer-render-state buffer) + :layout-snapshot-detail-generation) + 0)) + +(defun ebox--layout-snapshot-details-current-p (buffer snapshot) + "Return non-nil when SNAPSHOT's buffer-dependent detail fields are current." + (or (not (cl-some (lambda (key) (plist-member snapshot key)) + ebox--layout-snapshot-detail-keys)) + (= (or (plist-get snapshot :detail-generation) -1) + (ebox--buffer-layout-snapshot-detail-generation buffer)))) + +(defun ebox--layout-snapshot (buffer node-id) + "Return BUFFER's layout snapshot for NODE-ID." + (when-let ((snapshots (ebox--ensure-buffer-layout-snapshots buffer))) + (let ((snapshot (or (gethash node-id snapshots) + (when-let ((node (ebox--snapshot-published-node + buffer node-id))) + (let ((created + (ebox--node-layout-snapshot + buffer node nil))) + (puthash node-id created snapshots) + created))))) + (when snapshot + (if (ebox--layout-snapshot-details-current-p buffer snapshot) + snapshot + (let ((stripped (ebox--layout-snapshot-strip-details snapshot))) + (puthash node-id stripped snapshots) + stripped)))))) + +(defvar ebox--region-line-index nil + "Dynamic snapshot-detail-local line/region index. +This is an optimization for completing snapshot details, not persistent state.") + +(defvar ebox--region-line-index-buffer nil + "Dynamic snapshot-detail-local buffer used when a line index is available.") + +(defmacro ebox--with-layout-snapshot-detail-context (buffer &rest body) + "Evaluate BODY with shared node caches for BUFFER snapshot detail capture. +This context is intentionally lazy and does not build a full region-line index. +Use `ebox--with-layout-snapshot-index-context' for batch snapshot capture." + (declare (indent 1) (debug t)) + (let ((snapshot-buffer (make-symbol "snapshot-buffer"))) + `(let ((,snapshot-buffer ,buffer)) + (let ((ebox--region-line-index + ebox--region-line-index) + (ebox--region-line-index-buffer + (or ebox--region-line-index-buffer ,snapshot-buffer)) + (ebox--node-region-ids-cache + (or ebox--node-region-ids-cache + (make-hash-table :test 'eq)))) + ,@body)))) + +(defmacro ebox--with-layout-snapshot-index-context (buffer &rest body) + "Evaluate BODY with shared node caches and an eager line index for BUFFER." + (declare (indent 1) (debug t)) + (let ((snapshot-buffer (make-symbol "snapshot-buffer"))) + `(let ((,snapshot-buffer ,buffer)) + (let ((ebox--region-line-index + (or ebox--region-line-index + (and (buffer-live-p ,snapshot-buffer) + (with-current-buffer ,snapshot-buffer + (ebox--build-region-line-index))))) + (ebox--region-line-index-buffer ,snapshot-buffer) + (ebox--node-region-ids-cache + (or ebox--node-region-ids-cache + (make-hash-table :test 'eq)))) + ,@body)))) + +(defun ebox--current-layout-snapshots (buffer &optional details) + "Return a copy of BUFFER's current layout snapshot table. +When DETAILS is non-nil, include derived buffer spans and line signatures in +the returned copy without mutating BUFFER's stored snapshot table." + (when-let ((state (ebox--buffer-render-state buffer))) + (when (and (plist-get state :root-node) + (not (plist-get state :layout-snapshots-complete-p))) + (ebox--refresh-buffer-layout-snapshots buffer details))) + (let ((copy (make-hash-table :test 'equal))) + (when-let ((snapshots (ebox--ensure-buffer-layout-snapshots buffer))) + (let (entries) + (maphash (lambda (node-id snapshot) + (push (cons node-id + (if (ebox--layout-snapshot-details-current-p + buffer snapshot) + snapshot + (ebox--layout-snapshot-strip-details + snapshot))) + entries)) + snapshots) + (if details + (ebox--with-layout-snapshot-index-context buffer + (dolist (entry entries) + (let* ((node-id (car entry)) + (snapshot (cdr entry)) + (node (ebox--buffer-runtime-node buffer node-id))) + (puthash node-id + (if node + (ebox--complete-layout-snapshot + buffer node snapshot t) + (copy-sequence snapshot)) + copy)))) + (dolist (entry entries) + (puthash (car entry) + (copy-sequence (cdr entry)) + copy))))) + copy)) + +(defun ebox--put-layout-snapshot (buffer node-id snapshot) + "Store SNAPSHOT for NODE-ID in BUFFER render state." + (puthash node-id snapshot (ebox--ensure-buffer-layout-snapshots buffer))) + +(defun ebox--clear-layout-snapshots (buffer) + "Clear BUFFER's layout snapshots." + (when-let ((state (ebox--buffer-render-state buffer))) + (plist-put state :layout-snapshots-complete-p nil)) + (when-let ((snapshots (ebox--ensure-buffer-layout-snapshots buffer))) + (clrhash snapshots))) + +(defun ebox--plist-remove-key (plist key) + "Return PLIST without KEY and its value." + (let (result) + (while plist + (let ((current-key (pop plist)) + (value (pop plist))) + (unless (eq current-key key) + (push current-key result) + (push value result)))) + (nreverse result))) + +(defun ebox--layout-snapshot-strip-details (snapshot) + "Return SNAPSHOT with stale buffer-dependent detail fields removed." + (let ((result (copy-sequence snapshot))) + (dolist (key ebox--layout-snapshot-detail-keys result) + (setq result (ebox--plist-remove-key result key))))) + +(defun ebox--invalidate-buffer-layout-snapshot-details (buffer) + "Invalidate BUFFER's snapshot detail fields while keeping lightweight facts." + (when-let ((state (ebox--buffer-render-state buffer))) + (plist-put state :layout-snapshot-detail-generation + (1+ (or (plist-get state + :layout-snapshot-detail-generation) + 0))))) + +(defun ebox--clear-buffer-render-state (&optional buffer) + "Remove stored buffer-owned runtime state for BUFFER. +Defaults to the current buffer." + (let ((buffer (or buffer (current-buffer)))) + (when-let ((cache (ebox--buffer-render-cache buffer))) + (when-let ((root-cache + (gethash cache ebox--render-root-cache-table-table))) + (remhash root-cache ebox--render-cache-ring-table)) + (remhash cache ebox--render-root-cache-table-table) + (remhash cache ebox--render-cache-ring-table)) + (remhash buffer ebox--buffer-render-state-table) + (remhash buffer ebox-incremental--batch-table) + (ebox-cache-discard-report buffer))) + +;;; Runtime Buffer Lookup + +(defun ebox--prune-dead-buffer-render-states () + "Remove killed buffers from buffer-owned runtime tables." + (let (dead-buffers) + (maphash + (lambda (buffer _state) + (unless (buffer-live-p buffer) + (push buffer dead-buffers))) + ebox--buffer-render-state-table) + (dolist (buffer dead-buffers) + (ebox--clear-buffer-render-state buffer)))) + +;;; Snapshot Dirty Patch Runtime + +(defun ebox--region-id-set (region-ids) + "Return a hash set for REGION-IDS." + (let ((set (make-hash-table :test 'equal))) + (dolist (region-id region-ids) + (puthash region-id t set)) + set)) + +(defun ebox--pos-region-ids (pos) + "Return all ebox region ids present at POS." + (let (ids) + (dolist (region-id (get-text-property pos 'ebox-content-owners)) + (cl-pushnew region-id ids :test #'equal)) + (dolist (entry ebox-region-types) + (when-let ((region-id (get-text-property pos (cdr entry)))) + (cl-pushnew region-id ids :test #'equal))) + ids)) + +(defun ebox--pos-region-role-ids (pos) + "Return all ebox region role/id pairs present at POS." + (let (role-ids) + (dolist (entry ebox-region-types) + (when-let ((region-id (get-text-property pos (cdr entry)))) + (push (cons (car entry) region-id) role-ids))) + role-ids)) + +(defun ebox--next-region-property-change (pos limit) + "Return the next ebox region property change after POS, capped at LIMIT." + (let ((next limit)) + (dolist (entry ebox-region-types) + (let ((change (next-single-property-change + pos (cdr entry) nil limit))) + (when (< change next) + (setq next change)))) + next)) + +(defun ebox--index-add-region-line-range + (region-lines region-id line-number start end) + "Record REGION-ID ownership from START to END on LINE-NUMBER." + (let ((line-map (or (gethash region-id region-lines) + (let ((map (make-hash-table :test 'eql))) + (puthash region-id map region-lines) + map)))) + (if-let ((range (gethash line-number line-map))) + (setcdr range (max (cdr range) end)) + (puthash line-number (cons start end) line-map)))) + +(defun ebox--build-region-line-index () + "Return a per-line index of ebox region ownership in the current buffer." + (let ((region-lines (make-hash-table :test 'equal)) + (line-number 0) + lines) + (save-excursion + (goto-char (point-min)) + (while (< (point) (point-max)) + (let ((line-end (line-end-position)) + segments) + (let ((pos (point))) + (while (< pos line-end) + (let* ((role-ids (ebox--pos-region-role-ids pos)) + (ids (delete-dups + (mapcar #'cdr (copy-sequence role-ids)))) + (next (ebox--next-region-property-change + pos line-end))) + (when ids + (push (list :start pos :end next + :ids ids :role-ids role-ids) + segments)) + (setq pos (max next (1+ pos)))))) + (setq segments (nreverse segments)) + (dolist (segment segments) + (dolist (region-id (plist-get segment :ids)) + (ebox--index-add-region-line-range + region-lines region-id line-number + (plist-get segment :start) + (plist-get segment :end)))) + (push (list :segments segments) lines)) + (setq line-number (1+ line-number)) + (forward-line 1))) + (list :lines (vconcat (nreverse lines)) + :region-lines region-lines))) + +(defun ebox--build-region-line-index-for-spans (spans) + "Return a dense region ownership index limited to current buffer SPANS." + (let ((region-lines (make-hash-table :test 'equal)) + (line-number 0) + lines) + (save-excursion + (dolist (span spans) + (let ((end (cdr span))) + (goto-char (car span)) + (while (< (point) end) + (let ((line-end (min (line-end-position) end)) + segments) + (let ((pos (point))) + (while (< pos line-end) + (let* ((role-ids (ebox--pos-region-role-ids pos)) + (ids (delete-dups + (mapcar #'cdr (copy-sequence role-ids)))) + (next (ebox--next-region-property-change + pos line-end))) + (when ids + (push (list :start pos :end next + :ids ids :role-ids role-ids) + segments)) + (setq pos (max next (1+ pos)))))) + (setq segments (nreverse segments)) + (dolist (segment segments) + (dolist (region-id (plist-get segment :ids)) + (ebox--index-add-region-line-range + region-lines region-id line-number + (plist-get segment :start) + (plist-get segment :end)))) + (push (list :segments segments) lines) + (setq line-number (1+ line-number))) + (forward-line 1))))) + (list :lines (vconcat (nreverse lines)) + :region-lines region-lines))) + +(defun ebox--region-id-member-p (region-id region-set) + "Return non-nil when REGION-ID belongs to REGION-SET." + (gethash region-id region-set)) + +(defun ebox--region-ids-intersect-set-p (region-ids region-set) + "Return non-nil when any REGION-IDS entry belongs to REGION-SET." + (cl-some (lambda (region-id) + (ebox--region-id-member-p region-id region-set)) + region-ids)) + +(defun ebox--region-ids-subset-p (region-ids region-set) + "Return non-nil when every REGION-IDS entry belongs to REGION-SET." + (cl-every (lambda (region-id) + (ebox--region-id-member-p region-id region-set)) + region-ids)) + +(defun ebox--region-role-ids-compatible-p (role-ids region-set) + "Return non-nil when ROLE-IDS do not contain foreign blocking owners. +Wrapper and decoration roles may enclose a child patch span. A foreign direct +`content' owner means the candidate span crosses another editable box and is +therefore unsafe." + (cl-every (lambda (role-id) + (or (not (eq (car role-id) 'content)) + (ebox--region-id-member-p (cdr role-id) region-set))) + role-ids)) + +(defun ebox--segment-overlaps-span-p (segment start end) + "Return non-nil when SEGMENT overlaps START..END." + (and (< (plist-get segment :start) end) + (> (plist-get segment :end) start))) + +(defun ebox--region-set-line-spans-from-index (region-ids index) + "Return per-line spans for REGION-IDS using precomputed INDEX." + (let ((region-set (ebox--region-id-set region-ids)) + (lines (plist-get index :lines)) + (region-lines (plist-get index :region-lines)) + (candidate-lines (make-hash-table :test 'eql)) + spans) + (dolist (region-id region-ids) + (when-let ((line-map (gethash region-id region-lines))) + (maphash + (lambda (line-number range) + (if-let ((existing (gethash line-number candidate-lines))) + (progn + (setcar existing (min (car existing) (car range))) + (setcdr existing (max (cdr existing) (cdr range)))) + (puthash line-number (copy-tree range) candidate-lines))) + line-map))) + (dolist (line-number + (sort (let (keys) + (maphash (lambda (key _value) + (push key keys)) + candidate-lines) + keys) + #'<)) + (let* ((span (gethash line-number candidate-lines)) + (span-start (car span)) + (span-end (cdr span)) + (segments (plist-get (aref lines line-number) :segments)) + (patchable t)) + (dolist (segment segments) + (when (and patchable + (ebox--segment-overlaps-span-p + segment span-start span-end) + (not (ebox--region-role-ids-compatible-p + (or (plist-get segment :role-ids) + (mapcar (lambda (region-id) + (cons 'content region-id)) + (plist-get segment :ids))) + region-set))) + (setq patchable nil))) + (when patchable + (push (cons span-start span-end) spans)))) + (nreverse spans))) + +(defun ebox--add-role-span-line-ranges (line-ranges span) + "Add per-line coverage from SPAN to LINE-RANGES." + (save-excursion + (let ((end (cdr span))) + (goto-char (car span)) + (while (< (point) end) + (let* ((line-start (line-beginning-position)) + (line-end (line-end-position)) + (range-start (max (point) line-start)) + (range-end (min end line-end))) + (when (< range-start range-end) + (if-let ((existing (gethash line-start line-ranges))) + (progn + (setcar existing (min (car existing) range-start)) + (setcdr existing (max (cdr existing) range-end))) + (puthash line-start (cons range-start range-end) + line-ranges))) + (forward-line 1)))))) + +(defun ebox--role-table-region-spans (table region-id role) + "Return TABLE spans for REGION-ID ROLE." + (copy-sequence + (gethash (ebox-buffer--region-role-key region-id role) table))) + +(defun ebox--role-compatible-span-p (span region-set) + "Return non-nil when SPAN does not cross foreign content owners." + (let ((pos (car span)) + (end (cdr span)) + (compatible t)) + (while (and compatible (< pos end)) + (let ((next (or (next-single-property-change + pos 'ebox-content nil end) + end)) + (content-owner (get-text-property pos 'ebox-content))) + (when (and content-owner + (not (ebox--region-id-member-p + content-owner region-set))) + (setq compatible nil)) + (setq pos (max next (1+ pos))))) + compatible)) + +(defun ebox--region-set-line-spans-from-role-table (region-ids table) + "Return per-line spans for REGION-IDS using role span TABLE." + (let ((region-set (ebox--region-id-set region-ids)) + (line-ranges (make-hash-table :test 'eql)) + spans) + (dolist (region-id region-ids) + (dolist (entry ebox-region-types) + (dolist (span (ebox--role-table-region-spans + table region-id (car entry))) + (ebox--add-role-span-line-ranges line-ranges span)))) + (dolist (line-start + (sort (let (keys) + (maphash (lambda (key _value) + (push key keys)) + line-ranges) + keys) + #'<)) + (let ((span (gethash line-start line-ranges))) + (when (ebox--role-compatible-span-p span region-set) + (push (copy-tree span) spans)))) + (nreverse spans))) + +(defun ebox--merge-scan-line-ranges (ranges) + "Return sorted, merged line RANGES." + (let (merged) + (dolist (range (sort (copy-sequence ranges) + (lambda (left right) + (< (car left) (car right)))) + (nreverse merged)) + (if (and merged (<= (car range) (cdar merged))) + (setcdr (car merged) (max (cdar merged) (cdr range))) + (push (copy-tree range) merged))))) + +(defun ebox--line-range-from-live-box-extents (extents) + "Return a line-expanded scan range from live EXTENTS in the current buffer." + (when-let* ((start-marker (car-safe extents)) + (end-marker (cdr-safe extents)) + ((markerp start-marker)) + ((markerp end-marker)) + (buffer (marker-buffer start-marker)) + ((eq buffer (current-buffer))) + ((eq buffer (marker-buffer end-marker))) + (start (marker-position start-marker)) + (end (marker-position end-marker)) + ((< start end))) + (save-excursion + (goto-char start) + (let ((line-start (line-beginning-position))) + (goto-char (max (point-min) (1- end))) + (cons line-start (line-end-position)))))) + +(defun ebox--region-set-live-extents-line-ranges (region-ids) + "Return line scan ranges for REGION-IDS, or nil if any extent is missing." + (when region-ids + (catch 'missing + (let (ranges) + (dolist (region-id region-ids) + (let ((range (and-let* ((extents + (ebox--live-box-extents region-id))) + (ebox--line-range-from-live-box-extents extents)))) + (unless range + (throw 'missing nil)) + (push range ranges))) + (ebox--merge-scan-line-ranges ranges))))) + +(defun ebox--region-set-line-spans-by-scan (region-ids &optional line-ranges) + "Return patchable per-line spans for REGION-IDS by scanning LINE-RANGES. +When LINE-RANGES is nil, scan the full current buffer." + (let ((region-set (ebox--region-id-set region-ids)) + (ranges (or line-ranges + (list (cons (point-min) (point-max))))) + spans + patchable) + (setq patchable t) + (save-excursion + (dolist (range ranges) + (when patchable + (let ((scan-start (car range)) + (scan-end (cdr range))) + (goto-char scan-start) + (while (and patchable (< (point) scan-end)) + (let ((line-start (point)) + (line-end (min (line-end-position) scan-end)) + span-start + span-end) + (let ((pos line-start)) + (while (< pos line-end) + (when (ebox--region-ids-intersect-set-p + (ebox--pos-region-ids pos) + region-set) + (unless span-start + (setq span-start pos)) + (setq span-end (1+ pos))) + (setq pos (1+ pos)))) + (when span-start + (let ((pos span-start)) + (while (and patchable (< pos span-end)) + (unless (ebox--region-role-ids-compatible-p + (ebox--pos-region-role-ids pos) + region-set) + (setq patchable nil)) + (setq pos (1+ pos)))) + (when patchable + (push (cons span-start span-end) spans)))) + (forward-line 1)))))) + (when patchable + (nreverse spans)))) + +(defun ebox--region-set-line-spans (region-ids) + "Return patchable per-line spans owned by REGION-IDS. +If a line span would cross a foreign ebox region, return nil." + (if ebox--region-line-index + (ebox--region-set-line-spans-from-index + region-ids ebox--region-line-index) + (or (when-let ((table (ebox-buffer--region-role-span-table))) + (ebox--region-set-line-spans-from-role-table region-ids table)) + (ebox--region-set-line-spans-by-scan + region-ids + (ebox--region-set-live-extents-line-ranges region-ids))))) + +(defun ebox--spans-line-signature (spans) + "Return pixel-width signature for current buffer SPANS." + (mapcar (lambda (span) + (ebox--string-pixel-width + (buffer-substring (car span) (cdr span)))) + spans)) + +(defun ebox--rendered-line-signature (rendered) + "Return pixel-width signature for RENDERED." + (mapcar #'ebox--string-pixel-width (ebox-string-lines rendered))) + +(defun ebox--safe-max-number (numbers) + "Return the maximum number in NUMBERS, or 0 for an empty list." + (if numbers + (apply #'max numbers) + 0)) + +(defun ebox--span-whole-line-p (span) + "Return non-nil when SPAN covers the whole current buffer line." + (save-excursion + (goto-char (car span)) + (and (= (car span) (line-beginning-position)) + (= (cdr span) (line-end-position))))) + +(defun ebox--spans-contiguous-lines-p (spans) + "Return non-nil when SPANS cover adjacent current buffer lines." + (or (null spans) + (cl-loop for rest on spans + while (cdr rest) + always (= (caadr rest) (1+ (cdar rest)))))) + +(defun ebox--span-footprint-signature + (span-count pixel-widths char-lengths &optional whole-line-flags + contiguous-lines) + "Return the normalized footprint signature for replaceable spans." + (list :span-count span-count + :line-pixel-widths pixel-widths + :char-lengths char-lengths + :whole-line-flags whole-line-flags + :contiguous-lines contiguous-lines)) + +(defun ebox--spans-span-footprint-signature (spans) + "Return the L2 span-footprint signature for current buffer SPANS." + (let ((pixel-widths (ebox--spans-line-signature spans)) + char-lengths + whole-line-flags) + (dolist (span spans) + (push (- (cdr span) (car span)) char-lengths) + (push (ebox--span-whole-line-p span) whole-line-flags)) + (ebox--span-footprint-signature + (length spans) + pixel-widths + (nreverse char-lengths) + (nreverse whole-line-flags) + (ebox--spans-contiguous-lines-p spans)))) + +(defun ebox--rendered-span-footprint-signature (rendered) + "Return the L2 span-footprint signature for RENDERED." + (let* ((lines (ebox-string-lines rendered)) + (pixel-widths (mapcar #'ebox--string-pixel-width lines)) + (char-lengths (mapcar #'length lines))) + (ebox--span-footprint-signature + (length lines) + pixel-widths + char-lengths + (make-list (length lines) t) + t))) + +(defun ebox--external-footprint-signature-from-span-footprint (signature) + "Return parent-visible footprint facts derived from SIGNATURE." + (let ((pixel-widths (plist-get signature :line-pixel-widths))) + (list :block-lines (plist-get signature :span-count) + :max-line-pixel-width (ebox--safe-max-number pixel-widths)))) + +(defun ebox--span-parent-slot (span) + "Return parent-facing placement facts for SPAN in the current buffer." + (save-excursion + (goto-char (car span)) + (let* ((line-start (line-beginning-position)) + (start-pixel + (ebox--string-pixel-width + (buffer-substring line-start (car span)))) + (width + (ebox--string-pixel-width + (buffer-substring (car span) (cdr span))))) + (list :line (line-number-at-pos (car span) t) + :start-pixel start-pixel + :end-pixel (+ start-pixel width) + :whole-line (ebox--span-whole-line-p span))))) + +(defun ebox--spans-parent-slot-signature (spans) + "Return parent-facing slot signature for current buffer SPANS." + (list :slot-count (length spans) + :slots (mapcar #'ebox--span-parent-slot spans))) + +(defun ebox--project-parent-slot-signature + (old-parent-slot new-span-footprint) + "Return the projected parent slot after applying NEW-SPAN-FOOTPRINT." + (let ((widths (plist-get new-span-footprint :line-pixel-widths)) + projected-slots) + (cl-loop for slot in (plist-get old-parent-slot :slots) + for width in widths + do (push (plist-put (copy-sequence slot) + :end-pixel + (+ (plist-get slot :start-pixel) width)) + projected-slots)) + (list :slot-count (plist-get new-span-footprint :span-count) + :slots (nreverse projected-slots)))) + +(defun ebox--parent-slot-compatible-p (old new) + "Return non-nil when OLD and NEW parent slots are equivalent." + (equal old new)) + +(defun ebox--role-topology-signature (region-ids) + "Return the set of rendered region roles present for REGION-IDS." + (let ((region-set (ebox--region-id-set region-ids)) + (role-set (make-hash-table :test 'eq)) + roles) + (if-let ((table (ebox-buffer--region-role-span-table))) + (maphash + (lambda (key spans) + (when (and spans (consp key) (gethash (car key) region-set)) + (puthash (cadr key) t role-set))) + table) + (dolist (region-id region-ids) + (dolist (entry ebox-region-types) + (when (ebox--region-find region-id (car entry)) + (puthash (car entry) t role-set))))) + (maphash (lambda (role _) (push role roles)) role-set) + (list :roles + (sort roles + (lambda (left right) + (string< (symbol-name left) + (symbol-name right))))))) + +(defun ebox--overflow-signature (node) + "Return visible-overflow facts for NODE." + (list :visible (and node (ebox--node-visible-overflow-p node)))) + +(defun ebox--span-footprint-compatible-p (old new) + "Return non-nil when OLD and NEW span footprints are L2-compatible." + (and old new + (= (plist-get old :span-count) + (plist-get new :span-count)) + (equal (plist-get old :line-pixel-widths) + (plist-get new :line-pixel-widths)))) + +(defun ebox--external-footprint-compatible-p (old new) + "Return non-nil when OLD and NEW parent-visible footprints match." + (equal old new)) + +(defun ebox--buffer-line-spans (buffer) + "Return whole-line spans for BUFFER." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (let (spans) + (save-excursion + (goto-char (point-min)) + (while (< (point) (point-max)) + (push (cons (line-beginning-position) + (line-end-position)) + spans) + (forward-line 1))) + (nreverse spans))))) + +(defun ebox--complete-layout-snapshot-spans + (buffer node snapshot &optional copy) + "Return SNAPSHOT with buffer spans for NODE in BUFFER. +When COPY is non-nil, complete and return a copied snapshot without mutating +the original plist." + (let ((snapshot (if copy (copy-sequence snapshot) snapshot))) + (if (ebox--layout-snapshot-spans-p snapshot) + snapshot + (let* ((region-ids (or (plist-get snapshot :region-ids) + (ebox--node-all-region-ids node))) + (spans (if (equal (plist-get snapshot :node-id) + (ebox--buffer-root-node-id buffer)) + (ebox--buffer-line-spans buffer) + (and region-ids + (buffer-live-p buffer) + (with-current-buffer buffer + (ebox--region-set-line-spans region-ids)))))) + (setq snapshot (plist-put snapshot :region-ids region-ids)) + (setq snapshot (plist-put snapshot :buffer-spans spans)) + (setq snapshot + (plist-put snapshot :detail-generation + (ebox--buffer-layout-snapshot-detail-generation + buffer))) + snapshot)))) + +(defun ebox--complete-layout-snapshot (buffer node snapshot &optional copy) + "Return SNAPSHOT with derived spans and line signature for NODE in BUFFER. +When COPY is non-nil, complete and return a copied snapshot without mutating +the original plist." + (let ((snapshot (ebox--complete-layout-snapshot-spans + buffer node snapshot copy))) + (if (ebox--layout-snapshot-detailed-p snapshot) + snapshot + (let* ((spans (plist-get snapshot :buffer-spans)) + (span-footprint + (and spans + (with-current-buffer buffer + (ebox--spans-span-footprint-signature spans)))) + (line-signature + (plist-get span-footprint :line-pixel-widths)) + (external-footprint + (and span-footprint + (ebox--external-footprint-signature-from-span-footprint + span-footprint))) + (parent-slot + (and spans + (with-current-buffer buffer + (ebox--spans-parent-slot-signature spans)))) + (role-topology + (and (plist-get snapshot :region-ids) + (with-current-buffer buffer + (ebox--role-topology-signature + (plist-get snapshot :region-ids))))) + (overflow (ebox--overflow-signature node))) + (setq snapshot (plist-put snapshot :line-signature line-signature)) + (setq snapshot + (plist-put snapshot + :span-footprint-signature span-footprint)) + (setq snapshot + (plist-put snapshot + :external-footprint-signature external-footprint)) + (setq snapshot + (plist-put snapshot :parent-slot-signature parent-slot)) + (setq snapshot + (plist-put snapshot :role-topology-signature role-topology)) + (setq snapshot + (plist-put snapshot :overflow-signature overflow)) + snapshot)))) + +(defun ebox--snapshot-published-node (buffer node-id) + "Return the node whose PUBLISHED rendering a snapshot must describe. +During a logical-candidate planning pass the runtime lookup resolves +path-copied candidate nodes whose children already reflect the next +tree, but stored layout snapshots describe the still-published +buffer. Completing a snapshot from a candidate node drops regions +the candidate no longer contains, producing spans with holes and a +misplaced text replacement, so the base state's node wins whenever +the planning pass has one for this buffer." + (or (when-let* ((base ebox-incremental--candidate-base-state) + ((eq buffer (car base))) + (node-table (plist-get (cdr base) :node-table))) + (gethash node-id node-table)) + (ebox--buffer-runtime-node buffer node-id))) + +(defun ebox--ensure-layout-snapshot-spans (buffer node-id) + "Ensure BUFFER's stored snapshot for NODE-ID has buffer span details." + (when-let ((snapshot (ebox--layout-snapshot buffer node-id))) + (if (ebox--layout-snapshot-spans-p snapshot) + snapshot + (when-let ((node (ebox--snapshot-published-node buffer node-id))) + (let ((detailed + (ebox--with-layout-snapshot-detail-context buffer + (ebox--complete-layout-snapshot-spans + buffer node snapshot)))) + (ebox--put-layout-snapshot buffer node-id detailed) + detailed))))) + +(defun ebox--ensure-layout-snapshot-details (buffer node-id) + "Ensure BUFFER's stored snapshot for NODE-ID has expensive detail fields." + (when-let ((snapshot (ebox--layout-snapshot buffer node-id))) + (if (ebox--layout-snapshot-detailed-p snapshot) + snapshot + (when-let ((node (ebox--snapshot-published-node buffer node-id))) + (let ((detailed + (ebox--with-layout-snapshot-detail-context buffer + (ebox--complete-layout-snapshot buffer node snapshot)))) + (ebox--put-layout-snapshot buffer node-id detailed) + detailed))))) + +(defun ebox--capture-layout-snapshots (buffer node &optional details) + "Capture derived layout snapshots for NODE's runtime tree in BUFFER. +When DETAILS is non-nil, include expensive per-line detail fields." + (when (and (buffer-live-p buffer) + (listp node)) + (let ((node-id (ebox--ensure-node-id node))) + (ebox--put-layout-snapshot + buffer node-id (ebox--node-layout-snapshot buffer node details)) + (dolist (child (ebox--node-children node)) + (ebox--capture-layout-snapshots buffer child details))))) + +(defun ebox--layout-snapshots-for-runtime (buffer node &optional details) + "Return derived layout snapshots for NODE in BUFFER without storing them. +When DETAILS is non-nil, include expensive per-line detail fields." + (let ((snapshots (make-hash-table :test 'equal)) + (ebox--node-region-ids-cache (make-hash-table :test 'eq))) + (cl-labels ((visit (current) + (when (and (buffer-live-p buffer) + (listp current)) + (let ((node-id (ebox--ensure-node-id current))) + (puthash node-id + (ebox--node-layout-snapshot + buffer current details) + snapshots) + (dolist (child (ebox--node-children current)) + (visit child)))))) + (if details + (ebox--with-layout-snapshot-index-context buffer + (visit node)) + (visit node))) + snapshots)) + +(defun ebox--refresh-buffer-layout-snapshots (buffer &optional details) + "Recapture BUFFER's layout snapshots from its stored runtime root. +When DETAILS is non-nil, include expensive per-line detail fields." + (when-let ((root (ebox--buffer-root-node buffer))) + (ebox--clear-layout-snapshots buffer) + (let ((ebox--node-region-ids-cache (make-hash-table :test 'eq))) + (if details + (ebox--with-layout-snapshot-index-context buffer + (ebox--capture-layout-snapshots buffer root t)) + (ebox--capture-layout-snapshots buffer root))) + (when-let ((state (ebox--buffer-render-state buffer))) + (plist-put state :layout-snapshots-complete-p t)))) + +(defconst ebox--dirty-kind-order + '(paint span geometry placement structure) + "Dirty kinds ordered from cheapest to most disruptive.") + +(defun ebox--dirty-entry (node-id kind &rest props) + "Return a dirty-set entry for NODE-ID and KIND." + (append (list :node-id node-id :dirty-kind kind) props)) + +(defun ebox--dirty-set-kinds (dirty-set) + "Return ordered distinct dirty kinds from DIRTY-SET." + (let (kinds) + (dolist (entry dirty-set (nreverse kinds)) + (let ((kind (plist-get entry :dirty-kind))) + (unless (memq kind kinds) + (push kind kinds)))))) + +(defun ebox--dirty-set-changed-keys (dirty-set) + "Return ordered distinct changed keys from DIRTY-SET." + (let (keys) + (dolist (entry dirty-set (nreverse keys)) + (dolist (key (plist-get entry :changed-keys)) + (unless (memq key keys) + (push key keys)))))) + +(defun ebox--dirty-set-impact-vector (dirty-set) + "Return ordered distinct impact facts from DIRTY-SET." + (let (impacts) + (dolist (entry dirty-set (nreverse impacts)) + (dolist (impact (plist-get entry :impact-vector)) + (unless (memq impact impacts) + (push impact impacts)))))) + +(defun ebox--constraint-change + (source owner-id owner-type root-node-id dirty-set &rest props) + "Return a normalized constraint-change descriptor." + (append (list :constraint-source source + :constraint-owner-id owner-id + :constraint-owner-type owner-type + :constraint-root-node-id root-node-id + :dirty-set dirty-set) + props)) + +(defun ebox--constraint-change-report-props (change) + "Return update-report properties describing CHANGE." + (let ((dirty-set (plist-get change :dirty-set))) + (list :constraint-source (plist-get change :constraint-source) + :constraint-owner-id (plist-get change :constraint-owner-id) + :constraint-owner-type (plist-get change :constraint-owner-type) + :constraint-root-node-id (plist-get change :constraint-root-node-id) + :dirty-kinds (ebox--dirty-set-kinds dirty-set) + :dirty-keys (ebox--dirty-set-changed-keys dirty-set) + :impact-vector (ebox--dirty-set-impact-vector dirty-set)))) + +(defun ebox--diff-layout-snapshots (old-table new-table) + "Return dirty-set entries comparing OLD-TABLE and NEW-TABLE." + (let (dirty) + (maphash + (lambda (node-id old) + (unless (gethash node-id new-table) + (push (ebox--dirty-entry node-id 'structure + :old old :new nil) + dirty))) + old-table) + (maphash + (lambda (node-id new) + (let* ((old (gethash node-id old-table)) + (kind (ebox--layout-snapshot-dirty-kind old new))) + (when kind + (push (ebox--dirty-entry node-id kind + :old old :new new) + dirty)))) + new-table) + (nreverse dirty))) + +(defun ebox--runtime-node-by-id (node node-id) + "Return runtime NODE's descendant with NODE-ID." + (cond + ((or (stringp node) (not (listp node))) nil) + ((equal (ebox--ensure-node-id node) node-id) node) + (t + (cl-some (lambda (child) + (ebox--runtime-node-by-id child node-id)) + (ebox--node-children node))))) + +(defun ebox--buffer-runtime-node (buffer node-id) + "Return runtime node NODE-ID in BUFFER." + (or (and ebox-incremental--candidate-proof-node-table + (eq buffer + (car ebox-incremental--candidate-proof-node-table)) + (gethash node-id + (cdr ebox-incremental--candidate-proof-node-table))) + (when-let ((node-table (ebox--buffer-node-table buffer))) + (gethash node-id node-table)) + (when-let ((root (ebox--buffer-root-node buffer))) + (ebox--runtime-node-by-id root node-id)))) + +(defun ebox--runtime-parent-id (buffer node-id) + "Return NODE-ID's parent id in BUFFER runtime state." + (when-let ((parent-table (ebox--buffer-parent-table buffer))) + (gethash node-id parent-table))) + +(defun ebox--invalidate-runtime-render-signature-path + (node-id node-table parent-table signature-cache height-cache) + "Invalidate NODE-ID to root in explicit runtime structural caches." + (if (null node-id) + (progn + (when signature-cache (clrhash signature-cache)) + (when height-cache (clrhash height-cache))) + (while node-id + (when-let ((node (gethash node-id node-table))) + (when signature-cache (remhash node signature-cache)) + (when height-cache (remhash node height-cache))) + (setq node-id (gethash node-id parent-table))))) + +(defun ebox--invalidate-buffer-render-signatures (buffer node-id) + "Invalidate NODE-ID and ancestors in BUFFER's structural caches." + (ebox--invalidate-runtime-render-signature-path + node-id + (ebox--buffer-node-table buffer) + (ebox--buffer-parent-table buffer) + (ebox--buffer-render-signature-cache buffer) + (ebox--buffer-viewport-height-subtree-cache buffer))) + +(defun ebox--runtime-ancestor-id-p (buffer ancestor-id node-id) + "Return non-nil when ANCESTOR-ID is NODE-ID's ancestor in BUFFER." + (let ((parent (ebox--runtime-parent-id buffer node-id)) + found) + (while (and parent (not found)) + (if (equal parent ancestor-id) + (setq found t) + (setq parent (ebox--runtime-parent-id buffer parent)))) + found)) + +(defun ebox--patch-op (op owner-id &rest props) + "Return a patch-set operation." + (append (list :op op :owner-id owner-id) props)) + +(defun ebox--root-owner-patch-set (buffer dirty-set) + "Return a root owner patch set for BUFFER using DIRTY-SET provenance." + (when-let ((root-id (ebox--buffer-root-node-id buffer))) + (list (ebox--patch-op 'owner-rerender root-id + :dirty (ebox--merge-dirty-provenance-list + dirty-set))))) + +(defvar ebox--patchable-owner-cache nil + "Dynamic patch-planning cache for `ebox--patchable-owner-p' results.") + +(defvar ebox--runtime-ancestor-cache nil + "Dynamic patch-planning cache for runtime ancestor checks.") + +(defvar ebox--flex-slot-safety-cache nil + "Dynamic patch-planning cache for flex item slot safety checks.") + +(defconst ebox--owner-coalesce-min-count 8 + "Minimum owner-rerender op count before considering root coalescing.") + +(defconst ebox--owner-coalesce-min-coverage 0.8 + "Minimum root line coverage ratio before coalescing local owner patches.") + +(defconst ebox-incremental--broad-dirty-min-node-coverage 0.8 + "Minimum dirty descendant ratio for broad owner pre-coalescing.") + +(defconst ebox--line-index-min-dirty-count 2 + "Minimum dirty entry count before eagerly building a buffer line index.") + +(defconst ebox--span-patch-local-style-keys + '(:content :text-align :vertical-align :wrap-mode :scroll-offset + :visibility) + "Geometry style keys that can be local span-patch candidates. +Visibility toggles rerender the owner's own spans with ink suppressed +or restored while the footprint stays identical, so they are local by +construction.") + +(defconst ebox--span-patch-border-box-inline-keys + '(:padding-left-pixel :padding-right-pixel + :border-left-pixel :border-right-pixel) + "Border-box inline keys that can be internal span-patch candidates.") + +(defconst ebox--span-patch-border-box-block-keys + '(:padding-top-height :padding-bottom-height) + "Border-box block keys that can be internal span-patch candidates.") + +(defun ebox--cached-patchable-owner-p + (buffer node-id &optional dirty-kind allow-owned-overflow-coverage) + "Return whether NODE-ID is patchable, using a patch-planning cache." + (if (not ebox--patchable-owner-cache) + (ebox--patchable-owner-p + buffer node-id dirty-kind allow-owned-overflow-coverage) + (let* ((key (list node-id dirty-kind allow-owned-overflow-coverage)) + (missing (make-symbol "ebox-patchable-missing")) + (cached (gethash key ebox--patchable-owner-cache missing))) + (if (not (eq cached missing)) + cached + (puthash key + (ebox--patchable-owner-p + buffer node-id dirty-kind allow-owned-overflow-coverage) + ebox--patchable-owner-cache))))) + +(defun ebox--runtime-ancestor-id-set (buffer node-id) + "Return NODE-ID's complete strict-ancestor id set in BUFFER. +Uses the planning cache when bound so one parent walk serves every +later membership check for the same node." + (or (and ebox--runtime-ancestor-cache + (gethash node-id ebox--runtime-ancestor-cache)) + (let ((ancestors (make-hash-table :test 'equal)) + (walk (ebox--runtime-parent-id buffer node-id))) + (while walk + (puthash walk t ancestors) + (setq walk (ebox--runtime-parent-id buffer walk))) + (when ebox--runtime-ancestor-cache + (puthash node-id ancestors ebox--runtime-ancestor-cache)) + ancestors))) + +(defun ebox--cached-runtime-ancestor-id-p (buffer ancestor-id node-id) + "Return whether ANCESTOR-ID covers NODE-ID, using a planning cache. +The cache stores one complete ancestor-id set per node, built by a +single parent walk, so a planning pass with many pairwise dominance +checks pays one walk per distinct node instead of one walk per pair." + (if (not ebox--runtime-ancestor-cache) + (ebox--runtime-ancestor-id-p buffer ancestor-id node-id) + (gethash ancestor-id + (ebox--runtime-ancestor-id-set buffer node-id)))) + +(defun ebox--patch-operation-strength (strategy) + "Return structural dominance strength for patch STRATEGY." + (pcase strategy + ('owner-rerender 3) + ('span-patch 2) + ('paint-patch 1) + ('child-splice 0) + ('child-reorder 0) + (_ 0))) + +(defun ebox--dirty-kind-strength (kind) + "Return relative disruption strength for dirty KIND." + (or (cl-position kind ebox--dirty-kind-order) -1)) + +(defun ebox--dirty-provenance-node-ids (dirty) + "Return the distinct source node ids recorded by DIRTY provenance." + (delete-dups + (append (when-let ((node-id (plist-get dirty :node-id))) + (list node-id)) + (copy-sequence (or (plist-get dirty :node-ids) nil))))) + +(defun ebox--merge-dirty-provenance (primary secondary) + "Merge SECONDARY dirty provenance into PRIMARY." + (cond + ((null primary) (copy-sequence secondary)) + ((null secondary) (copy-sequence primary)) + (t + (let ((merged (copy-sequence primary))) + (setq merged + (plist-put + merged :node-ids + (delete-dups + (append (ebox--dirty-provenance-node-ids primary) + (ebox--dirty-provenance-node-ids secondary))))) + (dolist (key '(:changed-keys :region-ids :old-region-ids + :new-region-ids :impact-vector)) + (setq merged + (plist-put + merged key + (delete-dups + (append (copy-sequence (or (plist-get primary key) nil)) + (copy-sequence (or (plist-get secondary key) nil))))))) + (when (or (plist-get primary :requires-owned-overflow-coverage) + (plist-get secondary :requires-owned-overflow-coverage)) + (setq merged + (plist-put merged :requires-owned-overflow-coverage t))) + (when (> (ebox--dirty-kind-strength + (plist-get secondary :dirty-kind)) + (ebox--dirty-kind-strength + (plist-get primary :dirty-kind))) + (setq merged + (plist-put merged :dirty-kind + (plist-get secondary :dirty-kind)))) + merged)))) + +(defun ebox--merge-dirty-provenance-list (entries) + "Merge dirty provenance from ENTRIES into one entry." + (when entries + (cl-reduce #'ebox--merge-dirty-provenance entries))) + +(defun ebox--patch-op-merge-dirty (op absorbed) + "Return OP with dirty provenance from ABSORBED merged into it." + (let ((merged (copy-sequence op))) + (plist-put merged :dirty + (ebox--merge-dirty-provenance + (plist-get op :dirty) + (plist-get absorbed :dirty))))) + +(defun ebox--merge-same-owner-patch-ops (left right) + "Return the stronger same-owner patch with merged dirty provenance." + (let* ((left-strength + (ebox--patch-operation-strength (plist-get left :op))) + (right-strength + (ebox--patch-operation-strength (plist-get right :op))) + (left-dirty-strength + (ebox--dirty-kind-strength + (plist-get (plist-get left :dirty) :dirty-kind))) + (right-dirty-strength + (ebox--dirty-kind-strength + (plist-get (plist-get right :dirty) :dirty-kind))) + (left-wins + (or (> left-strength right-strength) + (and (= left-strength right-strength) + (>= left-dirty-strength right-dirty-strength)))) + (winner (if left-wins left right)) + (loser (if left-wins right left)) + (merged (copy-sequence winner))) + (plist-put merged :dirty + (ebox--merge-dirty-provenance + (plist-get winner :dirty) + (plist-get loser :dirty))))) + +(defun ebox--patch-op-covers-node-p (buffer op node-id node-strategy) + "Return non-nil when OP dominates NODE-STRATEGY at NODE-ID in BUFFER." + (let* ((owner-id (plist-get op :owner-id)) + (op-strength + (ebox--patch-operation-strength (plist-get op :op)))) + (and owner-id node-id + (if (equal owner-id node-id) + (>= op-strength + (ebox--patch-operation-strength node-strategy)) + (and (>= op-strength + (ebox--patch-operation-strength 'span-patch)) + (or (equal owner-id (ebox--buffer-root-node-id buffer)) + (ebox--cached-runtime-ancestor-id-p + buffer owner-id node-id))))))) + +(defun ebox--patch-op-dominates-p (buffer op other) + "Return non-nil when OP makes OTHER redundant in BUFFER." + (ebox--patch-op-covers-node-p + buffer op (plist-get other :owner-id) (plist-get other :op))) + +(defun ebox--merge-patch-op-into-set (buffer ops candidate) + "Merge CANDIDATE into OPS using operation-aware coverage in BUFFER." + (let* ((owner-id (plist-get candidate :owner-id)) + (same-owner + (cl-find-if (lambda (op) + (equal (plist-get op :owner-id) owner-id)) + ops)) + (candidate (if same-owner + (ebox--merge-same-owner-patch-ops + same-owner candidate) + candidate)) + (remaining (if same-owner + (delq same-owner (copy-sequence ops)) + (copy-sequence ops)))) + (if-let ((dominator + (cl-find-if + (lambda (op) + (ebox--patch-op-dominates-p buffer op candidate)) + remaining))) + (mapcar (lambda (op) + (if (eq op dominator) + (ebox--patch-op-merge-dirty op candidate) + op)) + remaining) + (let ((survivors nil)) + (dolist (op remaining) + (if (ebox--patch-op-dominates-p buffer candidate op) + (setq candidate (ebox--patch-op-merge-dirty candidate op)) + (push op survivors))) + (append (nreverse survivors) (list candidate)))))) + +(defun ebox--span-patch-definite-size-value-p (value) + "Return non-nil when VALUE is definite enough for local span planning." + (and value + (not (memq value '(auto min-content max-content fit-content + stretch contain viewport))) + (not (ebox--viewport-dependent-size-value-p value)))) + +(defun ebox--dirty-entry-local-span-keys-p (entry) + "Return non-nil when ENTRY changed only local span candidate keys. +Paint-class keys ride along: a span rerender republishes the owner's +text together with its faces and surface text properties, so mixing +`:surface-properties' or paint signature keys with local span keys +keeps the change local to the owner's own rendered spans." + (let ((changed-keys (plist-get entry :changed-keys))) + (and changed-keys + (cl-every (lambda (key) + (or (memq key ebox--span-patch-local-style-keys) + (eq key :surface-properties) + (memq key ebox--paint-style-signature-keys))) + changed-keys)))) + +(defun ebox--border-box-inline-content-fits-p (style-node) + "Return non-nil when STYLE-NODE's inline chrome fits its fixed width." + (let ((width (ebox-get style-node :width))) + (and (numberp width) + (>= width + (+ (ebox-get style-node :padding-left-pixel) + (ebox-get style-node :padding-right-pixel) + (ebox-get style-node :border-left-pixel) + (ebox-get style-node :border-right-pixel)))))) + +(defun ebox--border-box-block-content-fits-p (style-node) + "Return non-nil when STYLE-NODE's block padding leaves one content line." + (let ((height (ebox-get style-node :height))) + (and (numberp height) + (>= height + (+ 1 + (floor (ebox-get style-node :padding-top-height)) + (floor (ebox-get style-node :padding-bottom-height))))))) + +(defun ebox--dirty-entry-border-box-internal-span-p (buffer entry) + "Return non-nil when ENTRY changes only fixed border-box internals. +Inline and block padding/border fields may arrive together after shorthand +expansion. Treat that mixed set as one local candidate when the corresponding +outer width and height are definite and still contain their inner chrome." + (let* ((changed-keys (plist-get entry :changed-keys)) + (inline-changed + (cl-some (lambda (key) + (memq key ebox--span-patch-border-box-inline-keys)) + changed-keys)) + (block-changed + (cl-some (lambda (key) + (memq key ebox--span-patch-border-box-block-keys)) + changed-keys))) + (and changed-keys + (cl-every + (lambda (key) + (or (memq key ebox--span-patch-border-box-inline-keys) + (memq key ebox--span-patch-border-box-block-keys))) + changed-keys) + (when-let* ((node-id (plist-get entry :node-id)) + (node (ebox--buffer-runtime-node buffer node-id)) + (style-node (ebox-fragment-style-source-node node))) + (and (eq (ebox-get style-node :box-sizing) 'border-box) + (or (not inline-changed) + (ebox--border-box-inline-content-fits-p style-node)) + (or (not block-changed) + (ebox--border-box-block-content-fits-p style-node))))))) + +(defun ebox--dirty-entry-span-patch-candidate-p (buffer entry) + "Return non-nil when ENTRY can first attempt a local span patch." + (and buffer + (eq (plist-get entry :dirty-kind) 'geometry) + (or (ebox--dirty-entry-local-span-keys-p entry) + (ebox--dirty-entry-border-box-internal-span-p buffer entry)))) + +(defun ebox--changed-key-impact (key style-node) + "Return impact facts for changed KEY on STYLE-NODE." + (cond + ((memq key ebox--paint-style-signature-keys) + '(paint)) + ;; Surface text properties are stamped onto the owner's rendered + ;; spans and cannot move any geometry, exactly like paint; the + ;; declarative dirty-kind classifier already groups them with paint. + ((eq key :surface-properties) + '(paint)) + ((eq key :content) + '(internal-fragment external-footprint-proof)) + ((memq key ebox--span-patch-local-style-keys) + '(internal-fragment)) + ((and (eq (ebox-get style-node :box-sizing) 'border-box) + (memq key ebox--span-patch-border-box-inline-keys) + (ebox--span-patch-definite-size-value-p + (ebox-get style-node :width))) + '(internal-fragment external-footprint-proof)) + ((and (eq (ebox-get style-node :box-sizing) 'border-box) + (memq key ebox--span-patch-border-box-block-keys) + (numberp (ebox-get style-node :height))) + '(internal-fragment external-footprint-proof)) + ((eq key :overflow) + '(clipping external-footprint)) + ((eq key :box-sizing) + '(size-interpretation external-footprint)) + ((memq key '(:margin-left-pixel :margin-right-pixel + :margin-top-height :margin-bottom-height)) + '(external-footprint parent-layout)) + (t + '(external-footprint parent-layout)))) + +(defun ebox--dirty-entry-impact-vector (buffer entry) + "Return normalized impact vector for dirty ENTRY in BUFFER." + (let* ((node-id (plist-get entry :node-id)) + (node (and node-id (ebox--buffer-runtime-node buffer node-id))) + (style-node (and node (ebox-fragment-style-source-node node))) + impacts) + (dolist (key (plist-get entry :changed-keys)) + (dolist (impact (ebox--changed-key-impact key style-node)) + (cl-pushnew impact impacts))) + (nreverse impacts))) + +(defun ebox--dirty-entry-requires-owned-overflow-coverage-p (buffer entry) + "Return non-nil when ENTRY changes to or from unowned visible overflow." + (when (memq :overflow (plist-get entry :changed-keys)) + (let* ((node-id (plist-get entry :node-id)) + (node (and node-id (ebox--buffer-runtime-node buffer node-id))) + (style-node (and node (ebox-fragment-style-source-node node))) + (old-values (plist-get entry :old-style-values)) + (stored-snapshots + (plist-get (ebox--buffer-render-state buffer) :layout-snapshots)) + (old-snapshot + (or (plist-get entry :old) + (and stored-snapshots node-id + (gethash node-id stored-snapshots)))) + (old-style (and old-snapshot + (plist-get old-snapshot :style-signature))) + (old-overflow-known + (or (plist-member old-values :overflow) + (and old-style (plist-member old-style :overflow)))) + (old-overflow + (if (plist-member old-values :overflow) + (plist-get old-values :overflow) + (plist-get old-style :overflow))) + (new-overflow (and style-node (ebox-get style-node :overflow)))) + (or (not old-overflow-known) + (eq old-overflow 'visible) + (eq new-overflow 'visible))))) + +(defun ebox--dirty-entry-with-impact-vector (buffer entry) + "Return ENTRY with an impact vector computed in BUFFER." + (setq entry + (plist-put entry :impact-vector + (ebox--dirty-entry-impact-vector buffer entry))) + (when (ebox--dirty-entry-requires-owned-overflow-coverage-p buffer entry) + (setq entry + (plist-put entry :requires-owned-overflow-coverage t))) + entry) + +(defun ebox--impact-vector-requires-parent-promotion-p (impact-vector) + "Return non-nil when IMPACT-VECTOR requires parent-layout promotion." + (or (null impact-vector) + (memq 'parent-layout impact-vector) + (memq 'clipping impact-vector) + (memq 'size-interpretation impact-vector))) + +(defun ebox--impact-vector-requires-conservative-owner-p (impact-vector) + "Return non-nil when IMPACT-VECTOR must use conservative owner rerendering." + (or (memq 'clipping impact-vector) + (memq 'size-interpretation impact-vector))) + +(defun ebox--node-own-overflow-visible-p (buffer node-id) + "Return non-nil when NODE-ID's current or stored own overflow is visible." + (let* ((node (ebox--buffer-runtime-node buffer node-id)) + (style-node (and node (ebox-fragment-style-source-node node))) + (snapshots + (plist-get (ebox--buffer-render-state buffer) :layout-snapshots)) + (snapshot (and snapshots (gethash node-id snapshots))) + (old-style (and snapshot + (plist-get snapshot :style-signature)))) + (or (eq (and style-node (ebox-get style-node :overflow)) 'visible) + (eq (plist-get old-style :overflow) 'visible)))) + +(defun ebox--ancestor-own-overflow-visible-p (buffer node-id) + "Return non-nil when an ancestor of NODE-ID has visible own overflow." + (let ((ancestor-id (ebox--runtime-parent-id buffer node-id)) + found) + (while (and ancestor-id (not found)) + (if (ebox--node-own-overflow-visible-p buffer ancestor-id) + (setq found t) + (setq ancestor-id (ebox--runtime-parent-id buffer ancestor-id)))) + found)) + +(defun ebox--owned-overflow-coverage-wrapper-node-p (buffer node-id) + "Return non-nil when NODE-ID's wrapper owns the required overflow footprint." + (when-let* ((node (ebox--buffer-runtime-node buffer node-id)) + (style-node (ebox-fragment-style-source-node node))) + (let* ((parent-id (ebox--runtime-parent-id buffer node-id)) + (parent (and parent-id + (ebox--buffer-runtime-node buffer parent-id)))) + (and (ebox-get style-node :region-id) + (not (eq (ebox-get style-node :overflow) 'visible)) + (not (ebox--ancestor-own-overflow-visible-p buffer node-id)) + (not (and parent + (eq (ebox--display-inner parent) 'flex))))))) + +(defun ebox--patch-op-from-dirty-entry (entry &optional buffer) + "Return the patch op for dirty-set ENTRY. +When BUFFER is non-nil, non-paint entries promote to patchable owners." + (let* ((node-id (plist-get entry :node-id)) + (dirty-kind (plist-get entry :dirty-kind)) + (requires-owned-overflow-coverage + (or (plist-get entry :requires-owned-overflow-coverage) + (and buffer + (ebox--dirty-entry-requires-owned-overflow-coverage-p + buffer entry)))) + (entry + (if requires-owned-overflow-coverage + (plist-put entry :requires-owned-overflow-coverage t) + entry)) + (impact-vector (or (plist-get entry :impact-vector) + (and buffer + (plist-get entry :changed-keys) + (ebox--dirty-entry-impact-vector + buffer entry))))) + (pcase dirty-kind + ('paint + (ebox--patch-op 'paint-patch node-id + :dirty entry)) + (_ + (if-let ((span-owner-id + (and buffer + (ebox--span-patch-owner-id-for-dirty-entry + entry buffer)))) + (ebox--patch-op 'span-patch span-owner-id + :dirty entry) + (let ((fallback-owner-id + (or (and buffer + requires-owned-overflow-coverage + (or (ebox--next-patchable-owner-ancestor + buffer node-id dirty-kind t) + (ebox--buffer-root-node-id buffer))) + (and buffer + (or (memq 'size-interpretation impact-vector) + (not + (ebox--impact-vector-requires-conservative-owner-p + impact-vector))) + (ebox--promote-to-patchable-owner + buffer node-id dirty-kind + (plist-get entry :changed-keys) + impact-vector)) + node-id))) + (ebox--patch-op 'owner-rerender fallback-owner-id + :dirty entry))))))) + +(defun ebox--patch-ops-from-dirty-set (dirty-set &optional buffer) + "Return raw patch ops for DIRTY-SET. +When BUFFER is non-nil, merge candidates using operation-aware coverage." + (let (ops) + (dolist (entry dirty-set ops) + (let ((candidate (ebox--patch-op-from-dirty-entry entry buffer))) + (setq ops + (if buffer + (ebox--merge-patch-op-into-set buffer ops candidate) + (append ops (list candidate)))))))) + +(defun ebox--patch-set-from-dirty-set (dirty-set &optional buffer) + "Return executable patch ops for DIRTY-SET. +When BUFFER is non-nil, promote non-paint dirty nodes to patchable owners." + (let* ((needs-owner-p + (and buffer + (cl-some + (lambda (entry) + (not (eq (plist-get entry :dirty-kind) 'paint))) + dirty-set))) + (ops (if needs-owner-p + (let ((ebox--patchable-owner-cache + (make-hash-table :test 'equal)) + (ebox--runtime-ancestor-cache + (make-hash-table :test 'equal)) + (ebox--flex-slot-safety-cache + (make-hash-table :test 'equal))) + (if (cl-every + (lambda (entry) + (ebox--dirty-entry-span-patch-candidate-p + buffer entry)) + dirty-set) + (ebox--with-layout-snapshot-detail-context buffer + (ebox--patch-ops-from-dirty-set + dirty-set buffer)) + (if (< (length dirty-set) + ebox--line-index-min-dirty-count) + (ebox--with-layout-snapshot-detail-context buffer + (ebox--patch-ops-from-dirty-set + dirty-set buffer)) + (ebox--with-layout-snapshot-index-context buffer + (ebox--patch-ops-from-dirty-set + dirty-set buffer))))) + (ebox--patch-ops-from-dirty-set dirty-set buffer)))) + (if buffer + (let ((ebox--runtime-ancestor-cache + (or ebox--runtime-ancestor-cache + (make-hash-table :test 'equal)))) + (ebox--coalesce-large-owner-patch-set + buffer (ebox--dedupe-patch-owners buffer ops))) + ops))) + +(defun ebox--line-spans-cover-whole-lines-p (spans) + "Return non-nil when every span in SPANS owns whole buffer lines." + (cl-every (lambda (span) + (save-excursion + (goto-char (car span)) + (and (= (car span) (line-beginning-position)) + (= (cdr span) (line-end-position))))) + spans)) + +(defun ebox--row-or-flex-layout-p (node) + "Return non-nil when NODE repositions siblings after child geometry changes." + (memq (ebox--display-inner node) '(row flex))) + +(defun ebox--row-or-flex-ancestor-p (buffer node-id) + "Return non-nil when NODE-ID is contained by a row or flex layout ancestor." + (let ((ancestor-id (ebox--runtime-parent-id buffer node-id)) + found) + (while (and ancestor-id (not found)) + (let ((ancestor (ebox--buffer-runtime-node buffer ancestor-id))) + (if (and ancestor (ebox--row-or-flex-layout-p ancestor)) + (setq found t) + (setq ancestor-id + (ebox--runtime-parent-id buffer ancestor-id))))) + found)) + +(defun ebox--geometry-affects-parent-layout-p (buffer node-id) + "Return non-nil when NODE-ID geometry can change parent layout in BUFFER." + (when-let* ((parent-id (ebox--runtime-parent-id buffer node-id)) + (parent (ebox--buffer-runtime-node buffer parent-id))) + (or (ebox--row-or-flex-layout-p parent) + (ebox--row-or-flex-ancestor-p buffer parent-id)))) + +(defun ebox--definite-containment-inline-size-value-p (value) + "Return non-nil when VALUE is independent of intrinsic inline content." + (and (ebox--span-patch-definite-size-value-p value) + (not (eq (car-safe value) 'fit-content)))) + +(defun ebox--inline-size-change-p (changed-keys) + "Return non-nil when CHANGED-KEYS contains only inline-size fields." + (and changed-keys + (cl-every (lambda (key) + (memq key '(:width :min-width :max-width))) + changed-keys))) + +(defun ebox--definite-containment-formatting-context-p (buffer node-id) + "Return non-nil when NODE-ID contains descendant inline size in BUFFER. +A formatting context is a safe local owner candidate only when both outer axes +are definite and neither it nor a descendant allows visible overflow. Backend +footprint and parent-slot checks still decide whether it may actually be +patched." + (when-let* ((node (ebox--buffer-runtime-node buffer node-id)) + ((ebox--formatting-context-p node)) + (style-node (ebox-fragment-style-source-node node))) + (let ((min-width (ebox-get style-node :min-width)) + (max-width (ebox-get style-node :max-width)) + (min-height (ebox-get style-node :min-height)) + (max-height (ebox-get style-node :max-height))) + (and (ebox--definite-containment-inline-size-value-p + (ebox-get style-node :width)) + (or (null min-width) + (ebox--definite-containment-inline-size-value-p min-width)) + (or (null max-width) + (eq max-width 'none) + (ebox--definite-containment-inline-size-value-p max-width)) + (numberp (ebox-get style-node :height)) + (or (null min-height) (numberp min-height)) + (or (null max-height) + (eq max-height 'none) + (numberp max-height)) + (not (eq (ebox-get style-node :overflow) 'visible)) + (not (ebox--node-visible-overflow-p node)))))) + +(defun ebox--direct-row-or-flex-parent-p (buffer node-id) + "Return non-nil when NODE-ID is a direct row/flex item owner." + (when-let* ((parent-id (ebox--runtime-parent-id buffer node-id)) + (parent (ebox--buffer-runtime-node buffer parent-id))) + (ebox--row-or-flex-layout-p parent))) + +(defun ebox--direct-flex-parent-p (buffer node-id) + "Return non-nil when NODE-ID is a direct flex item owner." + (when-let* ((parent-id (ebox--runtime-parent-id buffer node-id)) + (parent (ebox--buffer-runtime-node buffer parent-id))) + (eq (ebox--display-inner parent) 'flex))) + +(defconst ebox--flex-participation-style-keys + '(:flex :flex-grow :flex-shrink :flex-basis :order :align-self + :flex-participation) + "Keys that participate in a parent flex sizing or placement algorithm.") + +(defun ebox--flex-main-axis (flex-node) + "Return FLEX-NODE's main axis." + (if (memq (plist-get (plist-get flex-node :props) :flex-direction) + '(column column-reverse)) + 'column + 'row)) + +(defun ebox--flex-main-definite-size-p (style-node axis) + "Return non-nil when STYLE-NODE has a definite main-axis outer size." + (and style-node + (if (eq axis 'row) + (ebox--span-patch-definite-size-value-p + (ebox-get style-node :width)) + (numberp (ebox-get style-node :height))))) + +(defun ebox--flex-border-box-main-internal-key-p (key axis) + "Return non-nil when KEY can be internal to a fixed border-box main size." + (if (eq axis 'row) + (memq key '(:padding-left-pixel :padding-right-pixel + :border-left-pixel :border-right-pixel)) + (memq key '(:padding-top-height :padding-bottom-height)))) + +(defun ebox--flex-content-flow-main-key-p (key) + "Return non-nil when KEY can change an auto/content flex base size." + (memq key '(:content :wrap-mode))) + +(defun ebox--flex-main-footprint-key-p (key axis style-node) + "Return non-nil when KEY can change STYLE-NODE's flex main footprint." + (or (memq key ebox--flex-participation-style-keys) + (eq key :box-sizing) + (if (eq axis 'row) + (memq key '(:width :min-width :max-width + :margin-left-pixel :margin-right-pixel)) + (memq key '(:height :min-height :max-height + :margin-top-height :margin-bottom-height))) + (and (ebox--flex-border-box-main-internal-key-p key axis) + (not (and (eq (ebox-get style-node :box-sizing) 'border-box) + (ebox--flex-main-definite-size-p style-node axis)))) + (and (ebox--flex-content-flow-main-key-p key) + (not (ebox--flex-main-definite-size-p style-node axis))))) + +(defun ebox--flex-main-size-keys-p (buffer node-id changed-keys) + "Return non-nil when CHANGED-KEYS can change NODE-ID's flex main footprint." + (and changed-keys + (when-let* ((parent-id (ebox--runtime-parent-id buffer node-id)) + (parent (ebox--buffer-runtime-node buffer parent-id)) + ((eq (ebox--display-inner parent) 'flex)) + (node (ebox--buffer-runtime-node buffer node-id)) + (style-node (ebox-fragment-style-source-node node))) + (let ((axis (ebox--flex-main-axis parent))) + (cl-some (lambda (key) + (ebox--flex-main-footprint-key-p + key axis style-node)) + changed-keys))))) + +(defun ebox--flex-item-slot-at-declared-main-p (buffer node-id) + "Return non-nil when NODE-ID's flex slot equals its declared main size. +Such an allocation is anchored by the item's own definite size rather +than by its content minimum, so a descendant footprint change cannot +move the slot while free space and sibling bases stay untouched. A +slot above or below the declared size was decided by grow, shrink, or +a content-minimum clamp, and descendant changes may move it." + (when-let* ((node (ebox--buffer-runtime-node buffer node-id)) + (parent-id (ebox--runtime-parent-id buffer node-id)) + (parent (ebox--buffer-runtime-node buffer parent-id)) + ((eq (ebox--display-inner parent) 'flex)) + (style-node (ebox-fragment-style-source-node node)) + (snapshot (ebox--ensure-layout-snapshot-spans buffer node-id)) + (spans (plist-get snapshot :buffer-spans)) + (footprint + (or (plist-get snapshot :span-footprint-signature) + (with-current-buffer buffer + (ebox--spans-span-footprint-signature spans)))) + (widths (plist-get footprint :line-pixel-widths)) + ((> (length widths) 0))) + (let* ((axis (ebox--flex-main-axis parent)) + (slot-main (if (eq axis 'row) + (apply #'max widths) + (length widths))) + (declared (ebox--flex-box-main-constraint + style-node axis + (if (eq axis 'row) :width :height)))) + (and declared (= slot-main declared))))) + +(defun ebox--flex-item-footprint-keys-p (buffer node-id changed-keys) + "Return non-nil when CHANGED-KEYS can change NODE-ID's flex footprint. +Both axes count: main-axis keys change the item's slot width and +cross-axis keys change its row footprint, exactly the pair +`ebox--flex-slot-allocation-stable-p' refuses for slot reuse." + (and changed-keys + (when-let* ((parent-id (ebox--runtime-parent-id buffer node-id)) + (parent (ebox--buffer-runtime-node buffer parent-id)) + ((eq (ebox--display-inner parent) 'flex)) + (node (ebox--buffer-runtime-node buffer node-id)) + (style-node (ebox-fragment-style-source-node node))) + (let ((axis (ebox--flex-main-axis parent))) + (cl-some (lambda (key) + (or (ebox--flex-main-footprint-key-p + key axis style-node) + (ebox--flex-main-footprint-key-p + key (ebox--flex-cross-axis axis) style-node))) + changed-keys))))) + +(defun ebox--flex-child-for-source-node (flex-node node-id) + "Return FLEX-NODE's child whose source owns NODE-ID." + (cl-find-if + (lambda (child) + (when-let ((source (ebox--flex-item-source-node child))) + (equal (plist-get source :node-id) node-id))) + (plist-get flex-node :children))) + +(defun ebox--flex-cross-axis (axis) + "Return the axis perpendicular to flex AXIS." + (if (eq axis 'row) 'column 'row)) + +(defun ebox--flex-content-allocation-stable-p + (buffer style-node item-props axis current-main) + "Return non-nil when content cannot change this flex allocation. +STYLE-NODE must have a definite main size used by the default auto basis, and +its new minimum must still fit CURRENT-MAIN." + (when (and (eq (plist-get item-props :flex-basis) 'auto) + (ebox--flex-main-definite-size-p style-node axis)) + (let ((rendered + (if (and (eq axis 'row) + (null (ebox-get style-node :wrap-mode))) + (ebox--with-buffer-render-context buffer + (ebox-render style-node)) + ""))) + (<= (ebox--flex-min-main style-node rendered axis) + current-main)))) + +(defun ebox--flex-slot-allocation-stable-p + (buffer node child axis current-main changed-keys) + "Return non-nil when CHANGED-KEYS preserve NODE's flex allocation. +Unknown changes fail closed. A fixed outer size is insufficient for content +updates unless the auto flex basis stays fixed and the new minimum still fits +the already published slot." + (when-let ((style-node (ebox-fragment-style-source-node node))) + (and changed-keys + (cl-every + (lambda (key) + (and (not (ebox--flex-main-footprint-key-p + key axis style-node)) + (not (ebox--flex-main-footprint-key-p + key (ebox--flex-cross-axis axis) style-node)))) + changed-keys) + (or (not (cl-some #'ebox--flex-content-flow-main-key-p + changed-keys)) + (ebox--flex-content-allocation-stable-p + buffer style-node (ebox--flex-item-props child) + axis current-main))))) + +(defun ebox--flex-item-slot-sized-node + (buffer node-id snapshot &optional changed-keys) + "Return NODE-ID copied to its currently rendered flex slot. +SNAPSHOT supplies the already published parent-facing footprint. This keeps +incremental rendering in the same used-size context as the parent flex layout +instead of re-expanding the source node to its declared intrinsic size. +CHANGED-KEYS must prove that reusing the allocation is safe." + (when-let* ((node (ebox--buffer-runtime-node buffer node-id)) + ((eq (plist-get node :ebox-type) 'box)) + (parent-id (ebox--runtime-parent-id buffer node-id)) + (parent (ebox--buffer-runtime-node buffer parent-id)) + ((eq (ebox--display-inner parent) 'flex)) + (child (ebox--flex-child-for-source-node parent node-id)) + (spans (plist-get snapshot :buffer-spans)) + (footprint + (or (plist-get snapshot :span-footprint-signature) + (with-current-buffer buffer + (ebox--spans-span-footprint-signature spans)))) + (widths (plist-get footprint :line-pixel-widths)) + ((and widths (> (length widths) 0)))) + (let* ((container-props + (ebox--flex-container-content-props + (plist-get parent :props) + (plist-get parent :raw-props) + (plist-get parent :box))) + (axis (ebox--flex-axis container-props)) + (main (if (eq axis 'row) + (apply #'max widths) + (length widths))) + (cross (if (eq axis 'row) + (length widths) + (apply #'max widths))) + (item-props (ebox--flex-item-props child)) + (item-align (plist-get item-props :align-self)) + (align (if (memq item-align '(nil auto)) + (plist-get container-props :align-items) + item-align)) + (stretch (memq align '(stretch normal)))) + (when (ebox--flex-slot-allocation-stable-p + buffer node child axis main changed-keys) + (ebox--flex-copy-node-for-size node axis main cross stretch))))) + +(defun ebox--render-node-in-current-flex-slot + (buffer node-id snapshot &optional changed-keys) + "Render NODE-ID in BUFFER using its proven-stable flex slot, or return nil." + (when-let* ((node (ebox--buffer-runtime-node buffer node-id)) + (sized-node + (ebox--flex-item-slot-sized-node + buffer node-id snapshot changed-keys))) + (prog1 + (ebox-render sized-node) + ;; Rendering the sized copy reuses the stable region id. Restore the + ;; authoritative source object so later public updates never target the + ;; temporary used-size copy. + (ebox--flex-recache-source-boxes node)))) + +(defun ebox--flex-item-slot-footprint-safe-p + (buffer node-id changed-keys) + "Return non-nil when NODE-ID's current render still fits its old flex slot." + (when-let* ((snapshot (ebox--ensure-layout-snapshot-details buffer node-id)) + (spans (plist-get snapshot :buffer-spans)) + (old-span-footprint + (plist-get snapshot :span-footprint-signature)) + (old-external-footprint + (plist-get snapshot :external-footprint-signature)) + (old-parent-slot + (plist-get snapshot :parent-slot-signature)) + (node (ebox--buffer-runtime-node buffer node-id)) + ((not (ebox--node-visible-overflow-p node)))) + (ebox--with-buffer-render-context buffer + (let* ((rendered + (or (ebox--render-node-in-current-flex-slot + buffer node-id snapshot changed-keys) + (ebox-render node))) + (slot-rendered + (with-current-buffer buffer + (ebox-buffer--rendered-in-existing-slots spans rendered))) + (final-rendered (or slot-rendered rendered)) + (new-span-footprint + (ebox--rendered-span-footprint-signature final-rendered)) + (new-external-footprint + (ebox--external-footprint-signature-from-span-footprint + new-span-footprint)) + (new-parent-slot + (ebox--project-parent-slot-signature + old-parent-slot new-span-footprint))) + (and (ebox--span-footprint-compatible-p + old-span-footprint new-span-footprint) + (ebox--external-footprint-compatible-p + old-external-footprint new-external-footprint) + (ebox--parent-slot-compatible-p + old-parent-slot new-parent-slot)))))) + +(defun ebox--cached-flex-item-slot-footprint-safe-p + (buffer node-id changed-keys) + "Return cached flex slot footprint safety for NODE-ID in BUFFER." + (if (not ebox--flex-slot-safety-cache) + (ebox--flex-item-slot-footprint-safe-p buffer node-id changed-keys) + (let* ((key (list buffer node-id changed-keys)) + (missing (make-symbol "ebox-flex-slot-safety-missing")) + (cached (gethash key ebox--flex-slot-safety-cache missing))) + (if (not (eq cached missing)) + cached + (puthash key + (ebox--flex-item-slot-footprint-safe-p + buffer node-id changed-keys) + ebox--flex-slot-safety-cache))))) + +(defun ebox--flex-item-slot-safe-p + (buffer node-id changed-keys &optional own-change) + "Return non-nil when NODE-ID can keep its old flex item slot. +Footprint CHANGED-KEYS on either axis veto the slot when OWN-CHANGE +is non-nil. For a climbed ancestor (OWN-CHANGE nil) they are +tolerable only while the slot is anchored at the item's own declared +main size: a descendant's size-family change flows into the item's +content minimum, and an allocation decided by that minimum (or by +grow/shrink) legitimately changes with it, so a slot-preserving +publication would freeze stale sibling geometry." + (and (ebox--direct-flex-parent-p buffer node-id) + (or (not (ebox--flex-item-footprint-keys-p + buffer node-id changed-keys)) + (and (not own-change) + (ebox--flex-item-slot-at-declared-main-p buffer node-id))) + (ebox--cached-flex-item-slot-footprint-safe-p + buffer node-id changed-keys))) + +(defun ebox--contained-partial-flex-owner-p (buffer node-id) + "Return non-nil when NODE-ID is a definite flex stored in partial slots." + (when-let* ((node (ebox--buffer-runtime-node buffer node-id)) + ((eq (ebox--display-inner node) 'flex)) + ((ebox--definite-containment-formatting-context-p + buffer node-id)) + (snapshot (ebox--ensure-layout-snapshot-spans buffer node-id)) + (spans (plist-get snapshot :buffer-spans))) + (with-current-buffer buffer + (ebox-buffer--partial-line-slots-p spans)))) + +(defun ebox--first-geometry-owner-candidate + (buffer node-id dirty-kind &optional changed-keys impact-vector) + "Return the first structurally possible patch owner for NODE-ID. +Geometry changes inside layout containers must promote to the container before +running expensive span and snapshot checks." + (let ((candidate node-id) + (dirty-node-id node-id)) + (while (and (eq dirty-kind 'geometry) + (ebox--impact-vector-requires-parent-promotion-p + impact-vector) + (not (and (not (equal candidate dirty-node-id)) + (ebox--contained-partial-flex-owner-p + buffer candidate))) + (ebox--geometry-affects-parent-layout-p buffer candidate) + (not (ebox--flex-item-slot-safe-p + buffer candidate changed-keys + (equal candidate dirty-node-id)))) + (setq candidate (ebox--runtime-parent-id buffer candidate))) + candidate)) + +(defun ebox--partial-slot-owner-p (buffer node-id) + "Return non-nil when NODE-ID owns replaceable partial row/flex slots." + (when-let* ((snapshot (ebox--ensure-layout-snapshot-spans buffer node-id)) + (spans (plist-get snapshot :buffer-spans))) + (with-current-buffer buffer + (and (ebox--direct-flex-parent-p buffer node-id) + (cl-some (lambda (span) + (not (ebox--span-whole-line-p span))) + spans))))) + +(defun ebox--patchable-owner-p + (buffer node-id &optional dirty-kind allow-owned-overflow-coverage) + "Return non-nil when NODE-ID can be safely range patched in BUFFER." + (when-let* ((node (ebox--buffer-runtime-node buffer node-id)) + (snapshot (ebox--ensure-layout-snapshot-spans buffer node-id)) + (spans (plist-get snapshot :buffer-spans))) + (with-current-buffer buffer + (let* ((partial-slot (ebox--partial-slot-owner-p buffer node-id)) + (coverage-flex + (and allow-owned-overflow-coverage + (eq (ebox--display-inner node) 'flex))) + (contained-flex + (ebox--contained-partial-flex-owner-p buffer node-id)) + (local-flex (or coverage-flex contained-flex))) + (and (or (eq dirty-kind 'span) + (ebox--line-spans-cover-whole-lines-p spans) + (and (eq dirty-kind 'geometry) partial-slot) + (and (memq dirty-kind '(geometry structure)) + local-flex)) + (not (and (eq dirty-kind 'geometry) + (ebox--geometry-affects-parent-layout-p buffer node-id) + (not partial-slot) + (not local-flex))) + (or partial-slot + local-flex + (not (ebox--node-visible-overflow-p node)))))))) + +(defun ebox--span-patchable-owner-p (buffer node-id) + "Return non-nil when NODE-ID can attempt verified span patching." + (when-let* ((node (ebox--buffer-runtime-node buffer node-id)) + (snapshot (ebox--ensure-layout-snapshot-spans buffer node-id)) + (spans (plist-get snapshot :buffer-spans))) + (and spans + (not (ebox--node-visible-overflow-p node))))) + +(defun ebox--promote-to-patchable-owner + (buffer node-id &optional dirty-kind changed-keys impact-vector) + "Return the smallest patchable owner id for NODE-ID in BUFFER." + (let ((candidate (ebox--first-geometry-owner-candidate + buffer node-id dirty-kind changed-keys + impact-vector)) + owner) + (while (and candidate (not owner)) + (if (ebox--cached-patchable-owner-p buffer candidate dirty-kind) + (setq owner candidate) + (setq candidate (ebox--runtime-parent-id buffer candidate)))) + owner)) + +(defun ebox--promote-to-span-patchable-owner + (buffer node-id &optional dirty-kind changed-keys) + "Return the smallest structural owner worth one verified span-patch attempt." + (let ((candidate node-id) + (root-id (ebox--buffer-root-node-id buffer)) + owner) + (ignore dirty-kind) + (while (and candidate (not owner)) + (cond + ((equal candidate root-id) + (setq candidate nil)) + ((and (ebox--span-patchable-owner-p buffer candidate) + (let ((parent-id (ebox--runtime-parent-id buffer candidate))) + (or (not (ebox--geometry-affects-parent-layout-p + buffer candidate)) + (not parent-id) + (and (not (equal candidate node-id)) + (ebox--inline-size-change-p changed-keys) + (ebox--definite-containment-formatting-context-p + buffer candidate)) + (ebox--flex-item-slot-safe-p + buffer candidate changed-keys + (equal candidate node-id))))) + (setq owner candidate)) + (t + (setq candidate (ebox--runtime-parent-id buffer candidate))))) + owner)) + +(defun ebox--block-size-change-p (changed-keys) + "Return non-nil when CHANGED-KEYS contains only block-size constraints." + (and changed-keys + (cl-every (lambda (key) + (memq key '(:height :min-height :max-height))) + changed-keys))) + +(defun ebox--block-size-containment-owner-id (buffer node-id) + "Return NODE-ID's nearest strict fixed partial-flex containment owner." + (let ((candidate (ebox--runtime-parent-id buffer node-id)) + (root-id (ebox--buffer-root-node-id buffer)) + owner) + (while (and candidate (not owner)) + (cond + ((equal candidate root-id) + (setq candidate nil)) + ((and (ebox--contained-partial-flex-owner-p buffer candidate) + (ebox--span-patchable-owner-p buffer candidate)) + (setq owner candidate)) + (t + (setq candidate (ebox--runtime-parent-id buffer candidate))))) + owner)) + +(defun ebox--runtime-child-under-ancestor (buffer ancestor-id node-id) + "Return the direct child id under ANCESTOR-ID on NODE-ID's runtime path." + (let ((child-id node-id) + (parent-id (ebox--runtime-parent-id buffer node-id)) + found) + (while (and parent-id (not found)) + (if (equal parent-id ancestor-id) + (setq found child-id) + (setq child-id parent-id + parent-id (ebox--runtime-parent-id buffer parent-id)))) + found)) + +(defun ebox--skip-flex-span-probe-p (buffer entry owner-id) + "Return non-nil when a flex OWNER-ID span probe would duplicate rerender work." + (let ((node-id (plist-get entry :node-id)) + (changed-keys (plist-get entry :changed-keys))) + (and (eq (plist-get entry :dirty-kind) 'geometry) + changed-keys + (when-let* ((owner (ebox--buffer-runtime-node buffer owner-id)) + ((eq (ebox--display-inner owner) 'flex)) + ((or (equal owner-id node-id) + (not + (and + (or (ebox--inline-size-change-p changed-keys) + (ebox--block-size-change-p changed-keys)) + (ebox--definite-containment-formatting-context-p + buffer owner-id))))) + (child-id + (ebox--runtime-child-under-ancestor + buffer owner-id node-id))) + (and (ebox--direct-flex-parent-p buffer child-id) + (not (ebox--flex-item-slot-safe-p + buffer child-id changed-keys))))))) + +(defun ebox--span-patch-owner-id-for-dirty-entry (entry buffer) + "Return a local span patch owner for ENTRY in BUFFER, or nil." + (let* ((node-id (plist-get entry :node-id)) + (dirty-kind (plist-get entry :dirty-kind)) + (impact-vector (or (plist-get entry :impact-vector) + (and (plist-get entry :changed-keys) + (ebox--dirty-entry-impact-vector + buffer entry))))) + (unless (ebox--impact-vector-requires-conservative-owner-p impact-vector) + (cond + ((and (ebox--dirty-entry-span-patch-candidate-p buffer entry) + (ebox--cached-patchable-owner-p buffer node-id 'span) + (or (not (ebox--direct-flex-parent-p buffer node-id)) + (ebox--flex-item-slot-safe-p + buffer node-id (plist-get entry :changed-keys) t))) + node-id) + ((and (eq dirty-kind 'geometry) + (plist-get entry :changed-keys)) + (let ((owner-id + (or (and (ebox--block-size-change-p + (plist-get entry :changed-keys)) + (ebox--block-size-containment-owner-id + buffer node-id)) + (ebox--promote-to-span-patchable-owner + buffer node-id dirty-kind + (plist-get entry :changed-keys))))) + (unless (and owner-id + (ebox--skip-flex-span-probe-p buffer entry owner-id)) + owner-id))))))) + +(defun ebox--make-patch-op-merge-index () + "Return fresh mutable state for indexed patch-op antichain merging." + (list :owner-table (make-hash-table :test 'equal) + :ancestor-table (make-hash-table :test 'equal) + :serial 0)) + +(defun ebox--patch-op-merge-index-insert (buffer index entry) + "Register ENTRY (a vector [OP SERIAL ANCESTOR-SET]) into INDEX." + (puthash (plist-get (aref entry 0) :owner-id) + entry (plist-get index :owner-table)) + (let ((ancestor-table (plist-get index :ancestor-table))) + (maphash (lambda (ancestor-id _present) + (push entry (gethash ancestor-id ancestor-table))) + (aref entry 2))) + (ignore buffer)) + +(defun ebox--patch-op-merge-index-remove (index entry) + "Remove ENTRY from INDEX." + (remhash (plist-get (aref entry 0) :owner-id) + (plist-get index :owner-table)) + (let ((ancestor-table (plist-get index :ancestor-table))) + (maphash (lambda (ancestor-id _present) + (puthash ancestor-id + (delq entry (gethash ancestor-id ancestor-table)) + ancestor-table)) + (aref entry 2)))) + +(defun ebox--patch-op-merge-index-add (buffer index candidate) + "Merge CANDIDATE into indexed antichain INDEX for BUFFER. +Preserves `ebox--merge-patch-op-into-set' semantics exactly: a +same-owner op merges by strength first, then the earliest-inserted +op at an ancestor with span strength or better absorbs the candidate, +otherwise the candidate absorbs every entry below it (in insertion +order) and joins the set. Ancestor relations resolve through the +per-node ancestor sets, so each insertion costs one walk plus +hash lookups instead of a scan of the whole set." + (let* ((owner-table (plist-get index :owner-table)) + (owner-id (plist-get candidate :owner-id)) + (same-owner (gethash owner-id owner-table))) + (when same-owner + (setq candidate (ebox--merge-same-owner-patch-ops + (aref same-owner 0) candidate)) + (ebox--patch-op-merge-index-remove index same-owner)) + (let* ((ancestors (ebox--runtime-ancestor-id-set buffer owner-id)) + (dominator nil)) + (maphash + (lambda (ancestor-id _present) + (when-let ((entry (gethash ancestor-id owner-table))) + (when (and (>= (ebox--patch-operation-strength + (plist-get (aref entry 0) :op)) + (ebox--patch-operation-strength 'span-patch)) + (or (null dominator) + (< (aref entry 1) (aref dominator 1)))) + (setq dominator entry)))) + ancestors) + (if dominator + (aset dominator 0 + (ebox--patch-op-merge-dirty (aref dominator 0) candidate)) + (when (>= (ebox--patch-operation-strength + (plist-get candidate :op)) + (ebox--patch-operation-strength 'span-patch)) + (dolist (entry (sort (copy-sequence + (gethash owner-id + (plist-get index :ancestor-table))) + (lambda (a b) (< (aref a 1) (aref b 1))))) + (setq candidate + (ebox--patch-op-merge-dirty candidate (aref entry 0))) + (ebox--patch-op-merge-index-remove index entry))) + (let ((entry (vector candidate + (plist-get index :serial) + ancestors))) + (plist-put index :serial (1+ (plist-get index :serial))) + (ebox--patch-op-merge-index-insert buffer index entry)))))) + +(defun ebox--patch-op-merge-index-result (index) + "Return INDEX's surviving ops in insertion order." + (let (entries) + (maphash (lambda (_owner-id entry) (push entry entries)) + (plist-get index :owner-table)) + (mapcar (lambda (entry) (aref entry 0)) + (sort entries (lambda (a b) (< (aref a 1) (aref b 1))))))) + +(defun ebox--dedupe-patch-owners (buffer ops) + "Merge same-owner OPS and remove structurally covered descendants in BUFFER." + (let ((index (ebox--make-patch-op-merge-index))) + (dolist (op ops) + (ebox--patch-op-merge-index-add buffer index op)) + (ebox--patch-op-merge-index-result index))) + +(defun ebox--owner-rerender-op-p (op) + "Return non-nil when OP is an owner rerender operation." + (eq (plist-get op :op) 'owner-rerender)) + +(defun ebox--text-changing-patch-op-p (op) + "Return non-nil when OP replaces rendered text spans." + (memq (plist-get op :op) + '(child-reorder child-splice span-patch owner-rerender))) + +(defun ebox--text-changing-op-spans (buffer op) + "Return buffer spans for text-changing OP." + (when (ebox--text-changing-patch-op-p op) + (or (plist-get (plist-get op :prepared-publication) :spans) + (when-let* ((owner-id (plist-get op :owner-id)) + (snapshot + (ebox--ensure-layout-snapshot-spans buffer owner-id))) + (plist-get snapshot :buffer-spans))))) + +(defun ebox--owner-rerender-op-spans (buffer op) + "Return buffer spans for owner rerender OP." + (when-let* ((owner-id (plist-get op :owner-id)) + (snapshot (ebox--ensure-layout-snapshot-spans buffer owner-id))) + (plist-get snapshot :buffer-spans))) + +(defun ebox--owner-ops-covered-line-count (buffer ops) + "Return number of unique buffer lines covered by owner rerender OPS." + (let ((line-starts (make-hash-table :test 'eql))) + (dolist (op ops) + (dolist (span (ebox--owner-rerender-op-spans buffer op)) + (puthash (car span) t line-starts))) + (hash-table-count line-starts))) + +(defun ebox--root-owner-op (buffer ops) + "Return a root owner rerender op using merged provenance from OPS." + (ebox--patch-op 'owner-rerender + (ebox--buffer-root-node-id buffer) + :dirty (ebox--merge-dirty-provenance-list + (mapcar (lambda (op) + (plist-get op :dirty)) + ops)))) + +(defun ebox--coalesce-large-owner-patch-set (buffer ops) + "Coalesce large owner rerender sets when they already cover most root lines." + (if (not (and (>= (length ops) ebox--owner-coalesce-min-count) + (cl-every #'ebox--owner-rerender-op-p ops))) + ops + (let* ((root-id (ebox--buffer-root-node-id buffer)) + (root-spans (and root-id + (ebox--owner-rerender-op-spans + buffer (ebox--patch-op + 'owner-rerender root-id)))) + (root-lines (length root-spans)) + (covered-lines (ebox--owner-ops-covered-line-count buffer ops))) + (if (and (> root-lines 0) + (>= (/ (float covered-lines) root-lines) + ebox--owner-coalesce-min-coverage)) + (list (ebox--root-owner-op buffer ops)) + ops)))) + +(defun ebox--owner-rerender-op-start (buffer op) + "Return the earliest buffer position owned by owner rerender OP." + (when (ebox--text-changing-patch-op-p op) + (when-let ((spans (ebox--text-changing-op-spans buffer op))) + (caar spans)))) + +(defun ebox--runtime-node-depth (buffer node-id) + "Return NODE-ID's depth in BUFFER's runtime tree." + (let ((depth 0) + (parent-id (ebox--runtime-parent-id buffer node-id))) + (while parent-id + (cl-incf depth) + (setq parent-id (ebox--runtime-parent-id buffer parent-id))) + depth)) + +(defun ebox--patch-set-execution-order (buffer patch-set) + "Return PATCH-SET in paint-first and bottom-up text replacement order." + (mapcar + (lambda (entry) (aref entry 4)) + (sort + (cl-loop for op in patch-set + for index from 0 + for paint-p = (eq (plist-get op :op) 'paint-patch) + collect + (vector paint-p + (and (not paint-p) + (or (ebox--owner-rerender-op-start buffer op) -1)) + (ebox--runtime-node-depth + buffer (plist-get op :owner-id)) + index + op)) + (lambda (left right) + (let ((left-paint (aref left 0)) + (right-paint (aref right 0))) + (cond + ((not (eq left-paint right-paint)) left-paint) + (left-paint + (if (/= (aref left 2) (aref right 2)) + (< (aref left 2) (aref right 2)) + (< (aref left 3) (aref right 3)))) + (t + (if (/= (aref left 1) (aref right 1)) + (> (aref left 1) (aref right 1)) + (if (/= (aref left 2) (aref right 2)) + (> (aref left 2) (aref right 2)) + (< (aref left 3) (aref right 3))))))))))) + +(defun ebox--refresh-paint-layout-snapshots (buffer patch-set) + "Refresh only paint-significant snapshot fields touched by PATCH-SET." + (dolist (op patch-set) + (when (eq (plist-get op :op) 'paint-patch) + (let* ((dirty (plist-get op :dirty)) + (node-id (plist-get dirty :node-id)) + (snapshot (and node-id + (ebox--layout-snapshot buffer node-id))) + (node (and node-id + (ebox--buffer-runtime-node buffer node-id)))) + (when (and snapshot node) + (plist-put snapshot :style-signature + (ebox--node-style-signature node))))))) + +(defun ebox--capture-layout-snapshot-spans-subtree (buffer node) + "Capture lightweight snapshots with current spans for NODE's subtree." + (when (and (buffer-live-p buffer) + (listp node)) + (let* ((node-id (ebox--ensure-node-id node)) + (snapshot (ebox--node-layout-snapshot buffer node nil))) + (ebox--put-layout-snapshot + buffer node-id + (ebox--complete-layout-snapshot-spans + buffer node snapshot))) + (dolist (child (ebox--node-children node)) + (ebox--capture-layout-snapshot-spans-subtree buffer child)))) + +(defun ebox--layout-snapshot-from-patch-result + (buffer node result &optional final-spans) + "Return NODE's current detailed snapshot using patch RESULT spans. +Span patches already computed their new footprint and parent-slot proofs, so +reuse those values instead of rebuilding a dense region-line index over the +same replacement. Owner rerenders fall back to completing the snapshot from +RESULT's exact new spans. FINAL-SPANS, when non-nil, are normalized to the +completed transaction's buffer coordinates." + (let* ((snapshot (ebox--node-layout-snapshot buffer node nil)) + (spans (or final-spans (plist-get result :new-buffer-spans))) + (span-footprint + (plist-get result :new-span-footprint-signature))) + (setq snapshot (plist-put snapshot :buffer-spans spans)) + (setq snapshot + (plist-put snapshot :detail-generation + (ebox--buffer-layout-snapshot-detail-generation + buffer))) + (if (not span-footprint) + (ebox--complete-layout-snapshot buffer node snapshot) + (setq snapshot + (plist-put snapshot :line-signature + (plist-get span-footprint :line-pixel-widths))) + (setq snapshot + (plist-put snapshot :span-footprint-signature + span-footprint)) + (setq snapshot + (plist-put snapshot :external-footprint-signature + (plist-get result + :new-external-footprint-signature))) + (setq snapshot + (plist-put snapshot :parent-slot-signature + (plist-get result :new-parent-slot-signature))) + (setq snapshot + (plist-put snapshot :role-topology-signature + (ebox--role-topology-signature + (plist-get snapshot :region-ids)))) + (setq snapshot + (plist-put snapshot :overflow-signature + (ebox--overflow-signature node))) + snapshot))) + +(defun ebox--refresh-text-patch-layout-snapshots + (buffer results &optional source-node-ids scoped-plan) + "Refresh snapshots affected by text-patched RESULTS in BUFFER. +Only each patched owner needs eager buffer-dependent details. Nodes named by +SOURCE-NODE-IDS receive fresh lightweight style/layout facts; every other +descendant keeps its retained facts and completes invalidated spans on demand +from the scoped role index. SCOPED-PLAN supplies final transaction spans for +multi-result publication without changing backend result metadata." + (let ((final-spans-by-result + (plist-get scoped-plan :final-spans-by-result))) + (ebox--invalidate-buffer-layout-snapshot-details buffer) + (with-current-buffer buffer + (let ((ebox--node-region-ids-cache (make-hash-table :test 'eq)) + owner-ids) + (dolist (result results) + (when (ebox--text-changing-patch-op-p result) + (let* ((node-id (plist-get result :owner-id)) + (node (and node-id + (ebox--buffer-runtime-node buffer node-id))) + (new-spans + (or (and final-spans-by-result + (gethash result final-spans-by-result)) + (plist-get result :new-buffer-spans)))) + (when (and node new-spans) + (cl-pushnew node-id owner-ids :test #'equal) + (ebox--put-layout-snapshot + buffer node-id + (ebox--layout-snapshot-from-patch-result + buffer node result new-spans)))))) + (dolist (source-node-id source-node-ids) + (unless (member source-node-id owner-ids) + (when-let ((source-node + (ebox--buffer-runtime-node buffer source-node-id))) + (ebox--put-layout-snapshot + buffer source-node-id + (ebox--node-layout-snapshot buffer source-node nil))))))))) + +(defun ebox--patch-results-scoped-snapshot-refreshable-p (results) + "Return non-nil when RESULTS carry complete scoped owner snapshot spans." + (let ((text-results + (cl-remove-if-not #'ebox--text-changing-patch-op-p results))) + (and text-results + (cl-every + (lambda (result) + (and (plist-get result :new-buffer-spans) + (not (plist-get result :flex-line-rerender)))) + text-results)))) + +(defun ebox--refresh-prepared-animation-root-snapshot (buffer result) + "Keep only RESULT's exact root spans current during a prepared animation." + (when-let* ((node-id (plist-get result :owner-id)) + ((equal node-id (ebox--buffer-root-node-id buffer))) + (node (ebox--buffer-runtime-node buffer node-id)) + (spans (plist-get result :new-buffer-spans))) + (let* ((snapshots (ebox--ensure-buffer-layout-snapshots buffer)) + (old (gethash node-id snapshots)) + (snapshot + (if old + (ebox--layout-snapshot-strip-details old) + (ebox--node-layout-snapshot buffer node nil)))) + (setq snapshot (plist-put snapshot :buffer-spans spans)) + (setq snapshot + (plist-put snapshot :detail-generation + (ebox--buffer-layout-snapshot-detail-generation + buffer))) + ;; Child geometry belongs to the previous width. Drop it instead of + ;; walking the whole runtime tree after every frame or at completion; + ;; the ordinary lazy snapshot path reconstructs only a later caller's + ;; requested scope. + (ebox--clear-layout-snapshots buffer) + (puthash node-id snapshot + (ebox--ensure-buffer-layout-snapshots buffer))))) + +(defun ebox--patch-result-region-ids (buffer result) + "Return runtime region ids covered by RESULT in BUFFER." + (let ((region-ids + (copy-sequence (plist-get result :affected-region-ids)))) + (let ((owner-region-ids + (or (plist-get result :new-owner-region-ids) + (when-let* ((owner-id (plist-get result :owner-id)) + (node (ebox--buffer-runtime-node buffer owner-id))) + (ebox--node-all-region-ids node))))) + (dolist (region-id owner-region-ids) + (cl-pushnew region-id region-ids :test #'equal))) + region-ids)) + +(defun ebox--patch-span-numeric-bounds (span) + "Return numeric bounds for SPAN, or nil when its bounds are invalid." + (let ((start (car-safe span)) + (end (cdr-safe span))) + (setq start (if (markerp start) (marker-position start) start) + end (if (markerp end) (marker-position end) end)) + (when (and (integerp start) (integerp end) (<= start end)) + (cons start end)))) + +(defun ebox--patch-results-scoped-refresh-plan (buffer results) + "Return one normalized scoped index refresh plan for RESULTS in BUFFER. +The returned old and new spans use the transaction's original and final +coordinates respectively. Publication executes text replacements bottom-up, +so a later upper replacement can shift a lower result's recorded new spans; +this function derives final coordinates from the complete ordered delta map +without changing the backend result plists." + (let ((text-results + (cl-remove-if-not #'ebox--text-changing-patch-op-p results)) + (root-id (ebox--buffer-root-node-id buffer))) + (when (and text-results + (cl-every + (lambda (result) + (let ((old-spans + (or (plist-get result :old-refresh-spans) + (plist-get result :old-buffer-spans))) + (new-spans + (or (plist-get result :new-refresh-spans) + (plist-get result :new-buffer-spans)))) + (and (not (equal (plist-get result :owner-id) root-id)) + (not (plist-get result :flex-line-rerender)) + old-spans new-spans + (= (length old-spans) (length new-spans))))) + text-results) + (cl-every + (lambda (result) + (memq (plist-get result :op) + '(paint-patch child-reorder child-splice + span-patch owner-rerender))) + results)) + (catch 'unsafe + (let ((final-spans-by-result (make-hash-table :test 'eq)) + entries region-ids old-owner-region-ids new-owner-region-ids + owner-region-delta-p) + (setq owner-region-delta-p + (cl-every + (lambda (result) + (and (plist-member result :old-owner-region-ids) + (plist-member result :new-owner-region-ids))) + text-results)) + (dolist (result text-results) + (when owner-region-delta-p + (setq old-owner-region-ids + (append (plist-get result :old-owner-region-ids) + old-owner-region-ids) + new-owner-region-ids + (append (plist-get result :new-owner-region-ids) + new-owner-region-ids))) + (dolist (region-id + (append + (copy-sequence + (plist-get result :old-owner-region-ids)) + (ebox--patch-result-region-ids buffer result))) + (cl-pushnew region-id region-ids :test #'equal)) + (cl-loop + for old-span in (or (plist-get result :old-refresh-spans) + (plist-get result :old-buffer-spans)) + for new-span in (or (plist-get result :new-refresh-spans) + (plist-get result :new-buffer-spans)) + for old = (ebox--patch-span-numeric-bounds old-span) + for new = (ebox--patch-span-numeric-bounds new-span) + unless (and old new) + do (throw 'unsafe nil) + do (push (list :result result + :old old + :new-length (- (cdr new) (car new))) + entries))) + (setq entries + (sort entries + (lambda (left right) + (let ((left-old (plist-get left :old)) + (right-old (plist-get right :old))) + (if (= (car left-old) (car right-old)) + (< (cdr left-old) (cdr right-old)) + (< (car left-old) (car right-old))))))) + (let ((delta 0) + previous-start + previous-end + old-spans + new-spans) + (dolist (entry entries) + (let* ((result (plist-get entry :result)) + (old (plist-get entry :old)) + (old-start (car old)) + (old-end (cdr old)) + (new-length (plist-get entry :new-length))) + (when (or (and previous-end (< old-start previous-end)) + (and previous-start (= old-start previous-start))) + (throw 'unsafe nil)) + (let* ((new-start (+ old-start delta)) + (new (cons new-start (+ new-start new-length)))) + (push old old-spans) + (push new new-spans) + (unless (plist-get result :rendered-buffer-spans) + (puthash result + (cons new (gethash result final-spans-by-result)) + final-spans-by-result)) + (setq delta (+ delta (- new-length (- old-end old-start))) + previous-start old-start + previous-end old-end)))) + (dolist (result text-results) + (puthash result + (or (plist-get result :rendered-buffer-spans) + (nreverse + (gethash result final-spans-by-result))) + final-spans-by-result)) + (list :region-ids region-ids + :old-spans (nreverse old-spans) + :new-spans (nreverse new-spans) + :final-spans-by-result final-spans-by-result + :extent-delta-p owner-region-delta-p + :added-region-ids + (and owner-region-delta-p + (cl-set-difference + (delete-dups new-owner-region-ids) + old-owner-region-ids :test #'equal)) + :removed-region-ids + (and owner-region-delta-p + (cl-set-difference + (delete-dups old-owner-region-ids) + new-owner-region-ids :test #'equal))))))))) + +(defun ebox--patch-results-root-render-metadata (buffer results) + "Return root replacement metadata when RESULTS replace BUFFER's root." + (when (and (= (length results) 1) + (equal (plist-get (car results) :owner-id) + (ebox--buffer-root-node-id buffer))) + (plist-get (car results) :root-render-metadata))) + +(defun ebox--prepare-patch-results-root-render-metadata (buffer results) + "Upgrade and return complete metadata for a root replacement in RESULTS." + (when (and (= (length results) 1) + (equal (plist-get (car results) :owner-id) + (ebox--buffer-root-node-id buffer))) + (when-let ((rendered (plist-get (car results) :root-rendered))) + (when (fboundp 'ebox--rendered-root-metadata) + (ebox--rendered-root-metadata rendered))))) + +(defun ebox--refresh-runtime-indexes-for-patch-results + (buffer results strategy &optional index-refresh scoped-plan) + "Refresh runtime indexes after RESULTS using the smallest safe scope." + (let ((root-metadata + (ebox--patch-results-root-render-metadata buffer results)) + (scoped-plan + (or scoped-plan + (and (not (eq strategy 'paint-patch)) + (ebox--patch-results-scoped-refresh-plan buffer results))))) + (cond + ((eq strategy 'paint-patch) + :paint) + (scoped-plan + (let ((region-ids (plist-get scoped-plan :region-ids)) + (spans (plist-get scoped-plan :new-spans)) + (old-spans (plist-get scoped-plan :old-spans)) + (removed-region-ids (plist-get scoped-plan :removed-region-ids)) + (final-spans-by-result + (plist-get scoped-plan :final-spans-by-result)) + role-refresh active-segments) + (when region-ids + (setq role-refresh + (ebox-buffer-refresh-region-role-spans-for-region-ids + buffer region-ids spans old-spans + (unless + (cl-some + (lambda (result) + (plist-get result :old-owner-region-ids)) + results) + (delq nil + (mapcar + (lambda (result) + (when-let* ((rendered + (plist-get result :rendered)) + (result-spans + (and final-spans-by-result + (gethash + result final-spans-by-result)))) + (cons rendered result-spans))) + results))))) + (with-current-buffer buffer + (setq active-segments + (plist-get role-refresh :active-segments)) + (ebox--refresh-box-extents-from-role-spans + (delete-dups + (append (copy-sequence region-ids) + (copy-sequence removed-region-ids))) + spans active-segments)))) + :scoped) + ((eq index-refresh 'incremental-root) + (let* ((result (car results)) + (prepared-metadata (plist-get result :root-render-metadata)) + (prepared-role-template + (and (plist-get prepared-metadata :prepared-p) + (plist-get prepared-metadata :role-span-template))) + (prepared-extent-template + (and (plist-get prepared-metadata :prepared-p) + (plist-get prepared-metadata :box-extent-template))) + (prepared-scroll-template + (and (plist-get prepared-metadata :prepared-p) + (plist-get prepared-metadata + :scroll-content-span-template))) + (affected-region-ids + (plist-get result :affected-region-ids)) + (published-spans + (plist-get result :published-buffer-spans)) + (root-start + (caar (plist-get result :new-buffer-spans))) + (published-indexes-p + (plist-get result :incremental-root-indexes)) + (role-complete-p + (if published-indexes-p + t + (if (and prepared-role-template + (fboundp + 'ebox-buffer--install-region-role-span-template)) + (progn + (ebox-buffer--install-region-role-span-template + buffer prepared-role-template + root-start) + t) + (ebox-buffer-apply-role-delta + buffer (plist-get result :role-delta))))) + (extent-complete-p + (if published-indexes-p + t + (and (hash-table-p prepared-extent-template) + (progn + (ebox--install-box-extent-template + buffer prepared-extent-template + root-start) + t)))) + (scroll-result + (and (not published-indexes-p) + role-complete-p extent-complete-p + (ebox--refresh-patched-scroll-markers + buffer + affected-region-ids + published-spans + prepared-scroll-template + root-start))) + (complete-p + (or published-indexes-p + (and role-complete-p + extent-complete-p + (plist-get scroll-result :complete-p))))) + (plist-put result :incremental-root-indexes complete-p) + (unless (plist-get result :incremental-scroll-region-ids) + (plist-put result :incremental-scroll-region-ids + (plist-get scroll-result :published-region-ids))) + (if complete-p + :incremental-root + (ebox--clear-buffer-extents buffer) + (ebox--set-buffer-region-role-span-table buffer nil) + :deferred-root))) + ((eq index-refresh 'defer-extents) + (ebox--clear-buffer-extents buffer) + (ebox--set-buffer-region-role-span-table buffer nil) + :deferred-root) + ((and root-metadata + (eq index-refresh 'invalidate-extents) + (plist-get root-metadata :scroll-window-p) + (fboundp 'ebox--refresh-root-owner-scroll-windows)) + ;; Chrome-free scroll roots can recover their exact visible window from + ;; the dedicated render markers without installing broad role indexes. + ;; Mixed or chromed roots fall through to the prepared complete indexes. + (ebox--clear-buffer-extents buffer) + (ebox--set-buffer-region-role-span-table buffer nil) + (if (ebox--refresh-root-owner-scroll-windows buffer results) + :lightweight-root + (let ((prepared-metadata + (ebox--prepare-patch-results-root-render-metadata + buffer results))) + (if (and prepared-metadata + (plist-get prepared-metadata :prepared-p) + (fboundp 'ebox-buffer--install-region-role-span-template)) + (progn + (ebox-buffer--install-region-role-span-template + buffer + (plist-get prepared-metadata :role-span-template) + (caar (plist-get (car results) :new-buffer-spans))) + :prepared-root) + :full)))) + ((and root-metadata + (plist-get root-metadata :prepared-p) + (eq index-refresh 'invalidate-extents) + (fboundp 'ebox-buffer--install-region-role-span-template)) + (ebox--clear-buffer-extents buffer) + (ebox-buffer--install-region-role-span-template + buffer + (plist-get root-metadata :role-span-template) + (caar (plist-get (car results) :new-buffer-spans))) + :prepared-root) + ((eq index-refresh 'invalidate-extents) + (ebox--clear-buffer-extents buffer) + (ebox--set-buffer-region-role-span-table buffer nil) + :full) + (t + (ebox--refresh-buffer-box-extents buffer) + (ebox--set-buffer-region-role-span-table buffer nil) + :full)))) + +(defun ebox--span-patch-promoted-owner-fallback-op (buffer op) + "Return same-owner L3 fallback for a failed promoted span patch OP." + (let* ((dirty (plist-get op :dirty)) + (node-id (plist-get dirty :node-id)) + (dirty-kind (plist-get dirty :dirty-kind)) + (owner-id (plist-get op :owner-id))) + (when (and buffer + owner-id + (not (equal owner-id node-id)) + (ebox--patchable-owner-p buffer owner-id dirty-kind)) + (ebox--patch-op 'owner-rerender owner-id + :dirty dirty)))) + +(defun ebox--span-patch-root-fallback-op (buffer op) + "Return conservative owner-rerender fallback op for failed span patch OP." + (let* ((dirty (plist-get op :dirty)) + (node-id (plist-get dirty :node-id)) + (dirty-kind (plist-get dirty :dirty-kind)) + (owner-id + (or (plist-get op :fallback-owner-id) + (and buffer + (ebox--with-layout-snapshot-index-context buffer + (ebox--promote-to-patchable-owner + buffer node-id dirty-kind + (plist-get dirty :changed-keys)))) + node-id))) + (when owner-id + (ebox--patch-op 'owner-rerender owner-id + :dirty dirty)))) + +(defun ebox--next-patchable-owner-ancestor + (buffer owner-id dirty-kind &optional require-owned-overflow-coverage) + "Return the nearest non-root patchable ancestor above OWNER-ID. +When REQUIRE-OWNED-OVERFLOW-COVERAGE is non-nil, skip ancestors whose wrapper +cannot cover an old unowned visible-overflow suffix." + (let ((candidate (ebox--runtime-parent-id buffer owner-id)) + (root-id (ebox--buffer-root-node-id buffer)) + owner) + (while (and candidate (not owner)) + (cond + ((equal candidate root-id) + (setq candidate nil)) + ((and (or (not require-owned-overflow-coverage) + (ebox--owned-overflow-coverage-wrapper-node-p + buffer candidate)) + (ebox--cached-patchable-owner-p + buffer candidate dirty-kind require-owned-overflow-coverage)) + (setq owner candidate)) + (t + (setq candidate (ebox--runtime-parent-id buffer candidate))))) + owner)) + +(defun ebox--ancestor-owner-fallback-op (buffer op) + "Return nearest ancestor owner fallback for failed text-changing OP." + (let* ((dirty (plist-get op :dirty)) + (dirty-kind (plist-get dirty :dirty-kind)) + (requires-owned-overflow-coverage + (plist-get dirty :requires-owned-overflow-coverage)) + (owner-id (plist-get op :owner-id)) + (ancestor-id + (and buffer owner-id + (ebox--next-patchable-owner-ancestor + buffer owner-id dirty-kind + requires-owned-overflow-coverage)))) + (when ancestor-id + (ebox--patch-op 'owner-rerender ancestor-id + :dirty dirty + :fallback-from-owner-id owner-id)))) + +(defun ebox--apply-span-patch-fallbacks (buffer op) + "Apply fallback chain for failed span patch OP in BUFFER." + (or (when-let ((fallback + (ebox--span-patch-promoted-owner-fallback-op + buffer op))) + (ebox-buffer-apply-owner-rerender buffer fallback)) + (when-let ((fallback + (ebox--ancestor-owner-fallback-op buffer op))) + (ebox-buffer-apply-owner-rerender buffer fallback)) + (when-let ((fallback + (ebox--span-patch-root-fallback-op buffer op))) + (ebox-buffer-apply-owner-rerender buffer fallback)))) + +(defun ebox--apply-owner-rerender-fallbacks (buffer op) + "Apply ancestor owner fallbacks for failed owner-rerender OP in BUFFER. +Climb the runtime ancestor chain until one owner publishes. A single +failing ancestor must not abort the walk: an owner whose spans share +buffer lines with siblings (for example a stage column inside a scene +row) cannot slot-preserve interior text whose line shape changed, but +a complete-line ancestor above it still can, and the documented +fallback chain only reaches root when every ancestor fails." + (let ((current op) + result) + (while (and (not result) + (setq current + (ebox--ancestor-owner-fallback-op buffer current))) + (setq result (ebox-buffer-apply-owner-rerender buffer current))) + result)) + +(defun ebox--patch-results-strategy (results) + "Return aggregate update strategy for executed patch RESULTS." + (cond + ((cl-some (lambda (result) + (eq (plist-get result :op) 'owner-rerender)) + results) + 'owner-rerender) + ((cl-some (lambda (result) + (eq (plist-get result :op) 'span-patch)) + results) + 'span-patch) + ((cl-some (lambda (result) + (eq (plist-get result :op) 'child-splice)) + results) + 'child-splice) + ((cl-some (lambda (result) + (eq (plist-get result :op) 'child-reorder)) + results) + 'child-reorder) + ((cl-every (lambda (result) + (eq (plist-get result :op) 'paint-patch)) + results) + 'paint-patch) + (t 'owner-rerender))) + +(defun ebox-incremental--validate-prepared-patch-set (patch-set) + "Validate that PATCH-SET is complete and conflict-free before publication." + (dolist (op patch-set) + (let ((prepared (plist-get op :prepared-publication))) + (unless (and prepared + (eq (plist-get prepared :op) (plist-get op :op)) + (equal (plist-get prepared :owner-id) + (plist-get op :owner-id)) + (or (not (ebox--text-changing-patch-op-p op)) + (plist-get prepared :spans) + (and (eq (plist-get op :op) 'child-splice) + (plist-get prepared :old-refresh-spans) + (plist-get prepared :new-refresh-spans)))) + (error "Ebox declarative patch lacks a matching prepared proof: %S" + (list (plist-get op :op) (plist-get op :owner-id)))))) + (when-let ((overlapping + (ebox-incremental--first-overlapping-prepared-text-ops + patch-set))) + (error "Ebox declarative prepared owners still overlap: %S and %S" + (plist-get (car overlapping) :owner-id) + (plist-get (cadr overlapping) :owner-id))) + t) + +(defun ebox-incremental--publish-prepared-patch-op (buffer op) + "Publish one already-proven declarative OP into BUFFER without fallback." + (let ((prepared (plist-get op :prepared-publication))) + (pcase (plist-get op :op) + ('paint-patch + (ebox-buffer-publish-prepared-paint-patch buffer prepared)) + ('span-patch + (ebox-buffer-publish-prepared-span-patch buffer prepared)) + ('child-reorder + (ebox-buffer-publish-prepared-child-reorder buffer prepared)) + ('child-splice + (ebox-buffer-publish-prepared-child-splice buffer prepared)) + ('owner-rerender + (ebox-buffer-publish-prepared-owner-rerender buffer prepared)) + (_ nil)))) + +(defun ebox-incremental--publish-prepared-patch-set + (buffer patch-set dirty-count) + "Publish final declarative PATCH-SET for BUFFER as one prepared transaction." + (ebox--execute-patch-set + buffer patch-set nil dirty-count nil 'invalidate-extents 'prepared-only)) + +(defun ebox--execute-patch-set + (buffer patch-set &optional region-id dirty-count + snapshot-refresh index-refresh publication-mode) + "Execute PATCH-SET in BUFFER and return an update report. +When PUBLICATION-MODE is `prepared-only', every operation must carry a final +prepared proof and publication cannot render, promote owners, or fall back." + (catch 'fallback + (let* ((prepared-only-p (eq publication-mode 'prepared-only)) + (_prepared-validation + (and prepared-only-p + (ebox-incremental--validate-prepared-patch-set patch-set))) + (source-node-ids + (delete-dups + (cl-mapcan + (lambda (op) + (ebox--dirty-provenance-node-ids + (plist-get op :dirty))) + patch-set))) + (root-node-id (ebox--buffer-root-node-id buffer)) + (buffer-scroll-region-ids + (plist-get (ebox--buffer-render-state buffer) + :scroll-region-ids)) + (scoped-owner-rerender-p + (cl-some + (lambda (op) + (and (eq (plist-get op :op) 'owner-rerender) + (not (equal (plist-get op :owner-id) root-node-id)))) + patch-set)) + (pre-scroll-state-region-ids + (and scoped-owner-rerender-p + buffer-scroll-region-ids + (fboundp + 'ebox--scroll-state-region-ids-containing-node-ids) + (ebox--scroll-state-region-ids-containing-node-ids + buffer source-node-ids))) + (ebox--collect-rebuilt-scroll-state-region-ids t) + (ebox--rebuilt-scroll-state-region-ids nil) + results) + (dolist (op (ebox--patch-set-execution-order buffer patch-set)) + (if prepared-only-p + (if-let ((result + (ebox-incremental--publish-prepared-patch-op buffer op))) + (push result results) + (error "Ebox prepared declarative publication failed for %S" + (list (plist-get op :op) (plist-get op :owner-id)))) + (pcase (plist-get op :op) + ('paint-patch + (if-let ((result (ebox-buffer-apply-paint-patch buffer op))) + (push result results) + (throw 'fallback nil))) + ('span-patch + (if-let ((result + (or (ebox-buffer-apply-span-patch buffer op) + (ebox--apply-span-patch-fallbacks buffer op)))) + (push result results) + (throw 'fallback nil))) + ('owner-rerender + (if-let ((result + (or (ebox-buffer-apply-owner-rerender buffer op) + (ebox--apply-owner-rerender-fallbacks buffer op)))) + (push result results) + (throw 'fallback nil))) + (_ (throw 'fallback nil))))) + (when results + (setq results (nreverse results)) + (let* ((strategy (ebox--patch-results-strategy results)) + (scoped-plan + (and (not (eq strategy 'paint-patch)) + (ebox--patch-results-scoped-refresh-plan + buffer results))) + (refresh-scope + (ebox--refresh-runtime-indexes-for-patch-results + buffer results strategy index-refresh scoped-plan)) + fast-root-scroll-region-ids + owner-scroll-publication + owner-scroll-patch) + (if (eq strategy 'paint-patch) + (ebox--refresh-paint-layout-snapshots buffer patch-set) + (cond + ((and + (eq refresh-scope :scoped) + (or (eq strategy 'span-patch) + (and (eq strategy 'owner-rerender) + (null snapshot-refresh))) + (ebox--patch-results-scoped-snapshot-refreshable-p results)) + (ebox--refresh-text-patch-layout-snapshots + buffer results source-node-ids scoped-plan)) + ((eq snapshot-refresh 'prepared-animation) + (ebox--refresh-prepared-animation-root-snapshot + buffer (car results))) + ((eq snapshot-refresh 'invalidate-details) + (ebox--invalidate-buffer-layout-snapshot-details buffer)) + (t + (ebox--refresh-buffer-layout-snapshots buffer))) + (let* ((root-metadata + (ebox--patch-results-root-render-metadata buffer results)) + (prepared-scroll-refresh + (and (eq refresh-scope :prepared-root) + root-metadata + (fboundp + 'ebox--install-buffer-scroll-content-span-template) + (ebox--install-buffer-scroll-content-span-template + buffer + (plist-get root-metadata + :scroll-content-span-template) + (caar (plist-get (car results) + :new-buffer-spans))))) + (incremental-scroll-refresh + (and (eq refresh-scope :incremental-root) + (plist-get (car results) + :incremental-root-indexes))) + (lightweight-scroll-refresh + (or (eq refresh-scope :lightweight-root) + incremental-scroll-refresh + prepared-scroll-refresh + (and (eq refresh-scope :full) + (fboundp + 'ebox--refresh-root-owner-scroll-windows) + (ebox--refresh-root-owner-scroll-windows + buffer results))))) + (when (and lightweight-scroll-refresh + (memq refresh-scope + '(:lightweight-root :prepared-root + :incremental-root))) + (setq fast-root-scroll-region-ids + (if (eq refresh-scope :incremental-root) + (copy-sequence + (plist-get (car results) + :incremental-scroll-region-ids)) + (copy-sequence + (plist-get (ebox--buffer-render-state buffer) + :scroll-region-ids))))) + (when (and (eq refresh-scope :full) + (not lightweight-scroll-refresh) + (fboundp 'ebox-buffer-refresh-region-role-spans)) + (ebox-buffer-refresh-region-role-spans buffer)) + (when (and (eq refresh-scope :full) + (not lightweight-scroll-refresh) + (fboundp + 'ebox--refresh-buffer-scroll-content-markers)) + (ebox--refresh-buffer-scroll-content-markers buffer)))) + ;; Root/prepared refreshes install their state first; scoped owner + ;; replacements reach the same publication gate afterward. Only a + ;; verified all-state publication may suppress ordinary post-sync. + (setq owner-scroll-publication + (when (eq strategy 'owner-rerender) + (cond + (fast-root-scroll-region-ids + (list :covered-p t + :covered-region-ids fast-root-scroll-region-ids + :published-region-ids fast-root-scroll-region-ids)) + ((and (or (and (not scoped-owner-rerender-p) + buffer-scroll-region-ids) + pre-scroll-state-region-ids + ebox--rebuilt-scroll-state-region-ids) + (fboundp + 'ebox--publish-scoped-owner-scroll-windows)) + (ebox--publish-scoped-owner-scroll-windows + buffer results source-node-ids + pre-scroll-state-region-ids + ebox--rebuilt-scroll-state-region-ids + (plist-get scoped-plan + :final-spans-by-result)))))) + (setq owner-scroll-patch + (plist-get owner-scroll-publication :covered-p)) + (let ((report + (ebox--update-report + region-id strategy + :dirty-count (or dirty-count (length patch-set)) + :patch-count (length results) + :patch-ops (mapcar (lambda (result) + (plist-get result :op)) + results) + :owner-ids (mapcar (lambda (result) + (plist-get result :owner-id)) + results) + :owner-type (and (= (length results) 1) + (plist-get (car results) :owner-type)) + :span-count (cl-loop for result in results + sum (or (plist-get result :span-count) + 0)) + :publication-scope + (and (= (length results) 1) + (plist-get (car results) :publication-scope)) + :published-buffer-spans + (and (= (length results) 1) + (plist-get (car results) :published-buffer-spans)) + :full-root-required-p + (and (= (length results) 1) + (plist-get (car results) :full-root-required-p)) + :patch-origin + (and (= (length results) 1) + (plist-get (car results) :patch-origin)) + :full-frame-bytes + (and (= (length results) 1) + (plist-get (car results) :full-frame-bytes)) + :replacement-bytes + (and (= (length results) 1) + (plist-get (car results) :replacement-bytes)) + :snapshot-refresh snapshot-refresh + :index-refresh index-refresh + :incremental-root-indexes + (and (= (length results) 1) + (plist-get (car results) + :incremental-root-indexes)) + :scoped-index-refresh (eq refresh-scope :scoped) + :slot-preserving (cl-some + (lambda (result) + (plist-get result :slot-preserving)) + results) + :flex-line-rerender + (cl-some + (lambda (result) + (plist-get result :flex-line-rerender)) + results) + :old-signature (and (= (length results) 1) + (plist-get (car results) + :old-signature)) + :new-signature (and (= (length results) 1) + (plist-get (car results) + :new-signature)) + :old-span-footprint-signature + (and (= (length results) 1) + (plist-get (car results) + :old-span-footprint-signature)) + :new-span-footprint-signature + (and (= (length results) 1) + (plist-get (car results) + :new-span-footprint-signature)) + :old-external-footprint-signature + (and (= (length results) 1) + (plist-get (car results) + :old-external-footprint-signature)) + :new-external-footprint-signature + (and (= (length results) 1) + (plist-get (car results) + :new-external-footprint-signature)) + :old-parent-slot-signature + (and (= (length results) 1) + (plist-get (car results) + :old-parent-slot-signature)) + :new-parent-slot-signature + (and (= (length results) 1) + (plist-get (car results) + :new-parent-slot-signature))))) + (if (plist-get owner-scroll-publication + :published-region-ids) + (append + report + (list + :scroll-state-owner-publication t + :scroll-state-owner-published-region-ids + (plist-get owner-scroll-publication + :published-region-ids) + :scroll-state-window-marker-refresh-count + (length + (plist-get owner-scroll-publication + :published-region-ids))) + (when owner-scroll-patch + (list + :scroll-state-patch t + :scroll-state-sync t + :scroll-state-sync-count + (length + (plist-get owner-scroll-publication + :published-region-ids))))) + report))))))) + +(defun ebox--constraint-root-rerender-report + (change patches &optional region-id) + "Return a root-rerender report for failed CHANGE patching." + (let ((dirty-set (plist-get change :dirty-set))) + (ebox--update-report + region-id 'root-rerender + :dirty-count (length dirty-set) + :patch-count (length patches) + :patch-ops (mapcar (lambda (patch) + (plist-get patch :op)) + patches) + :owner-ids (mapcar (lambda (patch) + (plist-get patch :owner-id)) + patches) + :root-rerender t))) + +(defun ebox--large-viewport-constraint-change-p (change) + "Return non-nil when CHANGE should skip local owner planning." + (and (eq (plist-get change :constraint-source) 'viewport) + (>= (or (plist-get change :coalesced-dirty-count) + (length (plist-get change :dirty-set))) + ebox--owner-coalesce-min-count))) + +(defun ebox--root-reflow-constraint-change-p (change) + "Return non-nil when CHANGE replaces the runtime root's width constraint." + (or (eq (plist-get change :constraint-source) 'viewport) + (and (eq (plist-get change :constraint-source) 'region) + (equal (plist-get change :constraint-owner-id) + (plist-get change :constraint-root-node-id)) + (cl-some (lambda (key) + (memq key '(:width :min-width :max-width))) + (ebox--dirty-set-changed-keys + (plist-get change :dirty-set)))))) + +(defun ebox--buffer-visible-in-graphic-frame-p (buffer) + "Return non-nil when BUFFER is visible in a graphical frame." + (cl-some (lambda (window) + (display-graphic-p (window-frame window))) + (get-buffer-window-list buffer nil t))) + +(defun ebox--execute-constraint-change (buffer change &optional region-id) + "Execute a normalized constraint CHANGE against BUFFER." + (let* ((dirty-set (plist-get change :dirty-set)) + (dirty-kinds (ebox--dirty-set-kinds dirty-set)) + (invalidated (ebox-cache-invalidated-spec-names dirty-kinds)) + (root-reflow (ebox--root-reflow-constraint-change-p change)) + (defer-gc (and root-reflow + (not ebox--root-reflow-gc-deferred-p) + (ebox--buffer-visible-in-graphic-frame-p buffer))) + (ebox--root-reflow-gc-deferred-p + (or ebox--root-reflow-gc-deferred-p defer-gc)) + report) + (when defer-gc + (ebox--deferred-render-gc-enter)) + (unwind-protect + (progn + (ebox-cache-clear-report buffer) + (ebox-cache-record-invalidation buffer invalidated dirty-kinds) + (let* ((ebox--scroll-window-initial-lookahead-lines-override + (and root-reflow + (ebox--root-reflow-scroll-lookahead-lines-for-buffer + buffer))) + (ebox-cache-report-buffer buffer) + (patches + (or (and (or (ebox--large-viewport-constraint-change-p + change) + (and root-reflow + (eq (plist-get change + :constraint-source) + 'region))) + (ebox--root-owner-patch-set buffer dirty-set)) + (ebox--patch-set-from-dirty-set dirty-set buffer)))) + (setq report + (or (and (null dirty-set) + (ebox--update-report region-id 'no-op + :dirty-count 0 + :patch-count 0 + :patch-ops nil)) + (and patches + (ebox--execute-patch-set + buffer patches region-id (length dirty-set) + (when root-reflow + (if ebox--prepared-root-defer-index-refresh + 'prepared-animation + 'invalidate-details)) + (when root-reflow + (cond + ((eq ebox--prepared-root-defer-index-refresh + 'incremental) + 'incremental-root) + (ebox--prepared-root-defer-index-refresh + 'defer-extents) + (t 'invalidate-extents))))) + (progn + (ebox-buffer-apply-root-rerender buffer) + (ebox--constraint-root-rerender-report + change patches region-id))))) + (if report + (append report (ebox--constraint-change-report-props change)) + report)) + (when defer-gc + (ebox--deferred-render-gc-schedule-restore))))) + +(defun ebox--try-paint-patch-region (buffer region-id) + "Patch REGION-ID paint-only changes in BUFFER when safe." + (when-let ((root (ebox--buffer-root-node buffer))) + (let* ((old (ebox--current-layout-snapshots buffer)) + (new (ebox--layout-snapshots-for-runtime buffer root)) + (dirty (ebox--diff-layout-snapshots old new)) + (patches (ebox--patch-set-from-dirty-set dirty buffer))) + (when (and patches + (cl-every (lambda (op) + (eq (plist-get op :op) 'paint-patch)) + patches)) + (ebox--execute-patch-set + buffer patches region-id (length dirty)))))) + +(defun ebox--try-owner-rerender-region (buffer region-id) + "Patch REGION-ID by rerendering the smallest safe owner in BUFFER." + (when-let ((dirty-node + (ebox--buffer-region-render-owner-node buffer region-id))) + (let* ((dirty-node-id (ebox--ensure-node-id dirty-node)) + (dirty (list (ebox--dirty-entry dirty-node-id 'geometry))) + (patches (ebox--patch-set-from-dirty-set dirty buffer))) + (ebox--execute-patch-set buffer patches region-id (length dirty))))) + +(defun ebox--viewport-dependent-size-value-p (value &optional nil-dependent) + "Return non-nil when horizontal size VALUE depends on viewport context. +When NIL-DEPENDENT is non-nil, nil is treated as viewport-dependent auto width." + (or (and nil-dependent (null value)) + (and nil-dependent (eq value 'auto)) + (memq value '(stretch contain fit-content viewport)) + (and (consp value) + (or (eq (car value) 'viewport) + (and (eq (car value) 'fit-content) + (ebox--viewport-dependent-size-value-p (cadr value))))))) + +(defun ebox--viewport-dependent-height-value-p (value) + "Return non-nil when vertical size VALUE depends on viewport height." + (or (eq value 'viewport-height) + (and (consp value) + (or (eq (car value) 'viewport-height) + (and (memq (car value) '(+ -)) + (cl-some #'ebox--viewport-dependent-height-value-p + (cdr value))))))) + +(defun ebox--viewport-dependent-height-props-p (props) + "Return non-nil when PROPS has a viewport-height size constraint." + (cl-some + (lambda (property) + (ebox--viewport-dependent-height-value-p + (plist-get props property))) + '(:height :min-height :max-height))) + +(defun ebox--node-direct-viewport-height-dependent-p (node) + "Return non-nil when NODE's own vertical size uses viewport height." + (when (and (listp node) + (not (stringp node))) + (pcase (plist-get node :ebox-type) + ('box + (ebox--viewport-dependent-height-props-p node)) + ('flex + (or (ebox--viewport-dependent-height-props-p + (plist-get node :props)) + (ebox--viewport-dependent-height-props-p + (plist-get node :raw-props))))))) + +(defun ebox--flex-participation-viewport-dependent-p (node) + "Return non-nil when NODE's flex item metadata depends on viewport context." + (when-let ((participation (ebox--flex-participation-props node))) + (ebox--viewport-dependent-size-value-p + (plist-get participation :flex-basis)))) + +(defun ebox--node-direct-viewport-width-dependent-p (node) + "Return non-nil when NODE's own layout depends on viewport width." + (when (listp node) + (or (ebox--flex-participation-viewport-dependent-p node) + (unless (eq (plist-get node :ebox-type) 'flex-item) + (cond + ((eq (ebox--display-inner node) 'flex) + (ebox--viewport-dependent-size-value-p + (plist-get (plist-get node :props) :width) t)) + ((eq (ebox--display-inner node) 'flow) + (or (ebox--viewport-dependent-size-value-p + (plist-get node :width) t) + (ebox--viewport-dependent-size-value-p + (plist-get node :min-width)) + (ebox--viewport-dependent-size-value-p + (plist-get node :max-width))))))))) + +(defun ebox--node-direct-viewport-dependent-p (node) + "Return non-nil when NODE's own layout depends on viewport context." + (or (ebox--node-direct-viewport-width-dependent-p node) + (ebox--node-direct-viewport-height-dependent-p node))) + +(defun ebox--viewport-height-dependent-subtree-p (node) + "Return non-nil when NODE or a descendant depends on viewport height." + (when (and (listp node) + (not (stringp node))) + (cl-labels + ((compute () + (or (ebox--node-direct-viewport-height-dependent-p node) + (cl-some #'ebox--viewport-height-dependent-subtree-p + (ebox--node-children node))))) + (if (not ebox--viewport-height-dependent-subtree-cache) + (compute) + (let* ((missing (make-symbol "ebox-viewport-height-subtree-missing")) + (cached + (gethash node ebox--viewport-height-dependent-subtree-cache + missing))) + (if (not (eq cached missing)) + cached + (puthash node (compute) + ebox--viewport-height-dependent-subtree-cache))))))) + +(defun ebox--viewport-dependent-subtree-p (node) + "Return non-nil when NODE or any descendant depends on viewport width." + (when (and (listp node) + (not (stringp node))) + (if (not ebox--viewport-dependent-subtree-cache) + (or (ebox--node-direct-viewport-width-dependent-p node) + (cl-some #'ebox--viewport-dependent-subtree-p + (ebox--node-children node))) + (let* ((missing (make-symbol "ebox-viewport-subtree-missing")) + (cached (gethash node ebox--viewport-dependent-subtree-cache + missing))) + (if (not (eq cached missing)) + cached + (puthash + node + (or (ebox--node-direct-viewport-width-dependent-p node) + (cl-some #'ebox--viewport-dependent-subtree-p + (ebox--node-children node))) + ebox--viewport-dependent-subtree-cache)))))) + +(defun ebox--viewport-dependent-node-id-axes (node) + "Return (WIDTH-IDS . HEIGHT-IDS) below NODE. +Definite width contains descendant width ids, but not height ids." + (when (and (listp node) + (not (stringp node))) + (cl-labels + ((collect () + (let* ((width-direct + (ebox--node-direct-viewport-width-dependent-p node)) + (vertical + (ebox--node-direct-viewport-height-dependent-p node)) + (contained + (ebox--render-cache-contained-viewport-node-p node)) + (children (ebox--node-children node)) + (relevant-children + (if contained + (cl-remove-if-not + #'ebox--viewport-height-dependent-subtree-p + children) + children)) + (child-axes + (mapcar #'ebox--viewport-dependent-node-id-axes + relevant-children)) + (node-id (and (or width-direct vertical) + (ebox--ensure-node-id node)))) + (cons + (append + (when width-direct + (list node-id)) + (unless contained + (apply #'append (mapcar #'car child-axes)))) + (append + (when vertical + (list node-id)) + (apply #'append (mapcar #'cdr child-axes))))))) + (if (not ebox--viewport-dependent-node-ids-cache) + (collect) + (let* ((missing (make-symbol "ebox-viewport-dependent-missing")) + (cached (gethash node ebox--viewport-dependent-node-ids-cache + missing))) + (if (not (eq cached missing)) + cached + (puthash node (collect) + ebox--viewport-dependent-node-ids-cache))))))) + +(defun ebox--viewport-dependent-node-ids (node) + "Return runtime node ids under NODE that depend on viewport context." + (when-let ((axes (ebox--viewport-dependent-node-id-axes node))) + (delete-dups (copy-sequence (append (car axes) (cdr axes)))))) + +(defun ebox--viewport-dirty-set (root) + "Return viewport dirty-set entries for ROOT." + (mapcar (lambda (node-id) + (ebox--dirty-entry node-id 'geometry)) + (delete-dups + (copy-sequence (ebox--viewport-dependent-node-ids root))))) + +(defun ebox--viewport-constraint-change (buffer &optional axes) + "Return the implicit viewport constraint change for BUFFER. +AXES may be `width', `height', `both', or `none'. Nil means both axes for +legacy callers." + (when-let ((root (ebox--buffer-root-node buffer))) + (let* ((node-ids + (pcase axes + ('width + (car (ebox--buffer-viewport-dependent-node-id-axes buffer))) + ('height + (cdr (ebox--buffer-viewport-dependent-node-id-axes buffer))) + ('none nil) + (_ + (ebox--buffer-viewport-dependent-node-ids buffer)))) + (node-ids (delete-dups (copy-sequence node-ids))) + (root-node-id (ebox--ensure-node-id root)) + (coalesced + (>= (length node-ids) ebox--owner-coalesce-min-count)) + (dirty-set + (if coalesced + (list (ebox--dirty-entry + root-node-id 'geometry :node-ids node-ids)) + (mapcar (lambda (node-id) + (ebox--dirty-entry node-id 'geometry)) + node-ids)))) + (ebox--constraint-change + 'viewport 'viewport 'viewport + root-node-id dirty-set + :coalesced-dirty-count (and coalesced (length node-ids)))))) + +(defun ebox--region-update-dirty-kind (changed-keys) + "Return the dirty kind for CHANGED-KEYS from `ebox-region-update'." + (if (cl-every (lambda (key) + (memq key ebox--paint-style-signature-keys)) + changed-keys) + 'paint + 'geometry)) + +(defun ebox--region-constraint-change + (buffer region-id dirty-kind &optional changed-keys old-style-values) + "Return the explicit region constraint change for REGION-ID in BUFFER." + (when-let* ((root (ebox--buffer-root-node buffer)) + (dirty-node + (ebox--buffer-region-render-owner-node buffer region-id)) + (dirty-node-id (ebox--ensure-node-id dirty-node))) + (let ((dirty-entry + (ebox--dirty-entry dirty-node-id dirty-kind + :region-id region-id + :changed-keys changed-keys + :old-style-values old-style-values + :region-ids + (and (eq dirty-kind 'paint) + (ebox--node-all-region-ids dirty-node))))) + (ebox--constraint-change + 'region dirty-node-id (plist-get dirty-node :ebox-type) + (ebox--ensure-node-id root) + (list (ebox--dirty-entry-with-impact-vector buffer dirty-entry)) + :region-id region-id)))) + +(defun ebox--update-report (region-id strategy &rest props) + "Build an update report for REGION-ID using STRATEGY and PROPS." + (append (list :region-id region-id + :strategy strategy + :full-rerender (eq strategy 'full-rerender)) + props + (when ebox-cache-report-buffer + (ebox-cache-report ebox-cache-report-buffer)))) + +(defun ebox-incremental--prepared-root-context-box + (state prepared context) + "Return the root model box selected by PREPARED and CONTEXT in STATE." + (or (plist-get context :box) + (let ((root (plist-get state :root-node)) + (region-id (plist-get prepared :region-id))) + (or (and region-id (ebox--root-region-box root region-id)) + (pcase (plist-get root :ebox-type) + ('box root) + ('flex (plist-get root :box))))))) + +(defun ebox-incremental--prepared-root-target-matches-p + (buffer state prepared context) + "Return non-nil when PREPARED targets BUFFER's exact CONTEXT." + (let* ((root (plist-get state :root-node)) + (native-patch (plist-get prepared :native-patch))) + (and prepared native-patch + (eq (plist-get prepared :publication-owner) + 'prepared-root-native) + (plist-get native-patch :native-patch) + (eq state (plist-get prepared :render-state)) + (equal (ebox--ensure-node-id root) (plist-get prepared :node-id)) + (eq (plist-get prepared :kind) (plist-get context :kind)) + (equal (plist-get prepared :region-id) + (plist-get context :region-id)) + (equal (plist-get prepared :viewport-width) + (plist-get context :target-viewport-width)) + (equal (plist-get prepared :viewport-height) + (plist-get context :target-viewport-height)) + (equal (plist-get prepared :root-width) + (plist-get context :target-root-width)) + (eq buffer (current-buffer))))) + +(defun ebox-incremental--prepared-root-base-current-p + (buffer state prepared context) + "Return non-nil when PREPARED's base still equals BUFFER and STATE." + (let ((box (ebox-incremental--prepared-root-context-box + state prepared context))) + (and (cl-every (lambda (key) (plist-member prepared key)) + '(:base-buffer-chars-modified-tick + :base-root-width :base-viewport-width + :base-viewport-height)) + (equal (plist-get prepared :runtime-revision) + (plist-get state :runtime-revision)) + (equal (plist-get prepared :display-signature) + (with-current-buffer buffer + (ebox--current-display-signature))) + (= (plist-get prepared :base-buffer-chars-modified-tick) + (with-current-buffer buffer (buffer-chars-modified-tick))) + (equal (plist-get prepared :base-viewport-width) + (plist-get state :viewport-width)) + (equal (plist-get prepared :base-viewport-height) + (plist-get state :viewport-height)) + (equal (plist-get prepared :base-root-width) + (ebox--literal-root-pixel-width box))))) + +(defun ebox-incremental--validate-prepared-root-native-metadata (prepared) + "Return PREPARED's complete native root metadata, or signal an error." + (let* ((native-patch (plist-get prepared :native-patch)) + (metadata (plist-get native-patch :root-render-metadata))) + (unless (and (plist-get prepared :full-p) + (plist-get prepared :metadata-complete-p) + (plist-get metadata :prepared-p) + (hash-table-p (plist-get metadata :role-span-template)) + (hash-table-p (plist-get metadata :box-extent-template)) + (hash-table-p + (plist-get metadata :scroll-content-span-template))) + (error "Prepared native root lacks complete target indexes")) + metadata)) + +(defun ebox-incremental--prepared-root-candidate-state (state context) + "Return an isolated target state copied from STATE for CONTEXT." + (let ((candidate (copy-sequence state))) + (plist-put candidate :viewport-width + (plist-get context :target-viewport-width)) + (plist-put candidate :viewport-height + (plist-get context :target-viewport-height)) + (dolist (key '(:render-signature-cache + :viewport-height-dependent-subtree-cache)) + (when-let ((table (plist-get state key))) + (plist-put candidate key (copy-hash-table table)))) + (when-let ((scratch (plist-get state :reflow-prewarm-scratch))) + (plist-put candidate :reflow-prewarm-scratch + (copy-sequence scratch))) + candidate)) + +(defun ebox-incremental--prepared-root-cache-report (dirty-kinds) + "Return cache invalidation facts equivalent to DIRTY-KINDS execution." + (let ((names (ebox-cache-invalidated-spec-names dirty-kinds)) + scopes) + (dolist (name names) + (when-let ((spec (ebox-cache-spec name))) + (cl-pushnew (ebox-cache-spec-scope spec) scopes :test #'equal))) + (list :cache-invalidated names + :cache-hit-count 0 + :cache-miss-count 0 + :cache-scope scopes + :cache-fallback-reason dirty-kinds))) + +(defun ebox-incremental--prepared-root-layout-snapshots + (buffer state spans) + "Return a root-only target snapshot table for BUFFER at SPANS." + (let* ((root (plist-get state :root-node)) + (root-id (ebox--ensure-node-id root)) + (old-table (plist-get state :layout-snapshots)) + (old (and (hash-table-p old-table) (gethash root-id old-table))) + (snapshot + (if old + (ebox--layout-snapshot-strip-details old) + (ebox--node-layout-snapshot buffer root nil))) + (target (make-hash-table :test 'equal))) + (plist-put snapshot :buffer-spans spans) + (plist-put snapshot :detail-generation + (or (plist-get state :layout-snapshot-detail-generation) 0)) + (puthash root-id snapshot target) + target)) + +(defun ebox-incremental--stage-prepared-root-scroll-state + (buffer old-state candidate-state plan text-result) + "Return isolated target scroll publication for prepared root PLAN." + (let* ((candidate-table + (ebox-incremental--candidate-scroll-state-table + buffer old-state candidate-state + (plist-get candidate-state :region-box-table))) + (timers (make-hash-table :test 'equal)) + (smooth (make-hash-table :test 'equal)) + (metadata (plist-get plan :root-render-metadata)) + (preflight (plist-get plan :scroll-preflight)) + (expected (plist-get preflight :scroll-region-ids)) + (candidate-keys + (ebox-incremental--hash-keys candidate-table)) + result staged-p) + (unless (null (cl-set-exclusive-or + expected candidate-keys :test #'equal)) + (error "Prepared native root lacks an exact scroll state set")) + (unwind-protect + (progn + (let ((ebox-incremental--buffer-render-state-override + (cons buffer candidate-state)) + (ebox--scroll-global-state candidate-table) + (ebox--scroll-idle-prefetch-timers timers) + (ebox--smooth-scroll-state-table smooth) + (ebox--render-cache-scroll-state-restorable-p nil)) + (cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch) + (lambda (&rest _) nil))) + (setq result + (ebox--refresh-patched-scroll-markers + buffer expected + (plist-get text-result :published-buffer-spans) + (plist-get metadata :scroll-content-span-template) + (plist-get plan :start))))) + (unless (and (plist-get result :complete-p) + (null (cl-set-exclusive-or + expected + (plist-get result :published-region-ids) + :test #'equal))) + (error "Prepared native root did not stage exact scroll indexes")) + (setq staged-p t) + (list :table candidate-table + :keys (copy-sequence expected) + :result result)) + (unless staged-p + (ebox-incremental--detach-scroll-state-table-markers + candidate-table))))) + +(defun ebox-incremental--prepared-root-report + (state context text-result scroll-keys impact cache-report) + "Return the native publication report for CONTEXT and TEXT-RESULT." + (let* ((root (plist-get state :root-node)) + (root-id (ebox--ensure-node-id root)) + (kind (plist-get context :kind)) + (region-id (plist-get context :region-id)) + (viewport-p (eq kind 'viewport)) + (publication-scope + (if (plist-get text-result :full-root-required-p) + 'full-root + 'minimal-spans))) + (append + (ebox--update-report + region-id 'owner-rerender + :dirty-count 1 :patch-count 1 :patch-ops '(owner-rerender) + :owner-ids (list root-id) :owner-type (plist-get root :ebox-type) + :span-count (length (plist-get text-result :new-buffer-spans)) + :publication-scope publication-scope + :published-buffer-spans + (plist-get text-result :published-buffer-spans) + :full-root-required-p + (plist-get text-result :full-root-required-p) + :patch-origin 'rust :full-frame-bytes 0 + :replacement-bytes (plist-get text-result :replacement-bytes) + :snapshot-refresh 'prepared-animation + :index-refresh 'incremental-root + :incremental-root-indexes t + :incremental-scroll-region-ids scroll-keys + :constraint-source (if viewport-p 'viewport 'region) + :constraint-owner-id (if viewport-p 'viewport root-id) + :constraint-owner-type + (if viewport-p 'viewport (plist-get root :ebox-type)) + :constraint-root-node-id root-id + :dirty-kinds '(geometry) + :dirty-keys (unless viewport-p '(:width)) + :impact-vector impact + :viewport-axes (and viewport-p (plist-get context :axes)) + :runtime-published t + :runtime-revision (plist-get state :runtime-revision)) + (when scroll-keys + (list :scroll-state-owner-publication t + :scroll-state-owner-published-region-ids scroll-keys + :scroll-state-window-marker-refresh-count (length scroll-keys) + :scroll-state-patch t :scroll-state-sync t + :scroll-state-sync-count (length scroll-keys))) + cache-report))) + +(defun ebox-incremental--snapshot-marker-table (snapshot) + "Return a hash table containing present values from SNAPSHOT." + (let ((table (make-hash-table :test 'equal))) + (dolist (entry snapshot table) + (when (nth 1 entry) + (puthash (car entry) (nth 2 entry) table))))) + +(defun ebox-incremental--retire-prepared-root-state + (old-role-table extent-snapshot scroll-snapshot scroll-keys) + "Retire marker state replaced by one prepared root publication. +This post-commit cleanup never turns a successful publication into a reported +failure. Cleanup errors are surfaced as warnings after all retirements run." + (let ((inhibit-quit t) + errors) + (dolist + (step + (list + (lambda () + (ebox-incremental--detach-region-role-span-table old-role-table)) + (lambda () + (dolist (entry extent-snapshot) + (when-let ((extents (and (nth 1 entry) (nth 2 entry)))) + (when (markerp (car-safe extents)) + (set-marker (car extents) nil)) + (when (markerp (cdr-safe extents)) + (set-marker (cdr extents) nil))))) + (lambda () + (ebox-incremental--detach-scroll-state-table-markers + (ebox-incremental--snapshot-marker-table scroll-snapshot))) + (lambda () + (ebox-incremental--finalize-declarative-scroll-publication + scroll-keys)))) + (condition-case err + (funcall step) + (error (push err errors)))) + (when errors + (display-warning + 'ebox + (format "Prepared root marker retirement errors: %S" + (nreverse errors)) + :error)) + (null errors))) + +(defun ebox-incremental--publish-prepared-root-state (buffer state) + "Publish prepared root STATE as BUFFER's live runtime pointer." + (puthash buffer state ebox--buffer-render-state-table)) + +(defun ebox-incremental--publish-prepared-root-native (buffer context) + "Publish BUFFER's exact prepared native root for CONTEXT, or return nil." + (unless (buffer-live-p buffer) + (error "Prepared native root requires a live buffer")) + (with-current-buffer buffer + (save-restriction + (widen) + (let* ((state (ebox--buffer-render-state buffer)) + (prepared ebox--prepared-root-render)) + (when (and state + (ebox-incremental--prepared-root-target-matches-p + buffer state prepared context) + (ebox-incremental--prepared-root-base-current-p + buffer state prepared context)) + (let* ((metadata + (ebox-incremental--validate-prepared-root-native-metadata + prepared)) + (native-patch (plist-get prepared :native-patch)) + (root-spans (list (cons (point-min) (point-max)))) + (plan + (or (ebox-buffer--preflight-native-root-property-patches + root-spans native-patch) + (error "Prepared native root patch could not be planned"))) + (box (ebox-incremental--prepared-root-context-box + state prepared context)) + (root-width-p (eq (plist-get context :kind) 'root-width)) + (box-before (and root-width-p (copy-sequence box))) + (old-role-table (plist-get state :region-role-span-table)) + (old-extent-template ebox--box-extent-template) + (target-extents (plist-get plan :prepared-extent-table)) + (extent-keys + (ebox-incremental--hash-keys target-extents)) + (extent-snapshot + (ebox-incremental--hash-snapshot + ebox--box-extents extent-keys)) + (scroll-keys + (copy-sequence + (plist-get (plist-get plan :scroll-preflight) + :scroll-region-ids))) + (scroll-snapshot + (ebox-incremental--hash-snapshot + ebox--scroll-global-state scroll-keys)) + (old-cache-snapshot (ebox-cache-snapshot-report buffer)) + (target-cache-report + (ebox-incremental--prepared-root-cache-report '(geometry))) + (old-consumed-p (plist-get prepared :consumed-p)) + candidate-state scroll-publication text-result report revision + extent-entered-p scroll-entered-p cache-entered-p + state-entered-p committed-p) + (ebox--cancel-buffer-runtime-prewarm buffer) + (ebox-incremental--notify-before-runtime-mutation + buffer (if root-width-p 'region-update 'viewport)) + (unless (and (eq state (ebox--buffer-render-state buffer)) + (ebox-incremental--prepared-root-base-current-p + buffer state prepared context)) + (error "Prepared native root base changed during notification")) + (setq candidate-state + (ebox-incremental--prepared-root-candidate-state + state context)) + (unwind-protect + (progn + (setq report + (ebox-buffer--with-native-root-patch-window-state + buffer plan + (let ((inhibit-read-only t)) + (atomic-change-group + (setq text-result + (ebox-buffer--publish-native-root-property-patch-text + plan)) + (when root-width-p + (ebox-put box :width + (plist-get context + :target-root-width))) + (let ((ebox-incremental--buffer-render-state-override + (cons buffer candidate-state))) + (when root-width-p + (ebox--invalidate-buffer-render-signatures + buffer (plist-get context :model-node-id)) + (ebox--invalidate-buffer-viewport-dependencies + buffer)) + (ebox--set-buffer-region-role-span-table + buffer (plist-get plan :prepared-role-table)) + (setq scroll-publication + (ebox-incremental--stage-prepared-root-scroll-state + buffer state candidate-state plan + text-result)) + (plist-put + candidate-state :layout-snapshots + (ebox-incremental--prepared-root-layout-snapshots + buffer candidate-state + (plist-get text-result :new-buffer-spans))) + (plist-put candidate-state + :layout-snapshots-complete-p nil) + (setq revision + (ebox--bump-buffer-runtime-revision + buffer root-width-p)) + (when root-width-p + (ebox--confirm-reflow-prewarm-scratch + buffer (plist-get context :region-id) + revision)) + (let* ((root + (plist-get candidate-state :root-node)) + (root-id (ebox--ensure-node-id root)) + (impact + (and root-width-p + (plist-get + (ebox--dirty-entry-with-impact-vector + buffer + (ebox--dirty-entry + root-id 'geometry + :region-id + (plist-get context :region-id) + :changed-keys '(:width))) + :impact-vector)))) + (setq report + (ebox-incremental--prepared-root-report + candidate-state context text-result + scroll-keys impact + target-cache-report))) + (plist-put candidate-state + :last-update-report report)) + (let ((inhibit-quit t)) + (setq extent-entered-p t) + (ebox--install-box-extent-template + buffer + (plist-get metadata :box-extent-template) + (plist-get plan :start)) + (dolist (region-id extent-keys) + (remhash region-id ebox--box-extents)) + (setq scroll-entered-p t) + (ebox-incremental--replace-hash-entries + ebox--scroll-global-state scroll-keys + (plist-get scroll-publication :table)) + (setq cache-entered-p t) + (ebox-cache-publish-report + buffer target-cache-report) + (setq state-entered-p t) + (ebox-incremental--publish-prepared-root-state + buffer candidate-state) + (setq ebox--prepared-root-render nil) + (plist-put prepared :consumed-p t)) + report)))) + (setq committed-p t) + (ebox-incremental--retire-prepared-root-state + old-role-table extent-snapshot scroll-snapshot scroll-keys) + report) + (unless committed-p + (let ((inhibit-quit t) + rollback-errors) + (dolist + (step + (list + (lambda () + (when state-entered-p + (puthash buffer state + ebox--buffer-render-state-table))) + (lambda () + (when cache-entered-p + (ebox-cache-restore-report + buffer old-cache-snapshot))) + (lambda () + (when scroll-entered-p + (ebox-incremental--restore-hash-snapshot + ebox--scroll-global-state scroll-snapshot))) + (lambda () + (when extent-entered-p + (setq-local ebox--box-extent-template + old-extent-template) + (ebox-incremental--restore-hash-snapshot + ebox--box-extents extent-snapshot))) + (lambda () + (when box-before + (setcar box (car box-before)) + (setcdr box (cdr box-before)))) + (lambda () + (when scroll-publication + (ebox-incremental--detach-scroll-state-table-markers + (plist-get scroll-publication :table)))) + (lambda () + (setq ebox--prepared-root-render prepared) + (plist-put prepared :consumed-p old-consumed-p)))) + (condition-case err + (funcall step) + (error (push err rollback-errors)))) + (when rollback-errors + (display-warning + 'ebox + (format "Prepared root rollback errors: %S" + (nreverse rollback-errors)) + :error))))))))))) + +(defun ebox--try-patch-node-same-signature (node region-id) + "Patch NODE in place when its rendered footprint is unchanged. +Return an update report plist when patching succeeds." + (unless (ebox--node-visible-overflow-p node) + (let* ((region-ids (ebox--node-all-region-ids node)) + (spans (and region-ids + (ebox--region-set-line-spans region-ids)))) + (when spans + (let* ((old-span-footprint + (ebox--spans-span-footprint-signature spans)) + (old-external-footprint + (ebox--external-footprint-signature-from-span-footprint + old-span-footprint)) + (old-parent-slot + (ebox--spans-parent-slot-signature spans)) + (rendered (ebox-render node)) + (new-span-footprint + (ebox--rendered-span-footprint-signature rendered)) + (new-external-footprint + (ebox--external-footprint-signature-from-span-footprint + new-span-footprint)) + (new-parent-slot + (ebox--project-parent-slot-signature + old-parent-slot new-span-footprint))) + (when (and (ebox--span-footprint-compatible-p + old-span-footprint new-span-footprint) + (ebox--external-footprint-compatible-p + old-external-footprint new-external-footprint) + (ebox--parent-slot-compatible-p + old-parent-slot new-parent-slot)) + (when (ebox--replace-line-spans spans rendered) + (ebox--update-report + region-id 'span-patch + :dirty-count 1 + :patch-count 1 + :patch-ops '(span-patch) + :owner-id (plist-get node :node-id) + :owner-type (plist-get node :ebox-type) + :span-count (length spans) + :old-signature + (plist-get old-span-footprint :line-pixel-widths) + :new-signature + (plist-get new-span-footprint :line-pixel-widths) + :old-span-footprint-signature old-span-footprint + :new-span-footprint-signature new-span-footprint + :old-external-footprint-signature old-external-footprint + :new-external-footprint-signature + new-external-footprint + :old-parent-slot-signature old-parent-slot + :new-parent-slot-signature + new-parent-slot)))))))) + +(defun ebox--incremental-rerender-region (buffer region-id) + "Rerender REGION-ID in BUFFER using the smallest signature-stable owner." + (when-let ((root (ebox--buffer-root-node buffer))) + (ebox--with-buffer-render-context buffer + (with-current-buffer buffer + (let ((path (ebox--node-path-to-region root region-id)) + patched) + (while (and path (not patched)) + (setq patched + (ebox--try-patch-node-same-signature (pop path) region-id))) + patched))))) + +(defun ebox--rerender-region-for-dirty-kind + (region-id dirty-kind &optional changed-keys buffer old-style-values) + "Rerender REGION-ID as a normalized DIRTY-KIND constraint change." + (if-let ((buf (or buffer (ebox--region-buffer region-id)))) + (progn + (ebox--invalidate-buffer-viewport-dependencies buf) + (or (when-let ((change + (ebox--region-constraint-change + buf region-id dirty-kind changed-keys + old-style-values))) + (ebox--execute-constraint-change buf change region-id)) + (progn + (ebox-buffer-apply-root-rerender buf) + (ebox--update-report + region-id 'full-rerender + :dirty-count 1 + :patch-count 0 + :patch-ops nil)))) + (ebox--rerender-region region-id))) + +(defun ebox--rerender-region (region-id) + "Rerender REGION-ID safely. +Prefer a full root-node rerender when the buffer was produced by +`ebox-render-to-buffer'. The in-place path is only a fallback for truly +standalone contiguous slices." + (if-let ((buf (ebox--region-buffer region-id))) + (or (ebox--try-paint-patch-region buf region-id) + (when-let ((report (ebox--incremental-rerender-region + buf region-id))) + (ebox--refresh-buffer-layout-snapshots buf) + report) + (ebox--try-owner-rerender-region buf region-id) + (progn + (ebox-buffer-apply-root-rerender buf) + (ebox--update-report region-id 'full-rerender + :dirty-count 1 + :patch-count 0 + :patch-ops nil))) + (when (ebox--live-box-extents region-id) + (ebox--rerender-box region-id) + (ebox--update-report region-id 'region-rerender + :dirty-count 1 + :patch-count 1 + :patch-ops '(region-rerender))))) + +;;; Region Buffer Lookup + +(defun ebox--region-buffer (region-id) + "Return the buffer that contains the box identified by REGION-ID, or nil." + (ebox--prune-dead-buffer-render-states) + (catch 'found + (maphash (lambda (buf state) + (when (and (buffer-live-p buf) + (gethash region-id + (plist-get state :region-id-set))) + (throw 'found buf))) + ebox--buffer-render-state-table) + (maphash (lambda (buf _state) + (when (and (buffer-live-p buf) + (with-current-buffer buf + (save-excursion + (goto-char (point-min)) + (cl-some + (lambda (entry) + (goto-char (point-min)) + (text-property-search-forward + (cdr entry) region-id t)) + ebox-region-types)))) + (throw 'found buf))) + ebox--buffer-render-state-table) + nil)) + +;;; Viewport Rerender Helper + +(defun ebox-incremental-rerender-buffer-with-context + (buffer viewport-width &optional viewport-height) + "Rerender BUFFER's stored runtime tree using viewport dimensions." + (unless (buffer-live-p buffer) + (error "ebox: buffer is not live: %S" buffer)) + (let ((state (ebox--buffer-render-state buffer))) + (unless state + (error "ebox: buffer has no runtime render state: %S" buffer)) + (let ((defer-gc + (and (not ebox--root-reflow-gc-deferred-p) + (ebox--buffer-visible-in-graphic-frame-p buffer)))) + (when defer-gc + (ebox--deferred-render-gc-enter)) + (unwind-protect + (let ((ebox--root-reflow-gc-deferred-p + (or ebox--root-reflow-gc-deferred-p defer-gc))) + (ebox--with-render-gc + (let* ((old-viewport-width (plist-get state :viewport-width)) + (old-viewport-height + (plist-get state :viewport-height)) + (width-changed + (not (equal viewport-width old-viewport-width))) + (height-changed + (and viewport-height + (not (equal viewport-height + old-viewport-height)))) + (axes (cond + ((and width-changed height-changed) 'both) + (width-changed 'width) + (height-changed 'height) + (t 'none))) + (change nil) + (root-box + (ebox-incremental--prepared-root-context-box + state ebox--prepared-root-render nil)) + (report + (and (not (eq axes 'none)) root-box + (ebox-incremental--publish-prepared-root-native + buffer + (list + :kind 'viewport :region-id nil :axes axes + :target-viewport-width viewport-width + :target-viewport-height + (or viewport-height old-viewport-height) + :target-root-width + (ebox--literal-root-pixel-width root-box)))))) + (if report + report + (unless (eq axes 'none) + (ebox-incremental--notify-before-runtime-mutation + buffer 'viewport)) + (unwind-protect + (progn + (plist-put state :viewport-width viewport-width) + (when viewport-height + (plist-put state :viewport-height viewport-height)) + (setq change + (ebox--viewport-constraint-change buffer axes)) + (let ((ebox--scroll-prefetch-delay-override 0)) + (setq report + (ebox--execute-constraint-change + buffer change nil)))) + ;; A quit or render error must leave the published + ;; dimensions matching the published render, or the + ;; next same-size call compares against the + ;; never-executed dimensions and degrades into a + ;; permanent no-op that never repairs the buffer. + (unless report + (plist-put state :viewport-width old-viewport-width) + (when viewport-height + (plist-put state :viewport-height + old-viewport-height)))) + (setq report + (if (plist-member report :dirty-count) + report + (append + report + (list :dirty-count + (length (plist-get change :dirty-set)))))) + (ebox--set-buffer-update-report buffer report))))) + (when defer-gc + (ebox--deferred-render-gc-schedule-restore)))))) + +(defun ebox--rerender-buffer-preserving-runtime (buffer) + "Rerender BUFFER from stored runtime state without rebuilding identity." + (let ((state (ebox--buffer-render-state buffer))) + (if state + (ebox-incremental-rerender-buffer-with-context + buffer (plist-get state :viewport-width)) + (ebox-buffer-apply-root-rerender buffer)))) + +(defun ebox-incremental--batch-state (buffer) + "Return explicit batch state for BUFFER." + (gethash buffer ebox-incremental--batch-table)) + +(defun ebox-incremental-batching-p (buffer) + "Return non-nil when BUFFER is inside an explicit incremental batch." + (and (buffer-live-p buffer) + (plist-get (ebox-incremental--batch-state buffer) :active))) + +(defun ebox-incremental-begin-batch (buffer) + "Begin an explicit incremental update batch for BUFFER." + (unless (buffer-live-p buffer) + (error "ebox-incremental: buffer is not live: %S" buffer)) + (let ((state (list :active t + :pending nil + :flush-count 0))) + (puthash buffer state ebox-incremental--batch-table) + state)) + +(defun ebox-incremental-pending-count (buffer) + "Return the number of pending explicit batch changes for BUFFER." + (length (plist-get (ebox-incremental--batch-state buffer) :pending))) + +(defun ebox-incremental-record-region-change + (buffer region-id dirty-kind &optional changed-keys old-style-values) + "Record REGION-ID and DIRTY-KIND in BUFFER's explicit batch. +Return non-nil when the change was recorded instead of executed immediately." + (when (ebox-incremental-batching-p buffer) + (let ((state (ebox-incremental--batch-state buffer))) + (plist-put state :pending + (append (plist-get state :pending) + (list (list :region-id region-id + :dirty-kind dirty-kind + :changed-keys changed-keys + :old-style-values old-style-values)))) + t))) + +(defun ebox-incremental--merge-old-style-values (old-values additions) + "Merge ADDITIONS into OLD-VALUES while preserving each first old value." + (let ((merged (copy-sequence old-values)) + (additions (copy-sequence additions))) + (while additions + (let ((key (pop additions)) + (value (pop additions))) + (unless (plist-member merged key) + (setq merged (append merged (list key value)))))) + merged)) + +(defun ebox-incremental--batch-dirty-set (buffer pending) + "Return a dirty set for BUFFER from PENDING batch entries." + (let ((seen (make-hash-table :test 'equal)) + dirty-set) + (dolist (entry pending) + (when-let* ((region-id (plist-get entry :region-id)) + (dirty-kind (plist-get entry :dirty-kind)) + (dirty-node + (ebox--buffer-region-render-owner-node buffer region-id)) + (node-id (ebox--ensure-node-id dirty-node))) + (let* ((key (list node-id dirty-kind)) + (changed-keys (plist-get entry :changed-keys)) + (existing (gethash key seen))) + (if existing + (progn + (plist-put + existing :changed-keys + (delete-dups + (append (copy-sequence + (or (plist-get existing :changed-keys) nil)) + (copy-sequence changed-keys)))) + (plist-put + existing :old-style-values + (ebox-incremental--merge-old-style-values + (plist-get existing :old-style-values) + (plist-get entry :old-style-values))) + (ebox--dirty-entry-with-impact-vector buffer existing)) + (let ((dirty-entry + (ebox--dirty-entry + node-id dirty-kind + :region-id region-id + :changed-keys changed-keys + :old-style-values (plist-get entry :old-style-values) + :region-ids + (and (eq dirty-kind 'paint) + (ebox--node-all-region-ids dirty-node))))) + (setq dirty-entry + (ebox--dirty-entry-with-impact-vector buffer dirty-entry)) + (puthash key dirty-entry seen) + (push dirty-entry dirty-set)))))) + (nreverse dirty-set))) + +(defun ebox-incremental--batch-change (buffer pending) + "Return a normalized batch constraint change for BUFFER and PENDING." + (when-let ((root (ebox--buffer-root-node buffer))) + (ebox--constraint-change + 'batch 'batch 'batch + (ebox--ensure-node-id root) + (ebox-incremental--batch-dirty-set buffer pending)))) + +(defun ebox-incremental-flush (buffer) + "Flush BUFFER's explicit incremental update batch." + (unless (buffer-live-p buffer) + (error "ebox-incremental: buffer is not live: %S" buffer)) + (let* ((state (ebox-incremental--batch-state buffer)) + (pending (plist-get state :pending))) + (unless state + (error "ebox-incremental: no active batch for buffer: %S" buffer)) + (plist-put state :active nil) + (plist-put state :pending nil) + (plist-put state :flush-count + (1+ (or (plist-get state :flush-count) 0))) + (if (null pending) + (ebox--set-buffer-update-report + buffer + (ebox--update-report nil 'no-op + :dirty-count 0 + :patch-count 0 + :patch-ops nil + :flush-count 1)) + (ebox--invalidate-buffer-viewport-dependencies buffer) + (let* ((change (ebox-incremental--batch-change buffer pending)) + report) + (unwind-protect + (setq report + (and change + (ebox--execute-constraint-change buffer change nil))) + ;; A quit or render error during execution must leave the + ;; batch active with its recorded changes still pending so a + ;; later retry cannot silently publish a no-op. If execution + ;; returned a report, including an explicit no-op report, the + ;; flush completed and the batch should close normally. + (when (and change (null report)) + (plist-put state :active t) + (plist-put state :pending pending) + (plist-put state :flush-count + (1- (plist-get state :flush-count))))) + (setq report + (append report + (list :flush-count 1 + :batched-count (length pending)))) + (ebox--set-buffer-update-report buffer report) + (when (and report + (not (eq (plist-get report :strategy) 'no-op))) + (ebox--bump-buffer-runtime-revision buffer) + (run-hook-with-args + 'ebox-incremental--after-successful-batch-flush-hook + buffer pending report)) + (ebox--buffer-update-report buffer))))) + +;;; Declarative Root Commit + +(defun ebox-incremental-candidate-begin (buffer) + "Return a logical one-shot candidate based on BUFFER's current runtime." + (unless (buffer-live-p buffer) + (error "Ebox candidate requires a live buffer: %S" buffer)) + (let ((state (ebox--buffer-render-state buffer))) + (unless state + (error "Ebox buffer has no rendered runtime: %S" buffer)) + (ebox-incremental--make-candidate + :buffer buffer + :base-state state + :base-root (plist-get state :root-node) + :base-revision (or (plist-get state :runtime-revision) 0) + :base-buffer-tick + (with-current-buffer buffer (buffer-modified-tick)) + :replacements nil + :sealed-p nil))) + +(defun ebox-incremental--candidate-ancestor-p + (candidate ancestor-id descendant-id) + "Return non-nil when ANCESTOR-ID contains DESCENDANT-ID in CANDIDATE's base." + (let* ((state (ebox-candidate--base-state candidate)) + (parent-table (plist-get state :parent-table)) + (current (gethash descendant-id parent-table)) + found) + (while (and current (not found)) + (if (equal current ancestor-id) + (setq found t) + (setq current (gethash current parent-table)))) + found)) + +(defun ebox-incremental-candidate-replace + (candidate node-id next-subtree + &optional old-semantic-key new-semantic-key) + "Replace NODE-ID in CANDIDATE with an isolated copy of NEXT-SUBTREE. + +Repeated replacement of one anchor is last-wins. A later ancestor supersedes +earlier descendants; a later descendant is applied inside an earlier ancestor. +When both OLD-SEMANTIC-KEY and NEW-SEMANTIC-KEY are non-nil, they identify +detached semantic variants of this stable anchor for bounded identity reuse." + (unless (ebox-candidate-p candidate) + (error "Ebox candidate replacement requires an Ebox candidate")) + (when (ebox-candidate--sealed-p candidate) + (error "Ebox candidate is already sealed")) + (unless (eq (null old-semantic-key) (null new-semantic-key)) + (error "Ebox semantic replacement requires both old and new keys")) + (let* ((state (ebox-candidate--base-state candidate)) + (node-table (plist-get state :node-table)) + retained) + (unless (gethash node-id node-table) + (error "Ebox candidate anchor does not exist: %S" node-id)) + (unless (and (listp next-subtree) (not (stringp next-subtree))) + (error "Ebox candidate replacement must be an Ebox node")) + ;; Validate before copying so reused objects and cycles remain observable. + (ebox-tree-validate-declarative-root next-subtree) + (let ((replacement + (ebox-tree-clear-runtime-identities + (ebox-tree-copy-node-structure next-subtree)))) + ;; The new operation supersedes an earlier operation at the same anchor + ;; and every earlier descendant operation. An earlier ancestor stays + ;; before a new descendant so the descendant is applied inside its + ;; candidate-owned subtree at commit time instead of being discarded. + (dolist (entry (ebox-candidate--replacements candidate)) + (let ((existing-id + (ebox-incremental--candidate-replacement-anchor-id entry))) + (unless (or (equal existing-id node-id) + (ebox-incremental--candidate-ancestor-p + candidate node-id existing-id)) + (push entry retained)))) + (setf (ebox-candidate--replacements candidate) + (append + (nreverse retained) + (list + (ebox-incremental--make-candidate-replacement + :anchor-id node-id + :subtree replacement + :old-semantic-key old-semantic-key + :new-semantic-key new-semantic-key))))) + candidate)) + +(defun ebox-incremental-candidate-replace-host-ref + (candidate host-ref next-subtree + &optional old-semantic-key new-semantic-key) + "Replace HOST-REF in CANDIDATE with declarative NEXT-SUBTREE. + +HOST-REF is resolved against the exact published base captured when CANDIDATE +was begun. References introduced only by earlier candidate replacements are +not anchors for the same transaction. OLD-SEMANTIC-KEY and NEW-SEMANTIC-KEY +have the same optional detached-identity meaning as in +`ebox-incremental-candidate-replace'." + (unless (ebox-candidate-p candidate) + (error "Ebox host-ref replacement requires an Ebox candidate")) + (when (ebox-candidate--sealed-p candidate) + (error "Ebox candidate is already sealed")) + (let* ((missing (make-symbol "ebox-missing-host-ref")) + (node-id + (gethash host-ref + (plist-get (ebox-candidate--base-state candidate) + :host-ref-table) + missing))) + (when (eq node-id missing) + (error "Ebox candidate host ref does not exist: %S" host-ref)) + (ebox-incremental-candidate-replace + candidate node-id next-subtree + old-semantic-key new-semantic-key))) + +(defun ebox-incremental--candidate-assert-current + (candidate buffer old-state) + "Reject CANDIDATE unless BUFFER still publishes its exact captured base." + (unless (eq buffer (ebox-candidate--buffer candidate)) + (error "Ebox candidate belongs to another buffer")) + (unless (eq old-state (ebox-candidate--base-state candidate)) + (error "Ebox candidate base state changed")) + (let ((current-state (ebox--buffer-render-state buffer))) + (unless (eq current-state (ebox-candidate--base-state candidate)) + (error "Ebox candidate is stale: runtime state changed")) + (unless (eq (plist-get current-state :root-node) + (ebox-candidate--base-root candidate)) + (error "Ebox candidate is stale: runtime root changed")) + (unless (equal (or (plist-get current-state :runtime-revision) 0) + (ebox-candidate--base-revision candidate)) + (error "Ebox candidate is stale: runtime revision changed")) + (with-current-buffer buffer + (unless (= (buffer-modified-tick) + (ebox-candidate--base-buffer-tick candidate)) + (error "Ebox candidate is stale: buffer changed"))))) + +(defun ebox-incremental--candidate-path-index (root) + "Return node and parent tables for path-copying runtime ROOT. +This intentionally omits root-global semantic validation; only the final +logical tree crosses that boundary." + (let ((node-table (make-hash-table :test 'equal)) + (parent-table (make-hash-table :test 'equal))) + (cl-labels + ((visit (node parent-id) + (when (and (listp node) (not (stringp node))) + (let ((node-id (ebox--ensure-node-id node))) + (puthash node-id node node-table) + (when parent-id + (puthash node-id parent-id parent-table)) + (dolist (child (ebox-tree--children-raw node)) + (visit child node-id)))))) + (visit root nil)) + (list :node-table node-table :parent-table parent-table))) + +(defun ebox-incremental--candidate-record-path-copy + (base-node-id next-node parent-id anchor-p structure-p) + "Record one candidate path copy when trace collection is active." + (when (hash-table-p ebox-incremental--candidate-path-copy-trace) + (let* ((origin-table + ebox-incremental--candidate-path-copy-origin-table) + (origin-node-id + (if (hash-table-p origin-table) + (or (gethash base-node-id origin-table) base-node-id) + base-node-id)) + (existing + (gethash origin-node-id + ebox-incremental--candidate-path-copy-trace))) + (puthash + origin-node-id + (list :node next-node + :parent-id parent-id + :anchor-p (or anchor-p (plist-get existing :anchor-p)) + :structure-p + (or structure-p (plist-get existing :structure-p))) + ebox-incremental--candidate-path-copy-trace) + (when (hash-table-p origin-table) + (puthash origin-node-id origin-node-id origin-table) + (puthash (plist-get next-node :node-id) origin-node-id + origin-table))))) + +(defun ebox-incremental--candidate-path-copy-one + (root node-id replacement index) + "Return ROOT path-copied with NODE-ID replaced by REPLACEMENT." + (let* ((node-table (plist-get index :node-table)) + (parent-table (plist-get index :parent-table)) + (old-child (gethash node-id node-table)) + (new-child replacement) + (parent-id (gethash node-id parent-table))) + (unless old-child + (error "Ebox candidate anchor is absent after an ancestor replacement: %S" + node-id)) + (ebox-incremental--candidate-record-path-copy + node-id replacement parent-id t + (not (equal (plist-get old-child :node-id) + (plist-get replacement :node-id)))) + (while parent-id + (let* ((current-parent-id parent-id) + (old-parent (gethash current-parent-id node-table)) + (new-parent + (ebox-tree-copy-with-direct-child-replacements + old-parent (list (cons old-child new-child)))) + (grandparent-id + (gethash current-parent-id parent-table))) + (ebox-incremental--candidate-record-path-copy + current-parent-id new-parent grandparent-id nil + (not (equal (plist-get old-child :node-id) + (plist-get new-child :node-id)))) + (setq old-child old-parent + new-child new-parent + parent-id grandparent-id))) + (if (equal node-id (plist-get root :node-id)) + replacement + new-child))) + +(defun ebox-incremental--candidate-path-copy-many + (root index replacements) + "Return ROOT with node-id REPLACEMENTS applied by one bottom-up path copy." + (let* ((node-table (plist-get index :node-table)) + (parent-table (plist-get index :parent-table)) + (direct (make-hash-table :test 'equal)) + (affected-table (make-hash-table :test 'equal)) + affected-ids) + (maphash + (lambda (node-id replacement) + (let ((old-node (gethash node-id node-table)) + (parent-id (gethash node-id parent-table))) + (unless old-node + (error "Ebox candidate path-copy node is absent: %S" node-id)) + (ebox-incremental--candidate-record-path-copy + node-id replacement parent-id t + (not (equal (plist-get old-node :node-id) + (plist-get replacement :node-id)))) + (if parent-id + (puthash parent-id + (cons (cons old-node replacement) + (gethash parent-id direct)) + direct) + (puthash node-id replacement direct)) + (while parent-id + (unless (gethash parent-id affected-table) + (puthash parent-id t affected-table) + (push parent-id affected-ids)) + (setq parent-id (gethash parent-id parent-table))))) + replacements) + (let ((depth-table (make-hash-table :test 'equal))) + (cl-labels + ((depth + (node-id) + (or (gethash node-id depth-table) + (let ((current node-id) + path + (result -1)) + (while (and current + (not (gethash current depth-table))) + (push current path) + (setq current (gethash current parent-table))) + (when current + (setq result (gethash current depth-table))) + (dolist (path-id path) + (puthash path-id (cl-incf result) depth-table)) + (gethash node-id depth-table))))) + (setq affected-ids + (sort affected-ids + (lambda (left right) + (> (depth left) (depth right))))))) + (let ((result (gethash (plist-get root :node-id) replacements))) + (dolist (parent-id affected-ids) + (let* ((child-replacements (gethash parent-id direct)) + (old-parent (gethash parent-id node-table)) + (copy + (ebox-tree-copy-with-direct-child-replacements + old-parent child-replacements)) + (grandparent-id (gethash parent-id parent-table))) + (ebox-incremental--candidate-record-path-copy + parent-id copy grandparent-id nil + (cl-some + (lambda (replacement) + (not (equal (plist-get (car replacement) :node-id) + (plist-get (cdr replacement) :node-id)))) + child-replacements)) + (if grandparent-id + (puthash grandparent-id + (cons (cons old-parent copy) + (gethash grandparent-id direct)) + direct) + (setq result copy)))) + (or result root)))) + +(defun ebox-incremental--candidate-replacements-disjoint-p + (replacements parent-table) + "Return non-nil when REPLACEMENTS have no ancestor relationship. + +PARENT-TABLE belongs to the exact candidate base. Every public replacement +anchor is resolved against that base, so disjointness can be proven without +building an index for an intermediate path-copied root." + (cl-labels + ((ancestor-p + (ancestor-id descendant-id) + (let ((current (gethash descendant-id parent-table)) + found) + (while (and current (not found)) + (if (equal current ancestor-id) + (setq found t) + (setq current (gethash current parent-table)))) + found))) + (cl-loop + for tail on replacements + always + (cl-loop + for right in (cdr tail) + never + (let ((left-id + (ebox-incremental--candidate-replacement-anchor-id + (car tail))) + (right-id + (ebox-incremental--candidate-replacement-anchor-id right))) + (or (ancestor-p left-id right-id) + (ancestor-p right-id left-id))))))) + +(defun ebox-incremental--candidate-replacement-for-anchor + (entries anchor-id) + "Return the replacement in ENTRIES for ANCHOR-ID, or nil." + (cl-find + anchor-id entries + :key #'ebox-incremental--candidate-replacement-anchor-id + :test #'equal)) + +(defun ebox-incremental--validate-candidate-replacements + (base-index entries) + "Validate ENTRIES' cross-boundary identity against BASE-INDEX. +Each replacement subtree was internally validated when recorded; what +remains is the shared context the full-tree walk used to re-check on +every commit: the replacement's sibling key must stay unique under +its anchor's parent, and every host ref inside a replacement must be +either fresh or owned by a node that some replaced anchor is removing. +Cost is proportional to the replacements, never to the page." + (let* ((node-table (plist-get base-index :node-table)) + (parent-table (plist-get base-index :parent-table)) + (host-ref-table (plist-get base-index :host-ref-table)) + (replacement-host-refs (make-hash-table :test 'equal)) + (anchor-ids + (mapcar + #'ebox-incremental--candidate-replacement-anchor-id + entries))) + (cl-labels + ((inside-replaced-anchor-p (node-id) + (let ((walk node-id)) + (while (and walk (not (member walk anchor-ids))) + (setq walk (gethash walk parent-table))) + walk)) + (collect-host-refs (node acc) + (if (or (not (listp node)) (stringp node)) + acc + (let ((ref (plist-get node :host-ref))) + (when ref (push ref acc))) + (dolist (child (ebox-tree--children-raw node) acc) + (setq acc (collect-host-refs child acc)))))) + (dolist (entry entries) + (let* ((node-id + (ebox-incremental--candidate-replacement-anchor-id entry)) + (replacement + (ebox-incremental--candidate-replacement-subtree entry)) + (replacement-key (plist-get replacement :key)) + (parent-id (gethash node-id parent-table)) + (parent (and parent-id (gethash parent-id node-table)))) + ;; Sibling keys stay unique under the anchor's parent. + (when (and replacement-key parent) + (dolist (sibling (ebox-tree--children-raw parent)) + (let ((sibling-id (plist-get sibling :node-id))) + (when (and sibling-id + (not (equal sibling-id node-id)) + (equal (plist-get + (if-let + ((sibling-replacement + (ebox-incremental--candidate-replacement-for-anchor + entries sibling-id))) + (ebox-incremental--candidate-replacement-subtree + sibling-replacement) + sibling) + :key) + replacement-key)) + (error + "Ebox declarative sibling key %S is not unique" + replacement-key))))) + ;; Host refs must be fresh or freed by this same commit. + (when host-ref-table + (dolist (ref (collect-host-refs replacement nil)) + (let ((count (hash-table-count replacement-host-refs))) + (puthash ref t replacement-host-refs) + (when (= count (hash-table-count replacement-host-refs)) + (error + "Ebox declarative host reference %S is not unique" + ref))) + (when-let ((owner-id (gethash ref host-ref-table))) + (unless (inside-replaced-anchor-p owner-id) + (error + "Ebox declarative host reference %S is not unique" + ref)))))))))) + +(defun ebox-incremental--copy-detached-history (state) + "Return an unpublished detached identity history copied from STATE." + (if-let ((history (plist-get state :detached-identity-history))) + (ebox-incremental--make-detached-history + :table + (copy-hash-table + (ebox-incremental--detached-history-table history)) + :order + (copy-sequence + (ebox-incremental--detached-history-order history)) + :node-count + (ebox-incremental--detached-history-node-count history)) + (ebox-incremental--make-detached-history + :table (make-hash-table :test 'equal) + :order nil + :node-count 0))) + +(defun ebox-incremental--detached-history-store + (history key snapshot) + "Store SNAPSHOT under KEY in bounded detached identity HISTORY." + (let* ((table (ebox-incremental--detached-history-table history)) + (existing (gethash key table)) + (count (ebox-incremental--detached-history-node-count history))) + (when existing + (cl-decf count (plist-get existing :node-count))) + (puthash key snapshot table) + (cl-incf count (plist-get snapshot :node-count)) + (setf (ebox-incremental--detached-history-order history) + (append + (cl-delete key + (ebox-incremental--detached-history-order history) + :test #'equal) + (list key))) + (setf (ebox-incremental--detached-history-node-count history) count) + (while (or (> (hash-table-count table) + (max 0 ebox-incremental--detached-history-entry-limit)) + (> (ebox-incremental--detached-history-node-count history) + (max 0 ebox-incremental--detached-history-node-limit))) + (let* ((oldest + (pop (ebox-incremental--detached-history-order history))) + (removed (gethash oldest table))) + (remhash oldest table) + (cl-decf (ebox-incremental--detached-history-node-count history) + (plist-get removed :node-count))))) + history) + +(defun ebox-incremental--detached-snapshot-safe-p + (buffer state current-snapshot historical-snapshot) + "Return non-nil when HISTORICAL-SNAPSHOT can reenter BUFFER's STATE. +CURRENT-SNAPSHOT identifies the subtree identities being replaced." + (let ((current-node-ids (plist-get current-snapshot :node-id-set)) + (node-table (plist-get state :node-table)) + conflict) + (maphash + (lambda (node-id _present) + (when (and (gethash node-id node-table) + (not (gethash node-id current-node-ids))) + (setq conflict t))) + (plist-get historical-snapshot :node-id-set)) + (and + (not conflict) + (not + (ebox--runtime-region-id-conflict + (plist-get historical-snapshot :region-id-set) + buffer))))) + +(defun ebox-incremental--reconcile-detached-replacement + (candidate state entry old-anchor history) + "Reconcile CANDIDATE's ENTRY with OLD-ANCHOR and STATE. +Use candidate-owned HISTORY for detached identity reuse." + (let* ((replacement + (ebox-incremental--candidate-replacement-subtree entry)) + (old-key + (ebox-incremental--candidate-replacement-old-semantic-key entry)) + (new-key + (ebox-incremental--candidate-replacement-new-semantic-key entry)) + (anchor-id + (ebox-incremental--candidate-replacement-anchor-id entry))) + (if (or (null history) + (null old-key) + (null new-key) + (not (eq (plist-get old-anchor :ebox-type) + (plist-get replacement :ebox-type)))) + (ebox-tree-reconcile-runtime old-anchor replacement) + (let* ((current (ebox-tree-runtime-identity-snapshot old-anchor)) + (old-history-key (list anchor-id old-key)) + (new-history-key (list anchor-id new-key))) + (ebox-incremental--detached-history-store + history old-history-key current) + (let ((historical + (gethash + new-history-key + (ebox-incremental--detached-history-table history)))) + (if (and historical + (eq (plist-get (plist-get historical :root) :ebox-type) + (plist-get replacement :ebox-type)) + (ebox-incremental--detached-snapshot-safe-p + (ebox-candidate--buffer candidate) + state current historical)) + (progn + (ebox-tree-reconcile-runtime + (plist-get historical :root) replacement) + (ebox-tree-transfer-runtime-identity + old-anchor replacement)) + (ebox-tree-reconcile-runtime old-anchor replacement)))))) + (ebox-incremental--candidate-replacement-subtree entry)) + +(defun ebox-incremental--candidate-logical-root + (candidate &optional detached-history) + "Return CANDIDATE's complete logical root without copying unchanged subtrees. +DETACHED-HISTORY receives identity snapshots for semantic replacements." + (let ((ebox-incremental--candidate-path-copy-origin-table + (make-hash-table :test 'equal))) + (let* ((root (ebox-candidate--base-root candidate)) + (state (ebox-candidate--base-state candidate)) + (base-index + (list :node-table (plist-get state :node-table) + :parent-table (plist-get state :parent-table) + :host-ref-table (plist-get state :host-ref-table))) + (entries (ebox-candidate--replacements candidate))) + (if (ebox-incremental--candidate-replacements-disjoint-p + entries (plist-get base-index :parent-table)) + (let ((replacements (make-hash-table :test 'equal))) + (ebox-incremental--validate-candidate-replacements + base-index entries) + ;; Reconcile every independent anchor against the same published + ;; base, then copy their shared ancestor paths in one bottom-up pass. + (dolist (entry entries) + (let* ((node-id + (ebox-incremental--candidate-replacement-anchor-id entry)) + (old-anchor + (gethash node-id (plist-get base-index :node-table)))) + (unless old-anchor + (error "Ebox candidate anchor is absent: %S" node-id)) + ;; Replacements were validated when they were recorded + ;; by `ebox-candidate-replace-host-ref'. + (puthash + node-id + (ebox-incremental--reconcile-detached-replacement + candidate state entry old-anchor detached-history) + replacements))) + (ebox-incremental--candidate-path-copy-many + root base-index replacements)) + ;; Ancestor/descendant overlap is intentionally rare and order-sensitive: + ;; a later descendant refines the earlier candidate-owned ancestor. + (when (cl-some + (lambda (entry) + (ebox-incremental--candidate-replacement-old-semantic-key + entry)) + entries) + (error "Ebox semantic candidate replacements must be disjoint")) + (let ((first-p t)) + (ebox-incremental--validate-candidate-replacements + base-index entries) + (dolist (entry entries) + (let* ((node-id + (ebox-incremental--candidate-replacement-anchor-id entry)) + (replacement + (ebox-incremental--candidate-replacement-subtree entry)) + (index + (if first-p + base-index + (ebox-incremental--candidate-path-index root))) + (old-anchor + (gethash node-id (plist-get index :node-table)))) + (setq first-p nil) + (unless old-anchor + (error + "Ebox candidate anchor is absent after an ancestor replacement: %S" + node-id)) + (ebox-tree-reconcile-runtime old-anchor replacement) + (setq root + (ebox-incremental--candidate-path-copy-one + root node-id replacement index)))) + root))))) + +(defun ebox-incremental--hash-keys (table) + "Return the keys currently present in hash TABLE." + (let (keys) + (when (hash-table-p table) + (maphash (lambda (key _value) (push key keys)) table)) + (nreverse keys))) + +(defun ebox-incremental--hash-snapshot (table keys) + "Return restorable entries for KEYS in hash TABLE." + (let ((missing (make-symbol "missing")) + entries) + (dolist (key keys (nreverse entries)) + (let ((value (gethash key table missing))) + (push (list key (not (eq value missing)) + (unless (eq value missing) value)) + entries))))) + +(defun ebox-incremental--restore-hash-snapshot (table entries) + "Restore hash TABLE entries from ENTRIES." + (dolist (entry entries) + (if (nth 1 entry) + (puthash (car entry) (nth 2 entry) table) + (remhash (car entry) table)))) + +(defun ebox-incremental--replace-hash-entries (target keys source) + "Replace TARGET entries under KEYS with every entry from SOURCE." + (dolist (key keys) + (remhash key target)) + (when (hash-table-p source) + (maphash (lambda (key value) (puthash key value target)) source))) + +(defun ebox-incremental--buffer-scroll-keys (buffer) + "Return global scroll-state keys currently owned by BUFFER." + (let (keys) + (maphash + (lambda (key state) + (when (eq (plist-get state :buffer) buffer) + (push key keys))) + ebox--scroll-global-state) + (nreverse keys))) + +(defun ebox-incremental--copy-marker-endpoint (endpoint) + "Return an isolated copy of marker ENDPOINT, or ENDPOINT itself." + (if (markerp endpoint) + (copy-marker endpoint (marker-insertion-type endpoint)) + endpoint)) + +(defun ebox-incremental--copy-marker-tree (value) + "Copy marker endpoints recursively through cons tree VALUE." + (cond + ((markerp value) + (ebox-incremental--copy-marker-endpoint value)) + ((vectorp value) + (vconcat (mapcar #'ebox-incremental--copy-marker-tree value))) + ((consp value) + (cons (ebox-incremental--copy-marker-tree (car value)) + (ebox-incremental--copy-marker-tree (cdr value)))) + (t value))) + +(defun ebox-incremental--candidate-layout-snapshots + (old-state candidate-root candidate-index) + "Seed CANDIDATE-ROOT snapshots from OLD-STATE's published geometry." + (let* ((old-snapshots (plist-get old-state :layout-snapshots)) + (old-root (plist-get old-state :root-node)) + (old-root-id (plist-get old-root :node-id)) + (candidate-root-id (plist-get candidate-root :node-id)) + (candidate-node-table (plist-get candidate-index :node-table)) + (snapshots (make-hash-table :test 'equal))) + (when (hash-table-p old-snapshots) + (maphash + (lambda (node-id _node) + (when-let ((snapshot (gethash node-id old-snapshots))) + (puthash node-id (copy-sequence snapshot) snapshots))) + candidate-node-table) + ;; A root type replacement receives a fresh runtime id, but its patch + ;; owner still covers the currently published complete-buffer geometry. + (unless (equal old-root-id candidate-root-id) + (when-let ((snapshot (gethash old-root-id old-snapshots))) + (setq snapshot (copy-sequence snapshot)) + (setq snapshot (plist-put snapshot :node-id candidate-root-id)) + (puthash candidate-root-id snapshot snapshots)))) + snapshots)) + +(defun ebox-incremental--candidate-scroll-state-table + (buffer old-state candidate-index candidate-region-box-table) + "Return retained BUFFER scroll state rebound to the candidate runtime." + (let ((candidate-region-set (plist-get candidate-index :region-id-set)) + (scroll-state-table (make-hash-table :test 'equal))) + (dolist + (region-id + (delete-dups + (append (copy-sequence (plist-get old-state :scroll-region-ids)) + (ebox-incremental--buffer-scroll-keys buffer)))) + (when-let* (((gethash region-id candidate-region-set)) + (old-scroll-state (gethash region-id ebox--scroll-global-state)) + (candidate-box + (gethash region-id candidate-region-box-table))) + (let ((candidate-scroll-state (copy-sequence old-scroll-state))) + (setq candidate-scroll-state + (plist-put candidate-scroll-state :box candidate-box)) + (setq candidate-scroll-state + (plist-put candidate-scroll-state :buffer buffer)) + (setq candidate-scroll-state + (ebox--scroll-window-rebind-state-producers + candidate-scroll-state candidate-box)) + (dolist (key '(:content-span-markers + :rendered-window-start-marker + :rendered-window-end-marker)) + (when (plist-member candidate-scroll-state key) + (setq candidate-scroll-state + (plist-put + candidate-scroll-state key + (ebox-incremental--copy-marker-tree + (plist-get candidate-scroll-state key)))))) + (puthash region-id candidate-scroll-state scroll-state-table)))) + scroll-state-table)) + +(defun ebox-incremental--detach-region-role-span-table (table) + "Detach every marker endpoint retained by region role span TABLE." + (when (hash-table-p table) + (maphash + (lambda (key spans) + (when (consp key) + (dolist (span spans) + (when (markerp (car span)) + (set-marker (car span) nil)) + (when (markerp (cdr span)) + (set-marker (cdr span) nil))))) + table))) + +(defun ebox-incremental--detach-unshared-region-role-spans + (source retained) + "Detach marker spans in SOURCE that are not shared by RETAINED." + (when (hash-table-p source) + (maphash + (lambda (key spans) + (when (consp key) + (let ((retained-spans (and (hash-table-p retained) + (gethash key retained)))) + (dolist (span spans) + (unless + (cl-some + (lambda (retained-span) + (and (eq (car span) (car retained-span)) + (eq (cdr span) (cdr retained-span)))) + retained-spans) + (when (markerp (car span)) + (set-marker (car span) nil)) + (when (markerp (cdr span)) + (set-marker (cdr span) nil))))))) + source))) + +(defun ebox-incremental--restore-region-role-span-table (table snapshot) + "Restore marker role TABLE entries from shallow SNAPSHOT." + (when (and (hash-table-p table) (hash-table-p snapshot)) + (clrhash table) + (maphash (lambda (key spans) (puthash key spans table)) snapshot) + table)) + +(defun ebox-incremental--detach-scroll-state-table-markers (table) + "Detach scroll markers owned by every state in TABLE." + (when (hash-table-p table) + (maphash + (lambda (_region-id state) + (ebox--scroll-clear-content-markers state) + (ebox--scroll-clear-rendered-window-markers state)) + table))) + +(defun ebox-incremental--retire-removed-region-indexes + (buffer old-state candidate-state) + "Remove BUFFER indexes owned only by OLD-STATE, not CANDIDATE-STATE." + (let ((candidate-region-set (plist-get candidate-state :region-id-set)) + removed-region-ids) + (maphash + (lambda (region-id _present) + (unless (gethash region-id candidate-region-set) + (push region-id removed-region-ids))) + (plist-get old-state :region-id-set)) + (when removed-region-ids + (when-let ((table (ebox--buffer-region-role-span-table buffer))) + (let (removed-keys) + (maphash + (lambda (key _spans) + (when (and (consp key) + (member (car key) removed-region-ids)) + (push key removed-keys))) + table) + (dolist (key removed-keys) + (dolist (span (gethash key table)) + (when (markerp (car span)) + (set-marker (car span) nil)) + (when (markerp (cdr span)) + (set-marker (cdr span) nil))) + (remhash key table)))) + (ebox--clear-box-extents-for-region-ids removed-region-ids)) + removed-region-ids)) + +(defun ebox-incremental--node-child-ids-equal-p (old new) + "Return non-nil when OLD and NEW have identical direct child runtime ids." + (let ((old-children (ebox--node-children old)) + (new-children (ebox--node-children new)) + equal-p) + (setq equal-p t) + (while (and equal-p old-children new-children) + (setq equal-p + (equal (plist-get (car old-children) :node-id) + (plist-get (car new-children) :node-id))) + (setq old-children (cdr old-children) + new-children (cdr new-children))) + (and equal-p (null old-children) (null new-children)))) + +(defun ebox-incremental--node-child-ids (node) + "Return NODE's direct child runtime ids in render order." + (mapcar #'ebox--ensure-node-id (ebox--node-children node))) + +(defun ebox-incremental--pure-child-reorder-p (old-ids new-ids) + "Return non-nil when OLD-IDS and NEW-IDS are one pure permutation." + (when (and (> (length old-ids) 1) + (= (length old-ids) (length new-ids)) + (not (equal old-ids new-ids))) + (let ((remaining (make-hash-table :test 'equal)) + valid) + (setq valid t) + (dolist (node-id old-ids) + (if (gethash node-id remaining) + (setq valid nil) + (puthash node-id t remaining))) + (dolist (node-id new-ids) + (if (gethash node-id remaining) + (remhash node-id remaining) + (setq valid nil))) + (and valid (zerop (hash-table-count remaining)))))) + +(defun ebox-incremental--child-splice-p (old-ids new-ids) + "Return non-nil when OLD-IDS and NEW-IDS are one insert or remove run." + (when (and (not (equal old-ids new-ids)) + (not (ebox-incremental--pure-child-reorder-p old-ids new-ids))) + (pcase-let ((`(,old-middle ,new-middle) + (ebox-incremental--child-list-middle old-ids new-ids))) + (or (and old-middle (not new-middle)) + (and new-middle (not old-middle)))))) + +(defun ebox-incremental--child-list-middle (old-ids new-ids) + "Return minimal changed middle child id ranges for OLD-IDS and NEW-IDS." + (let* ((old (vconcat old-ids)) + (new (vconcat new-ids)) + (start 0) + (old-end (length old)) + (new-end (length new))) + (while (and (< start old-end) + (< start new-end) + (equal (aref old start) (aref new start))) + (cl-incf start)) + (while (and (> old-end start) + (> new-end start) + (equal (aref old (1- old-end)) + (aref new (1- new-end)))) + (cl-decf old-end) + (cl-decf new-end)) + (list (append (cl-subseq old start old-end) nil) + (append (cl-subseq new start new-end) nil)))) + +(defun ebox-incremental--direct-child-by-id (owner child-id) + "Return OWNER's direct child with runtime CHILD-ID." + (cl-find-if + (lambda (child) + (equal (ebox--ensure-node-id child) child-id)) + (ebox--node-children owner))) + +(defun ebox-incremental--simple-new-child-splice-p + (buffer owner-id dirty) + "Return non-nil when DIRTY is a cheap vertical child splice." + (when-let ((owner (ebox--buffer-runtime-node buffer owner-id))) + (pcase-let* ((old-ids (plist-get dirty :old-child-ids)) + (new-ids (plist-get dirty :new-child-ids)) + (`(,_old-middle ,new-middle) + (ebox-incremental--child-list-middle old-ids new-ids))) + (and (eq (plist-get owner :ebox-type) 'stack) + (eq (ebox-tree-display-inner owner) 'column) + (when-let ((snapshot + (ebox--ensure-layout-snapshot-spans + buffer owner-id))) + (unless (with-current-buffer buffer + (ebox-buffer--partial-line-slots-p + (plist-get snapshot :buffer-spans))) + (cl-every + (lambda (child-id) + (let ((child + (ebox-incremental--direct-child-by-id + owner child-id))) + (and child (eq (plist-get child :ebox-type) 'box)))) + new-middle))))))) + +(defun ebox-incremental--child-change-props + (old-node new-node children-changed) + "Return direct-child provenance for OLD-NODE and NEW-NODE when changed." + (when children-changed + (let ((old-ids (ebox-incremental--node-child-ids old-node)) + (new-ids (ebox-incremental--node-child-ids new-node))) + (list :old-child-ids old-ids + :new-child-ids new-ids + :pure-child-reorder-p + (ebox-incremental--pure-child-reorder-p old-ids new-ids) + :child-splice-p + (ebox-incremental--child-splice-p old-ids new-ids))))) + +(defun ebox-incremental--declarative-dirty-kind (changed-keys) + "Return a declarative dirty kind for CHANGED-KEYS." + (cond + ((and changed-keys + (cl-every (lambda (key) + (memq key ebox-tree-metadata-source-keys)) + changed-keys)) + 'metadata) + ((and changed-keys + (cl-every (lambda (key) + (or (eq key :surface-properties) + (memq key ebox--paint-style-signature-keys))) + changed-keys)) + 'paint) + ((memq :display changed-keys) 'structure) + (t 'geometry))) + +(defun ebox-incremental--candidate-region-box-table (state) + "Return STATE's buffer-owned region boxes from the active side table." + (if-let* ((owned-table (plist-get state :region-box-table)) + ((hash-table-p owned-table))) + (copy-hash-table owned-table) + (let ((table (make-hash-table :test 'equal))) + (when-let ((region-id-set (plist-get state :region-id-set))) + (maphash + (lambda (region-id _present) + (when-let ((box (gethash region-id ebox--region-box-table))) + (puthash region-id box table))) + region-id-set)) + table))) + +(defun ebox-incremental--candidate-base-index (state) + "Return STATE's persistent indexes without copying unchanged core tables." + (list :node-table (plist-get state :node-table) + :parent-table (plist-get state :parent-table) + :region-id-set (plist-get state :region-id-set) + :region-node-table (plist-get state :region-node-table) + :region-box-count-table (plist-get state :region-box-count-table) + :region-box-table + (ebox-incremental--candidate-region-box-table state) + :host-ref-table (plist-get state :host-ref-table) + :selector-id-table (plist-get state :selector-id-table) + :selector-class-table (plist-get state :selector-class-table) + :selector-type-table (plist-get state :selector-type-table) + :selector-index-stale-p + (plist-get state :selector-index-stale-p) + :native-node-postorder (plist-get state :native-node-postorder))) + +(defun ebox-incremental--candidate-copy-index-table (state key test) + "Return a materialized copy of STATE's hash table under KEY." + (let ((table (plist-get state key))) + (if (hash-table-p table) + (copy-hash-table table) + (make-hash-table :test test)))) + +(defun ebox-incremental--candidate-node-region-record (node) + "Return NODE's `(REGION-ID OWNER-ID BOX)' record, or nil." + (pcase (and (listp node) (plist-get node :ebox-type)) + ('box + (list (ebox--ensure-region-id node) + (ebox--ensure-node-id node) + node)) + ('flex + (when-let ((box (plist-get node :box))) + (list (ebox--ensure-region-id box) + (ebox--ensure-node-id node) + box))))) + +(defun ebox-incremental--candidate-adjust-region-box-count + (table node delta) + "Adjust TABLE's box-object region count for NODE by DELTA." + (when (eq (and (listp node) (plist-get node :ebox-type)) 'box) + (let* ((region-id (ebox--ensure-region-id node)) + (count (+ (or (gethash region-id table) 0) delta))) + (if (> count 0) + (puthash region-id count table) + (remhash region-id table))))) + +(defun ebox-incremental--candidate-region-box-count-table (root) + "Return complete box-object region multiplicities below legacy ROOT." + (let ((table (make-hash-table :test 'equal))) + (cl-labels + ((visit (node) + (when (and (listp node) (not (stringp node))) + (ebox-incremental--candidate-adjust-region-box-count + table node 1) + (dolist (child (ebox--node-children node)) + (visit child))))) + (visit root)) + table)) + +(defun ebox-incremental--candidate-recompute-regions + (root region-ids region-id-set region-node-table region-box-table) + "Recompute REGION-IDS below ROOT with complete traversal ordering. + +This is the compatibility path for duplicated legacy region ids. Core +logical-candidate updates stay local for unique ids; duplicates need the +complete preorder first-owner and postorder last-box semantics." + (maphash + (lambda (region-id _present) + (remhash region-id region-id-set) + (remhash region-id region-node-table) + (remhash region-id region-box-table)) + region-ids) + (cl-labels + ((visit (node) + (when (and (listp node) (not (stringp node))) + (when-let ((record + (ebox-incremental--candidate-node-region-record node))) + (pcase-let ((`(,region-id ,owner-id ,_box) record)) + (when (gethash region-id region-ids) + (puthash region-id t region-id-set) + (unless (gethash region-id region-node-table) + (puthash region-id owner-id region-node-table))))) + (dolist (child (ebox--node-children node)) + (visit child)) + (when-let ((record + (ebox-incremental--candidate-node-region-record node))) + (pcase-let ((`(,region-id ,_owner-id ,box) record)) + (when (gethash region-id region-ids) + (puthash region-id box region-box-table))))))) + (visit root))) + +(defun ebox-incremental--candidate-native-postorder (root) + "Return ROOT's native compiler postorder without building other indexes." + (let (postorder) + (cl-labels + ((visit (node native-layout-p) + (when (and (listp node) (not (stringp node))) + (let ((type (plist-get node :ebox-type))) + (dolist (child (ebox--node-children node)) + (visit child + (and native-layout-p + (not (and (eq type 'flex) + (eq child (plist-get node :box))))))) + (when (and native-layout-p (not (eq type 'flex-item))) + (push node postorder)))))) + (visit root t)) + (vconcat (nreverse postorder)))) + +(defun ebox-incremental--candidate-map-native-postorder + (old-state candidate-node-table) + "Re-key OLD-STATE's structurally stable native postorder to candidate nodes." + (let ((old-postorder (plist-get old-state :native-node-postorder))) + (if (vectorp old-postorder) + (vconcat + (mapcar + (lambda (old-node) + (or (gethash (plist-get old-node :node-id) + candidate-node-table) + (error "Ebox candidate lost a structurally retained native node"))) + (append old-postorder nil))) + nil))) + +(defun ebox-incremental--candidate-dirty-set-from-touched + (old-state candidate-root touched) + "Return declarative dirty entries for path-local TOUCHED nodes." + (let ((old-root (plist-get old-state :root-node)) + dirty) + (if (not (equal (plist-get old-root :node-id) + (plist-get candidate-root :node-id))) + (list + (ebox--dirty-entry + (plist-get candidate-root :node-id) 'structure + :changed-keys '(:ebox-type) + :old-region-ids (ebox--node-all-region-ids old-root) + :new-region-ids (ebox--node-all-region-ids candidate-root) + :old old-root :new candidate-root)) + (dolist (entry touched) + (pcase-let ((`(,old-node ,new-node ,_parent-id ,children-changed) + entry)) + (when old-node + (let* ((changed-keys + (ebox-tree-node-local-changed-keys old-node new-node)) + (kind + (cond + (children-changed 'structure) + (changed-keys + (ebox-incremental--declarative-dirty-kind + changed-keys))))) + (when kind + (push + (append + (ebox--dirty-entry + (plist-get new-node :node-id) kind + :changed-keys + (if children-changed + (cons :children changed-keys) + changed-keys) + :old-region-ids + (and children-changed + (ebox--node-all-region-ids old-node)) + :new-region-ids + (and children-changed + (ebox--node-all-region-ids new-node)) + :old-signature + (ebox-tree-node-local-source-signature old-node) + :new-signature + (ebox-tree-node-local-source-signature new-node)) + (ebox-incremental--child-change-props + old-node new-node children-changed)) + dirty)))))) + (nreverse dirty)))) + +(defconst ebox-incremental--candidate-full-index-min-touched 64 + "Minimum touched nodes before a candidate may rebuild its complete index.") + +(defun ebox-incremental--candidate-full-index-p (old-state touched) + "Return non-nil when TOUCHED justifies rebuilding OLD-STATE's index." + (let ((touched-count (length touched)) + (old-count (hash-table-count (plist-get old-state :node-table)))) + (and (>= touched-count + ebox-incremental--candidate-full-index-min-touched) + (>= (* 2 touched-count) old-count)))) + +(defun ebox-incremental--candidate-full-index-preparation + (old-state candidate-root touched removed) + "Return broad candidate preparation from TOUCHED and REMOVED nodes." + (let ((index (ebox--runtime-index candidate-root t))) + (setq index (plist-put index :selector-index-stale-p nil)) + (list :index index + :dirty-set + (ebox-incremental--candidate-dirty-set-from-touched + old-state candidate-root touched) + :touched-count (length touched) + :removed-count (length removed)))) + +(defun ebox-incremental--candidate-local-index-delta + (old-state candidate-root &optional path-copy-trace) + "Return local indexes and dirty entries for CANDIDATE-ROOT. + +Traversal stops at every node that is `eq' to OLD-STATE's indexed node. The +work is therefore bounded by copied ancestor paths plus added, removed, and +replacement subtrees. Core tables are materialized in this stage so existing +scalar `gethash' consumers remain unchanged; publication journaling can later +replace those O(n) table copies without changing this delta contract." + (let ((old-root (plist-get old-state :root-node))) + (if (eq candidate-root old-root) + (list :index (ebox-incremental--candidate-base-index old-state) + :dirty-set nil + :touched-count 0 + :removed-count 0) + (let* ((old-node-table (plist-get old-state :node-table)) + (missing (make-symbol "ebox-candidate-missing-node")) + (removed-seen (make-hash-table :test 'equal)) + (visited-new (make-hash-table :test 'equal)) + (touched-parent-table (make-hash-table :test 'equal)) + touched removed) + (cl-labels + ((remove-old-subtree (node) + (let ((node-id (ebox--ensure-node-id node))) + (unless (gethash node-id removed-seen) + (puthash node-id t removed-seen) + (push node removed) + (dolist (child (ebox--node-children node)) + (remove-old-subtree child))))) + (record-touched + (old-node new-node parent-id children-changed) + (let ((node-id (ebox--ensure-node-id new-node))) + (puthash node-id parent-id touched-parent-table) + (push (list old-node new-node parent-id children-changed) + touched))) + (visit-new (node parent-id) + (let* ((node-id (ebox--ensure-node-id node)) + (old-node (gethash node-id old-node-table missing))) + (unless (or (gethash node-id visited-new) + (eq node old-node)) + (puthash node-id t visited-new) + (when (eq old-node missing) + (setq old-node nil)) + (let ((children-changed + (or (null old-node) + (not + (ebox-incremental--node-child-ids-equal-p + old-node node))))) + (record-touched + old-node node parent-id children-changed) + (when (and old-node children-changed) + (let ((new-child-ids (make-hash-table :test 'equal))) + (dolist (child (ebox--node-children node)) + (puthash (ebox--ensure-node-id child) t + new-child-ids)) + (dolist (old-child (ebox--node-children old-node)) + (unless (gethash (ebox--ensure-node-id old-child) + new-child-ids) + (remove-old-subtree old-child))))) + (dolist (child (ebox--node-children node)) + (visit-new child node-id))))))) + (if (and (hash-table-p path-copy-trace) + (> (hash-table-count path-copy-trace) 0)) + (progn + ;; Replacement anchors own their affected subtrees. Visit + ;; those first so descendant anchors are naturally deduped. + (maphash + (lambda (base-node-id trace) + (when (plist-get trace :anchor-p) + (let* ((old-node (gethash base-node-id old-node-table)) + (new-node (plist-get trace :node)) + (parent-id (plist-get trace :parent-id))) + (when (and old-node + (not (equal (plist-get old-node :node-id) + (plist-get new-node :node-id)))) + (remove-old-subtree old-node)) + (visit-new new-node parent-id)))) + path-copy-trace) + ;; Ancestor copies are known exactly from path construction; + ;; indexing them must not probe every untouched sibling. + (maphash + (lambda (base-node-id trace) + (unless (plist-get trace :anchor-p) + (let* ((new-node (plist-get trace :node)) + (node-id (plist-get new-node :node-id))) + (unless (gethash node-id visited-new) + (puthash node-id t visited-new) + (record-touched + (gethash base-node-id old-node-table) + new-node + (plist-get trace :parent-id) + (plist-get trace :structure-p)))))) + path-copy-trace)) + (unless (equal (plist-get old-root :node-id) + (plist-get candidate-root :node-id)) + (remove-old-subtree old-root)) + (visit-new candidate-root nil))) + ;; A type-changing anchor first marks its complete old subtree as + ;; removed, then runtime reconciliation may retain keyed descendants + ;; under the new type. Those descendants also appear in TOUCHED and + ;; must not be debited a second time from core indexes or region + ;; multiplicities. + (setq removed + (cl-delete-if + (lambda (node) + (gethash (plist-get node :node-id) visited-new)) + (nreverse removed))) + (if (ebox-incremental--candidate-full-index-p old-state touched) + (ebox-incremental--candidate-full-index-preparation + old-state candidate-root touched removed) + (progn + ;; Local region owner repair needs ancestors before descendants. + ;; A complete index rebuild already establishes that order itself. + (let ((depth-table (make-hash-table :test 'equal)) + (old-parent-table (plist-get old-state :parent-table))) + (cl-labels + ((parent-id + (node-id) + (if (gethash node-id visited-new) + (gethash node-id touched-parent-table) + (gethash node-id old-parent-table))) + (depth + (node-id) + (or (gethash node-id depth-table) + (let ((current node-id) + path + (result -1)) + (while (and current + (not (gethash current depth-table))) + (push current path) + (setq current (parent-id current))) + (when current + (setq result (gethash current depth-table))) + (dolist (path-id path) + (puthash path-id (cl-incf result) depth-table)) + (gethash node-id depth-table))))) + (setq touched + (sort touched + (lambda (left right) + (< (depth (plist-get (nth 1 left) :node-id)) + (depth + (plist-get (nth 1 right) :node-id)))))))) + (let* ((node-table + (ebox-incremental--candidate-copy-index-table + old-state :node-table 'equal)) + (parent-table + (ebox-incremental--candidate-copy-index-table + old-state :parent-table 'equal)) + (region-id-set + (ebox-incremental--candidate-copy-index-table + old-state :region-id-set 'equal)) + (region-node-table + (ebox-incremental--candidate-copy-index-table + old-state :region-node-table 'equal)) + (old-region-box-count-table + (plist-get old-state :region-box-count-table)) + (region-box-count-table + (if (hash-table-p old-region-box-count-table) + (copy-hash-table old-region-box-count-table) + (ebox-incremental--candidate-region-box-count-table + candidate-root))) + (region-box-table + (ebox-incremental--candidate-region-box-table old-state)) + (host-ref-table + (ebox-incremental--candidate-copy-index-table + old-state :host-ref-table 'equal)) + (affected-region-ids (make-hash-table :test 'equal)) + (ambiguous-region-ids (make-hash-table :test 'equal))) + (dolist (node removed) + (let ((node-id (plist-get node :node-id))) + (remhash node-id node-table) + (remhash node-id parent-table) + (when-let ((host-ref (plist-get node :host-ref))) + (when (equal (gethash host-ref host-ref-table) node-id) + (remhash host-ref host-ref-table))) + (when-let ((record + (ebox-incremental--candidate-node-region-record node))) + (puthash (car record) t affected-region-ids)) + (when (hash-table-p old-region-box-count-table) + (ebox-incremental--candidate-adjust-region-box-count + region-box-count-table node -1)))) + ;; Remove every old Host binding before installing any new one. + ;; Final candidate trees may legally exchange two unique refs; a + ;; per-node remove/install loop would reject that valid final state + ;; merely because the other old binding had not been visited yet. + (dolist (entry touched) + (when-let* ((old-node (car entry)) + (old-host-ref (plist-get old-node :host-ref))) + (let ((node-id (plist-get old-node :node-id))) + (when (equal (gethash old-host-ref host-ref-table) node-id) + (remhash old-host-ref host-ref-table))))) + (dolist (entry touched) + (pcase-let ((`(,old-node ,new-node ,parent-id ,_children-changed) + entry)) + (let ((node-id (plist-get new-node :node-id))) + (when old-node + (when-let ((record + (ebox-incremental--candidate-node-region-record + old-node))) + (puthash (car record) t affected-region-ids)) + (when (hash-table-p old-region-box-count-table) + (ebox-incremental--candidate-adjust-region-box-count + region-box-count-table old-node -1))) + (puthash node-id new-node node-table) + (if parent-id + (puthash node-id parent-id parent-table) + (remhash node-id parent-table)) + ;; Replacement subtrees were validated before recording and + ;; checked against untouched Host refs before path copying. + (when-let ((new-host-ref (plist-get new-node :host-ref))) + (puthash new-host-ref node-id host-ref-table)) + (when-let ((record + (ebox-incremental--candidate-node-region-record + new-node))) + (puthash (car record) t affected-region-ids)) + (when (hash-table-p old-region-box-count-table) + (ebox-incremental--candidate-adjust-region-box-count + region-box-count-table new-node 1))))) + (maphash + (lambda (region-id _present) + (when (or (not (hash-table-p old-region-box-count-table)) + (> (or (and old-region-box-count-table + (gethash region-id + old-region-box-count-table)) + 0) + 1) + (> (or (gethash region-id region-box-count-table) 0) + 1)) + (puthash region-id t ambiguous-region-ids))) + affected-region-ids) + (maphash + (lambda (region-id _present) + (remhash region-id region-id-set) + (remhash region-id region-node-table) + (remhash region-id region-box-table)) + affected-region-ids) + ;; TOUCHED is preorder, matching `ebox--runtime-index' ownership: + ;; a flex owner claims its wrapper before the wrapper box is visited. + (dolist (entry touched) + (when-let ((record + (ebox-incremental--candidate-node-region-record + (nth 1 entry)))) + (pcase-let ((`(,region-id ,owner-id ,box) record)) + (puthash region-id t region-id-set) + (unless (gethash region-id region-node-table) + (puthash region-id owner-id region-node-table)) + (puthash region-id box region-box-table)))) + (when (> (hash-table-count ambiguous-region-ids) 0) + (ebox-incremental--candidate-recompute-regions + candidate-root ambiguous-region-ids + region-id-set region-node-table region-box-table)) + (let* ((dirty-set + (ebox-incremental--candidate-dirty-set-from-touched + old-state candidate-root touched)) + (structure-p + (cl-some + (lambda (entry) + (eq (plist-get entry :dirty-kind) 'structure)) + dirty-set)) + (native-node-postorder + (if structure-p + (ebox-incremental--candidate-native-postorder + candidate-root) + (ebox-incremental--candidate-map-native-postorder + old-state node-table))) + (index + (list :node-table node-table + :parent-table parent-table + :region-id-set region-id-set + :region-node-table region-node-table + :region-box-count-table region-box-count-table + :region-box-table region-box-table + :host-ref-table host-ref-table + ;; Paths contain ancestor objects, so a path copy makes + ;; selector indexes stale even when selector metadata + ;; itself did not change. Rebuild on first query. + :selector-id-table + (plist-get old-state :selector-id-table) + :selector-class-table + (plist-get old-state :selector-class-table) + :selector-type-table + (plist-get old-state :selector-type-table) + :selector-index-stale-p t + :native-node-postorder native-node-postorder))) + (list :index index + :dirty-set dirty-set + :touched-node-ids + (mapcar + (lambda (entry) + (plist-get (nth 1 entry) :node-id)) + touched) + :removed-node-ids + (mapcar + (lambda (node) (plist-get node :node-id)) + removed) + :touched-count (length touched) + :removed-count (length removed)))))))))) + +(defun ebox-incremental--declarative-dirty-set + (old-state candidate-root candidate-index) + "Return source-level dirty entries from OLD-STATE to CANDIDATE-ROOT. +CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." + (let ((old-table (plist-get old-state :node-table)) + (new-table (plist-get candidate-index :node-table)) + (old-root (plist-get old-state :root-node)) + dirty) + (if (not (equal (plist-get old-root :node-id) + (plist-get candidate-root :node-id))) + (push (ebox--dirty-entry + (plist-get candidate-root :node-id) 'structure + :changed-keys '(:ebox-type) + :old-region-ids (ebox--node-all-region-ids old-root) + :new-region-ids (ebox--node-all-region-ids candidate-root) + :old old-root :new candidate-root) + dirty) + (maphash + (lambda (node-id new-node) + (when-let ((old-node (gethash node-id old-table))) + (let* ((changed-keys + (ebox-tree-node-local-changed-keys + old-node new-node)) + (children-changed + (not + (ebox-incremental--node-child-ids-equal-p + old-node new-node))) + (kind (cond + (children-changed 'structure) + (changed-keys + (ebox-incremental--declarative-dirty-kind + changed-keys))))) + (when kind + (push + (append + (ebox--dirty-entry + node-id kind + :changed-keys + (if children-changed + (cons :children changed-keys) + changed-keys) + :old-region-ids + (and children-changed + (ebox--node-all-region-ids old-node)) + :new-region-ids + (and children-changed + (ebox--node-all-region-ids new-node)) + :old-signature + (ebox-tree-node-local-source-signature old-node) + :new-signature + (ebox-tree-node-local-source-signature new-node)) + (ebox-incremental--child-change-props + old-node new-node children-changed)) + dirty))))) + new-table)) + (nreverse dirty))) + +(defun ebox-incremental--candidate-state + (old-state candidate-root candidate-index prepared) + "Return unpublished runtime state for a prepared declarative root." + (let ((state (copy-sequence old-state))) + (dolist + (entry + `((:root-node ,candidate-root) + (:logical-candidate-p + ,(plist-get prepared :logical-candidate-p)) + (:layout-snapshots ,(plist-get prepared :layout-snapshots)) + (:layout-snapshots-complete-p nil) + (:layout-snapshot-detail-generation + ,(or (plist-get old-state :layout-snapshot-detail-generation) + 0)) + (:runtime-revision + ,(1+ (or (plist-get old-state :runtime-revision) 0))) + (:display-signature ,(plist-get prepared :display-signature)) + (:render-cache ,(plist-get prepared :render-cache)) + (:detached-identity-history + ,(or (plist-get prepared :detached-identity-history) + (plist-get old-state :detached-identity-history))) + (:render-signature-cache + ,(plist-get prepared :render-signature-cache)) + (:flex-content-min-widths ,(make-hash-table :test 'eq)) + (:viewport-height-dependent-subtree-cache + ,(plist-get prepared + :viewport-height-dependent-subtree-cache)) + (:viewport-dependent-node-ids-ready + ,(and (eq candidate-root (plist-get old-state :root-node)) + (plist-get old-state + :viewport-dependent-node-ids-ready))) + (:viewport-dependent-node-ids + ,(and (eq candidate-root (plist-get old-state :root-node)) + (plist-get old-state :viewport-dependent-node-ids))) + (:viewport-dependent-node-id-axes + ,(and (eq candidate-root (plist-get old-state :root-node)) + (plist-get old-state + :viewport-dependent-node-id-axes))) + (:reflow-prewarm-scratch nil) + (:region-role-span-table + ,(plist-get prepared :region-role-span-table)) + (:scroll-region-ids + ,(ebox-incremental--hash-keys + (plist-get prepared :scroll-state-table))) + (:node-table ,(plist-get candidate-index :node-table)) + (:parent-table ,(plist-get candidate-index :parent-table)) + (:region-id-set ,(plist-get candidate-index :region-id-set)) + (:region-node-table + ,(plist-get candidate-index :region-node-table)) + (:region-box-count-table + ,(plist-get candidate-index :region-box-count-table)) + (:region-box-table + ,(plist-get candidate-index :region-box-table)) + (:host-ref-table + ,(plist-get candidate-index :host-ref-table)) + (:selector-id-table + ,(plist-get candidate-index :selector-id-table)) + (:selector-class-table + ,(plist-get candidate-index :selector-class-table)) + (:selector-type-table + ,(plist-get candidate-index :selector-type-table)) + (:selector-index-stale-p + ,(plist-get candidate-index :selector-index-stale-p)) + (:native-node-postorder + ,(plist-get candidate-index :native-node-postorder)))) + (setq state + (plist-put state (car entry) (cadr entry)))) + state)) + +(defun ebox-incremental--seed-candidate-node-cache + (old-cache old-node-table candidate-node-table) + "Seed an eq-keyed candidate cache from OLD-CACHE by stable node identity." + (let ((candidate-cache (make-hash-table :test 'eq)) + (missing (make-symbol "ebox-candidate-cache-missing"))) + (when (and (hash-table-p old-cache) + (hash-table-p old-node-table) + (hash-table-p candidate-node-table)) + (maphash + (lambda (node-id candidate-node) + (when-let ((old-node (gethash node-id old-node-table))) + (let ((cached (gethash old-node old-cache missing))) + (unless (eq cached missing) + (puthash candidate-node cached candidate-cache))))) + candidate-node-table)) + candidate-cache)) + +(defun ebox-incremental--copy-pruned-candidate-node-cache + (old-cache old-node-table old-parent-table dirty-set + touched-node-ids removed-node-ids) + "Copy OLD-CACHE and remove stale candidate-local nodes. +TOUCHED-NODE-IDS and REMOVED-NODE-IDS name old runtime nodes that no longer +belong in the candidate tree. DIRTY-SET removes old ancestor-path facts that +must be recomputed in the next publication." + (let ((candidate-cache + (if (hash-table-p old-cache) + (copy-hash-table old-cache) + (make-hash-table :test 'eq)))) + (dolist (entry dirty-set) + (ebox--invalidate-runtime-render-signature-path + (plist-get entry :node-id) + old-node-table old-parent-table + candidate-cache nil)) + (dolist (node-id (append touched-node-ids removed-node-ids)) + (when-let ((old-node (gethash node-id old-node-table))) + (remhash old-node candidate-cache))) + candidate-cache)) + +(defun ebox-incremental--candidate-structural-caches + (old-state candidate-index dirty-set &optional local-preparation) + "Return seeded candidate signature caches invalidated by DIRTY-SET." + (let* ((old-node-table (plist-get old-state :node-table)) + (old-parent-table (plist-get old-state :parent-table)) + (candidate-node-table (plist-get candidate-index :node-table)) + (candidate-parent-table (plist-get candidate-index :parent-table)) + (touched-node-ids + (plist-get local-preparation :touched-node-ids)) + (removed-node-ids + (plist-get local-preparation :removed-node-ids)) + (signature-cache + (if touched-node-ids + (ebox-incremental--copy-pruned-candidate-node-cache + (plist-get old-state :render-signature-cache) + old-node-table old-parent-table dirty-set + touched-node-ids removed-node-ids) + (ebox-incremental--seed-candidate-node-cache + (plist-get old-state :render-signature-cache) + old-node-table candidate-node-table))) + (height-cache + (if touched-node-ids + (ebox-incremental--copy-pruned-candidate-node-cache + (plist-get old-state :viewport-height-dependent-subtree-cache) + old-node-table old-parent-table dirty-set + touched-node-ids removed-node-ids) + (ebox-incremental--seed-candidate-node-cache + (plist-get old-state :viewport-height-dependent-subtree-cache) + old-node-table candidate-node-table)))) + (unless touched-node-ids + (dolist (entry dirty-set) + (ebox--invalidate-runtime-render-signature-path + (plist-get entry :node-id) + candidate-node-table candidate-parent-table + signature-cache height-cache))) + (list :render-signature-cache signature-cache + :viewport-height-dependent-subtree-cache height-cache))) + +(defun ebox-incremental--prepare-declarative-runtime + (buffer old-state candidate-root &optional logical-candidate-p + local-preparation) + "Prepare CANDIDATE-ROOT for BUFFER without mutating its published runtime." + (let* ((old-root (plist-get old-state :root-node)) + (render-cache + (or (plist-get old-state :render-cache) + (make-hash-table :test 'equal)))) + (let* ((candidate-index + (or (plist-get local-preparation :index) + (ebox--runtime-index candidate-root t))) + (candidate-region-box-table + (plist-get candidate-index :region-box-table)) + (dirty-set + (if local-preparation + (plist-get local-preparation :dirty-set) + (ebox-incremental--declarative-dirty-set + old-state candidate-root candidate-index))) + (current-display-signature (ebox--current-display-signature))) + (unless (equal (plist-get old-state :display-signature) + current-display-signature) + (push + (ebox--dirty-entry + (plist-get candidate-root :node-id) 'geometry + :changed-keys '(:display-signature) + :old-region-ids (ebox--node-all-region-ids old-root) + :new-region-ids (ebox--node-all-region-ids candidate-root)) + dirty-set)) + ;; Dirty-owner planning needs the geometry that is actually published, + ;; not geometry recomputed from the candidate source. Freeze only the + ;; lightweight text spans here; detail consumers complete footprint and + ;; topology fields on demand before the candidate becomes current. + (with-current-buffer buffer + (dolist (entry dirty-set) + (when (memq (plist-get entry :dirty-kind) '(geometry structure)) + (when (gethash (plist-get entry :node-id) + (plist-get old-state :node-table)) + (ebox--ensure-layout-snapshot-spans + buffer (plist-get entry :node-id))))) + (unless (equal (plist-get old-root :node-id) + (plist-get candidate-root :node-id)) + (ebox--ensure-layout-snapshot-spans + buffer (plist-get old-root :node-id)))) + (let* ((structural-caches + (ebox-incremental--candidate-structural-caches + old-state candidate-index dirty-set local-preparation)) + (render-signature-cache + (plist-get structural-caches :render-signature-cache)) + (viewport-height-dependent-subtree-cache + (plist-get structural-caches + :viewport-height-dependent-subtree-cache))) + (let* ((candidate-scroll-state-table + (ebox-incremental--candidate-scroll-state-table + buffer old-state candidate-index candidate-region-box-table)) + (layout-snapshots + (ebox-incremental--candidate-layout-snapshots + old-state candidate-root candidate-index))) + (list :root candidate-root + :logical-candidate-p logical-candidate-p + :index candidate-index + :dirty-set dirty-set + :display-signature current-display-signature + :render-cache render-cache + :detached-identity-history + (or (plist-get local-preparation + :detached-identity-history) + (plist-get old-state :detached-identity-history)) + :render-signature-cache render-signature-cache + :viewport-height-dependent-subtree-cache + viewport-height-dependent-subtree-cache + :layout-snapshots layout-snapshots + :region-role-span-table + ;; Candidate preparation reads published geometry but never + ;; mutates its role index. Reuse that marker forest until a + ;; text-changing batch detaches it once and rebuilds the + ;; winning generation after publication. + (plist-get old-state :region-role-span-table) + :scroll-state-table candidate-scroll-state-table)))))) + +(defun ebox-incremental--prepare-declarative-root + (buffer old-state next-root) + "Prepare newly built NEXT-ROOT for BUFFER without mutating publication." + (unless (and (listp next-root) (not (stringp next-root))) + (error "Ebox declarative root must be an Ebox node")) + ;; Validate before copying so shared references and cycles remain visible. + (ebox-tree-validate-declarative-root next-root) + (let* ((candidate-root + (ebox-tree-clear-runtime-identities + (ebox-tree-copy-node-structure next-root))) + (old-root (plist-get old-state :root-node))) + ;; Failed preparation may leave harmless allocator gaps, but cannot publish + ;; duplicate runtime identities. + (ebox-tree-reconcile-runtime old-root candidate-root) + (ebox-incremental--prepare-declarative-runtime + buffer old-state candidate-root nil))) + +(defun ebox-incremental--prepare-logical-candidate + (buffer old-state candidate) + "Prepare CANDIDATE's path-copied logical root for BUFFER." + (let* ((path-copy-trace (make-hash-table :test 'equal)) + (detached-history + (ebox-incremental--copy-detached-history old-state)) + (candidate-root + (let ((ebox-incremental--candidate-path-copy-trace + path-copy-trace)) + (ebox-incremental--candidate-logical-root + candidate detached-history))) + (local-preparation + (ebox-incremental--candidate-local-index-delta + old-state candidate-root path-copy-trace))) + ;; Replaced subtrees were validated when they were recorded; the + ;; untouched published remainder was validated when it was itself + ;; published, and path copying introduces no new sibling or ref + ;; relations outside the replaced anchors. Re-walking the whole + ;; tree here made every commit pay one full-page validation. + (setq local-preparation + (plist-put local-preparation + :detached-identity-history detached-history)) + (ebox-incremental--prepare-declarative-runtime + buffer old-state candidate-root t local-preparation))) + +(defun ebox-incremental--prepare-declarative-patch-op-direct (buffer op) + "Return a prepared publication for declarative OP in BUFFER." + (pcase (plist-get op :op) + ('paint-patch + (ebox-buffer-prepare-paint-patch buffer op)) + ('span-patch + (ebox-buffer-prepare-span-patch buffer op)) + ('child-reorder + (ebox-buffer-prepare-child-reorder buffer op)) + ('child-splice + (ebox-buffer-prepare-child-splice buffer op)) + ('owner-rerender + (ebox-buffer-prepare-owner-rerender buffer op)) + (_ nil))) + +(defun ebox-incremental--candidate-install-index + (state root index region-box-table scroll-state-table buffer) + "Install ROOT and INDEX into unpublished STATE and candidate side tables." + (let ((old-node-table (plist-get state :node-table)) + (candidate-node-table (plist-get index :node-table))) + ;; Scroll mutation isolation may path-copy retained nodes after the + ;; candidate caches were seeded. Re-key every persistent eq cache by + ;; stable node id before installing the new index so the current runtime + ;; never retains superseded path-copy nodes. + (dolist (key '(:render-signature-cache + :viewport-height-dependent-subtree-cache + :flex-content-min-widths)) + (plist-put + state key + (ebox-incremental--seed-candidate-node-cache + (plist-get state key) old-node-table candidate-node-table)))) + (plist-put state :root-node root) + (dolist (key '(:node-table :parent-table :region-id-set + :region-node-table :region-box-count-table + :host-ref-table :selector-id-table + :selector-class-table :selector-type-table + :selector-index-stale-p + :native-node-postorder)) + (plist-put state key (plist-get index key))) + (clrhash region-box-table) + (maphash (lambda (region-id box) + (puthash region-id box region-box-table)) + (plist-get index :region-box-table)) + (plist-put state :region-box-table region-box-table) + (maphash + (lambda (region-id scroll-state) + (when-let ((candidate-box (gethash region-id region-box-table))) + (setq scroll-state (plist-put scroll-state :box candidate-box)) + (setq scroll-state (plist-put scroll-state :buffer buffer)) + (setq scroll-state + (ebox--scroll-window-rebind-state-producers + scroll-state candidate-box)) + (puthash region-id scroll-state scroll-state-table))) + scroll-state-table) + state) + +(defun ebox-incremental--candidate-isolate-owner-runtime-mutations + (buffer owner-id) + "Path-copy retained scroll boxes that proof rendering OWNER-ID may mutate." + (let* ((state (ebox--buffer-render-state buffer)) + (old-state + (and ebox-incremental--candidate-base-state + (eq buffer + (car ebox-incremental--candidate-base-state)) + (cdr ebox-incremental--candidate-base-state))) + (old-node-table (and old-state (plist-get old-state :node-table))) + (owner (ebox--buffer-runtime-node buffer owner-id)) + (replacements (make-hash-table :test 'equal))) + (when (and owner old-node-table) + (cl-labels + ((visit (node) + (when (and (listp node) (not (stringp node))) + (let ((node-id (plist-get node :node-id))) + (when (and (eq node (gethash node-id old-node-table)) + (eq (plist-get node :ebox-type) 'box) + (eq (ebox-get node :overflow) 'scroll)) + (puthash node-id (copy-sequence node) replacements))) + (dolist (child (ebox-tree--children-raw node)) + (visit child))))) + (visit owner))) + (when (> (hash-table-count replacements) 0) + (let* ((root (plist-get state :root-node)) + (path-index + (list :node-table (plist-get state :node-table) + :parent-table (plist-get state :parent-table))) + (isolated-root + (ebox-incremental--candidate-path-copy-many + root path-index replacements)) + (isolated-preparation + (ebox-incremental--candidate-local-index-delta + state isolated-root)) + (isolated-index + (plist-get isolated-preparation :index))) + (ebox-incremental--candidate-install-index + state isolated-root isolated-index + ebox--region-box-table ebox--scroll-global-state buffer))) + (ebox--buffer-runtime-node buffer owner-id))) + +(defun ebox-incremental--candidate-proof-node-table (owner) + "Return a node-id table for one isolated deep copy of OWNER." + (let* ((proof-owner (ebox-tree-copy-node-structure owner)) + (table (make-hash-table :test 'equal))) + (cl-labels + ((visit (node) + (when (and (listp node) (not (stringp node))) + (puthash (ebox--ensure-node-id node) node table) + (dolist (child (ebox-tree--children-raw node)) + (visit child))))) + (visit proof-owner)) + table)) + +(defun ebox-incremental--candidate-proof-scroll-state-table + (buffer candidate-table proof-node-table) + "Return an isolated scroll table rebound to PROOF-NODE-TABLE." + (let ((proof-table (make-hash-table :test 'equal))) + (maphash + (lambda (region-id state) + (let* ((candidate-box (plist-get state :box)) + (proof-box + (and candidate-box + (gethash (plist-get candidate-box :node-id) + proof-node-table)))) + (when proof-box + (let ((proof-state + (ebox-incremental--copy-marker-tree + (copy-tree state)))) + (setq proof-state (plist-put proof-state :box proof-box)) + (setq proof-state (plist-put proof-state :buffer buffer)) + (setq proof-state + (ebox--scroll-window-rebind-state-producers + proof-state proof-box)) + (puthash region-id proof-state proof-table))))) + candidate-table) + proof-table)) + +(defun ebox-incremental--merge-proof-scroll-state + (buffer proof-table proof-scope candidate-table candidate-region-box-table) + "Merge PROOF-TABLE scroll state for PROOF-SCOPE into CANDIDATE-TABLE. + +PROOF-SCOPE is the set of region ids whose candidate scroll state was copied +into the proof runtime before rendering. A scoped id missing from PROOF-TABLE +after rendering represents an intentional `ebox--scroll-clear-state' and must +therefore be removed from the candidate runtime as part of the same proof." + (dolist (region-id proof-scope) + (unless (gethash region-id proof-table) + (when-let ((old-candidate-state (gethash region-id candidate-table))) + (ebox--scroll-clear-content-markers old-candidate-state) + (ebox--scroll-clear-rendered-window-markers old-candidate-state)) + (remhash region-id candidate-table))) + (maphash + (lambda (region-id proof-state) + (when-let ((candidate-box + (gethash region-id candidate-region-box-table))) + (let ((candidate-state (copy-sequence proof-state)) + (proof-box (plist-get proof-state :box)) + (old-candidate-state (gethash region-id candidate-table))) + ;; Retained scroll boxes were path-copied before proof, so syncing the + ;; canonical runtime offset cannot mutate the published base object. + (when (and proof-box (plist-member proof-box :scroll-offset)) + (plist-put candidate-box :scroll-offset + (plist-get proof-box :scroll-offset))) + (setq candidate-state + (plist-put candidate-state :box candidate-box)) + (setq candidate-state + (plist-put candidate-state :buffer buffer)) + (setq candidate-state + (ebox--scroll-window-rebind-state-producers + candidate-state candidate-box)) + (when old-candidate-state + (ebox--scroll-clear-content-markers old-candidate-state) + (ebox--scroll-clear-rendered-window-markers old-candidate-state)) + (puthash region-id candidate-state candidate-table)))) + proof-table)) + +(defun ebox-incremental--project-proof-node-cache + (proof-cache proof-node-table candidate-node-table candidate-cache) + "Project PROOF-CACHE entries onto live candidate nodes by stable node id. + +Only keys owned by PROOF-NODE-TABLE are transferable. This keeps proof-copy +conses out of the retained eq-keyed CANDIDATE-CACHE while preserving structural +facts computed during successful proof rendering." + (when (and (hash-table-p proof-cache) + (hash-table-p proof-node-table) + (hash-table-p candidate-node-table) + (hash-table-p candidate-cache)) + (maphash + (lambda (proof-node value) + (when-let* ((node-id (and (listp proof-node) + (plist-get proof-node :node-id))) + ((eq proof-node (gethash node-id proof-node-table))) + (candidate-node (gethash node-id candidate-node-table))) + (puthash candidate-node value candidate-cache))) + proof-cache))) + +(defun ebox-incremental--project-proof-structural-caches + (proof-node-table proof-signature-cache proof-height-cache + proof-flex-content-min-widths candidate-state) + "Promote successful proof cache facts into CANDIDATE-STATE without copies." + (let ((candidate-node-table (plist-get candidate-state :node-table))) + (ebox-incremental--project-proof-node-cache + proof-signature-cache proof-node-table candidate-node-table + (plist-get candidate-state :render-signature-cache)) + (ebox-incremental--project-proof-node-cache + proof-height-cache proof-node-table candidate-node-table + (plist-get candidate-state :viewport-height-dependent-subtree-cache)) + (ebox-incremental--project-proof-node-cache + proof-flex-content-min-widths proof-node-table candidate-node-table + (plist-get candidate-state :flex-content-min-widths)))) + +(defun ebox-incremental--prepare-logical-candidate-patch-op (buffer op) + "Prepare logical candidate OP against a proof-local owner subtree copy." + (let* ((candidate-state (ebox--buffer-render-state buffer)) + (owner-id (plist-get op :owner-id)) + (owner + (ebox-incremental--candidate-isolate-owner-runtime-mutations + buffer owner-id)) + (proof-node-table + (and owner + (ebox-incremental--candidate-proof-node-table owner))) + (candidate-region-box-table ebox--region-box-table) + (candidate-scroll-state-table ebox--scroll-global-state) + (proof-region-box-table + (copy-hash-table candidate-region-box-table)) + (proof-scroll-state-table + (and proof-node-table + (ebox-incremental--candidate-proof-scroll-state-table + buffer candidate-scroll-state-table proof-node-table))) + (proof-scroll-scope + (and proof-scroll-state-table + (ebox-incremental--hash-keys proof-scroll-state-table))) + (proof-idle-timer-table (make-hash-table :test 'equal)) + (proof-smooth-scroll-table (make-hash-table :test 'equal)) + (proof-signature-cache + (ebox-incremental--seed-candidate-node-cache + (plist-get candidate-state :render-signature-cache) + (plist-get candidate-state :node-table) + proof-node-table)) + (proof-height-cache + (ebox-incremental--seed-candidate-node-cache + (plist-get candidate-state + :viewport-height-dependent-subtree-cache) + (plist-get candidate-state :node-table) + proof-node-table)) + (proof-flex-content-min-widths + (ebox-incremental--seed-candidate-node-cache + (plist-get candidate-state :flex-content-min-widths) + (plist-get candidate-state :node-table) + proof-node-table)) + (published-render-cache (plist-get candidate-state :render-cache)) + (proof-render-cache + (if (hash-table-p published-render-cache) + (copy-hash-table published-render-cache) + (make-hash-table :test 'equal))) + (proof-root-cache-table-table + (let ((table (copy-hash-table ebox--render-root-cache-table-table)) + (published-root-cache + (and (hash-table-p published-render-cache) + (gethash published-render-cache + ebox--render-root-cache-table-table)))) + (when (hash-table-p published-root-cache) + (puthash proof-render-cache + (copy-hash-table published-root-cache) + table)) + table)) + (proof-render-cache-ring-table + (copy-hash-table ebox--render-cache-ring-table)) + (proof-render-cache-entry-side-effects-table + (copy-hash-table ebox--render-cache-entry-side-effects-table)) + (proof-render-state + (let ((state (copy-sequence candidate-state))) + (plist-put state :render-cache proof-render-cache) + (plist-put state :render-signature-cache proof-signature-cache) + state)) + prepared transferred-p) + (when proof-node-table + (unwind-protect + (progn + (let ((ebox-incremental--candidate-proof-node-table + (cons buffer proof-node-table)) + (ebox-incremental--buffer-render-state-override + (cons buffer proof-render-state)) + (ebox--region-box-table proof-region-box-table) + (ebox--scroll-global-state proof-scroll-state-table) + (ebox--scroll-idle-prefetch-timers proof-idle-timer-table) + (ebox--smooth-scroll-state-table proof-smooth-scroll-table) + (ebox--render-root-cache-table-table + proof-root-cache-table-table) + (ebox--render-cache-ring-table + proof-render-cache-ring-table) + (ebox--render-cache-entry-side-effects-table + proof-render-cache-entry-side-effects-table) + (ebox--render-cache-signature-cache proof-signature-cache) + (ebox--viewport-dependent-node-ids-cache + (make-hash-table :test 'eq)) + (ebox--viewport-dependent-subtree-cache + (make-hash-table :test 'eq)) + (ebox--viewport-height-dependent-subtree-cache + proof-height-cache) + (ebox--flex-content-min-width-table + proof-flex-content-min-widths)) + (setq prepared + (ebox-incremental--prepare-declarative-patch-op-direct + buffer op))) + (when prepared + (ebox-incremental--merge-proof-scroll-state + buffer proof-scroll-state-table proof-scroll-scope + candidate-scroll-state-table candidate-region-box-table) + (ebox-incremental--project-proof-structural-caches + proof-node-table proof-signature-cache proof-height-cache + proof-flex-content-min-widths candidate-state) + (plist-put candidate-state :render-cache proof-render-cache) + (when-let ((root-cache + (gethash + proof-render-cache + proof-root-cache-table-table))) + (puthash proof-render-cache root-cache + ebox--render-root-cache-table-table) + (when-let ((ring + (gethash root-cache + proof-render-cache-ring-table))) + (puthash root-cache ring ebox--render-cache-ring-table))) + (when-let ((ring + (gethash proof-render-cache + proof-render-cache-ring-table))) + (puthash proof-render-cache ring ebox--render-cache-ring-table)) + (dolist (cache + (delq nil + (list proof-render-cache + (gethash + proof-render-cache + proof-root-cache-table-table)))) + (maphash + (lambda (_key entry) + (when-let ((metadata + (gethash + entry + proof-render-cache-entry-side-effects-table))) + (puthash + entry metadata + ebox--render-cache-entry-side-effects-table))) + cache)) + (setq transferred-p t))) + (unless transferred-p + (ebox-incremental--detach-scroll-state-table-markers + proof-scroll-state-table)))) + prepared)) + +(defun ebox-incremental--op-with-published-owner-scope (buffer op) + "Return text-changing OP with BUFFER's replaced publication scope." + (if (not (ebox--text-changing-patch-op-p op)) + op + (when-let ((published + (ebox--snapshot-published-node + buffer (plist-get op :owner-id)))) + (setq op (copy-sequence op)) + (setq op + (plist-put op :old-owner-region-ids + (ebox--node-all-region-ids published)))) + op)) + +(defun ebox-incremental--prepare-declarative-patch-op (buffer op) + "Return a non-mutating prepared publication for declarative OP in BUFFER." + (setq op (ebox-incremental--op-with-published-owner-scope buffer op)) + (if (memq (plist-get op :op) '(child-reorder child-splice)) + (ebox-incremental--prepare-declarative-patch-op-direct buffer op) + (if (plist-get (ebox--buffer-render-state buffer) :logical-candidate-p) + (ebox-incremental--prepare-logical-candidate-patch-op buffer op) + (ebox-incremental--prepare-declarative-patch-op-direct buffer op)))) + +(defun ebox-incremental--declarative-tentative-op (buffer entry) + "Return the smallest proof candidate for declarative dirty ENTRY." + (let* ((entry (ebox--dirty-entry-with-impact-vector buffer entry)) + (node-id (plist-get entry :node-id)) + (dirty-kind (plist-get entry :dirty-kind))) + (if (and (equal (plist-get entry :changed-keys) '(:children)) + (or (plist-get entry :pure-child-reorder-p) + (and (plist-get entry :child-splice-p) + (ebox-incremental--simple-new-child-splice-p + buffer node-id entry)))) + (if (plist-get entry :pure-child-reorder-p) + (ebox--patch-op 'child-reorder node-id :dirty entry) + (ebox--patch-op 'child-splice node-id :dirty entry)) + (pcase dirty-kind + ('paint + (ebox--patch-op 'paint-patch node-id :dirty entry)) + (_ + (if-let ((span-owner-id + (ebox--span-patch-owner-id-for-dirty-entry entry buffer))) + (ebox--patch-op 'span-patch span-owner-id :dirty entry) + (when (and (eq dirty-kind 'structure) + (not + (ebox-incremental--owner-can-absorb-line-count-change-p + buffer node-id))) + (setq node-id + (or (ebox-incremental--declarative-next-owner-id + buffer node-id entry) + (ebox--buffer-root-node-id buffer)))) + (ebox--patch-op 'owner-rerender node-id :dirty entry))))))) + +(defun ebox-incremental--coalesce-sibling-line-span-ops (buffer ops) + "Merge sibling span patches sharing one published line in BUFFER. +Two or more span-patch ops whose owners are direct children of the +same parent, when that parent owns exactly one buffer line and is +itself span patchable, become one span patch at the parent: the +line is rerendered and republished once instead of once per child. +The parent proof still verifies the rendered line against the +published slots, so this only changes op granularity, never safety." + (let ((groups (make-hash-table :test 'equal)) + (coalesced nil) + (result nil)) + (dolist (op ops) + (let ((parent-id + (and (eq (plist-get op :op) 'span-patch) + (ebox--runtime-parent-id + buffer (plist-get op :owner-id))))) + (if parent-id + (push op (gethash parent-id groups)) + (push op result)))) + (maphash + (lambda (parent-id group) + (if (and (cdr group) + (not (equal parent-id (ebox--buffer-root-node-id buffer))) + (when-let* ((snapshot (ebox--ensure-layout-snapshot-spans + buffer parent-id)) + (spans (plist-get snapshot :buffer-spans))) + (= (length spans) 1)) + (ebox--cached-patchable-owner-p buffer parent-id 'span)) + (let ((merged (ebox--patch-op + 'span-patch parent-id + :dirty (plist-get (car group) :dirty)))) + (dolist (op (cdr group)) + (setq merged (ebox--patch-op-merge-dirty merged op))) + (push merged coalesced)) + (setq result (nconc group result)))) + groups) + ;; Coalesced parents re-enter dominance merging: a parent op may + ;; now cover other surviving descendants. + (dolist (op coalesced) + (setq result (ebox--merge-patch-op-into-set buffer result op))) + result)) + +(defun ebox-incremental--owner-can-absorb-line-count-change-p + (buffer node-id) + "Return non-nil when NODE-ID could publish a line-count change. +An owner whose spans cover whole buffer lines can replace them with a +different number of lines; a definite containment context can clamp a +descendant's line-count change inside its fixed height. A partial-slot +owner with neither property shares its lines with siblings, so any +render whose line count moved is unpublishable there by construction." + (or (when-let* ((snapshot + (ebox--ensure-layout-snapshot-spans buffer node-id)) + (spans (plist-get snapshot :buffer-spans))) + (with-current-buffer buffer + (ebox--line-spans-cover-whole-lines-p spans))) + (ebox--definite-containment-formatting-context-p buffer node-id))) + +(defun ebox-incremental--common-runtime-ancestor-id (buffer node-ids) + "Return the lowest common runtime ancestor of NODE-IDS in BUFFER." + (let ((ancestor-id (car node-ids))) + (dolist (node-id (cdr node-ids)) + (while (and ancestor-id + (not (or (equal ancestor-id node-id) + (ebox--cached-runtime-ancestor-id-p + buffer ancestor-id node-id)))) + (setq ancestor-id + (ebox--runtime-parent-id buffer ancestor-id)))) + ancestor-id)) + +(defun ebox-incremental--broad-dirty-owner-id (buffer node-ids) + "Return one structurally safe broad owner for NODE-IDS in BUFFER." + (let* ((root-id (ebox--buffer-root-node-id buffer)) + (root (ebox--buffer-runtime-node buffer root-id)) + (owner-id + (ebox-incremental--common-runtime-ancestor-id + buffer node-ids))) + (when (and root-id root owner-id) + (if (or (equal owner-id root-id) + (not (eq (ebox-tree-display-inner root) 'column))) + root-id + ;; A direct child of a column root owns complete vertical lines. + ;; Publishing it can therefore absorb descendant line-count changes + ;; without probing every intermediate ancestor's buffer spans. + (while (and owner-id + (not (equal + (ebox--runtime-parent-id buffer owner-id) + root-id))) + (setq owner-id (ebox--runtime-parent-id buffer owner-id))) + owner-id)))) + +(defun ebox-incremental--runtime-descendant-count (node) + "Return the number of declarative descendants below runtime NODE." + (cl-loop for child in (ebox-tree-node-children node) + sum (1+ (ebox-incremental--runtime-descendant-count child)))) + +(defun ebox-incremental--broad-dirty-op (buffer render-dirty-set) + "Return one safely proofable owner op for a broad RENDER-DIRTY-SET." + (when (and (>= (length render-dirty-set) + ebox--owner-coalesce-min-count) + (cl-some (lambda (entry) + (not (eq (plist-get entry :dirty-kind) 'paint))) + render-dirty-set)) + (let* ((node-ids + (delete-dups + (cl-mapcan #'ebox--dirty-provenance-node-ids + render-dirty-set))) + (owner-id + (ebox-incremental--broad-dirty-owner-id + buffer node-ids))) + (when-let* ((owner (and owner-id + (ebox--buffer-runtime-node buffer owner-id))) + (descendant-count + (ebox-incremental--runtime-descendant-count owner)) + ((> descendant-count 0)) + (dirty-descendant-count + (cl-count-if + (lambda (node-id) + (not (equal node-id owner-id))) + node-ids)) + ((>= (/ (float dirty-descendant-count) descendant-count) + ebox-incremental--broad-dirty-min-node-coverage))) + (ebox--patch-op + 'owner-rerender owner-id + :dirty (ebox--merge-dirty-provenance-list render-dirty-set)))))) + +(defun ebox-incremental--local-reflow-op (buffer render-dirty-set) + "Return one local wrapper op for related geometry RENDER-DIRTY-SET." + (when (and (> (length render-dirty-set) 1) + (not + (cl-every + (lambda (entry) + (equal (plist-get entry :changed-keys) '(:content))) + render-dirty-set)) + (cl-every + (lambda (entry) + (let ((entry (ebox--dirty-entry-with-impact-vector + buffer entry))) + (and (eq (plist-get entry :dirty-kind) 'geometry) + (memq 'parent-layout + (plist-get entry :impact-vector)) + (cl-some + (lambda (node-id) + (ebox--geometry-affects-parent-layout-p + buffer node-id)) + (ebox--dirty-provenance-node-ids entry))))) + render-dirty-set)) + (let* ((root-id (ebox--buffer-root-node-id buffer)) + (node-ids + (delete-dups + (cl-mapcan #'ebox--dirty-provenance-node-ids + render-dirty-set))) + (owner-id + (ebox-incremental--common-runtime-ancestor-id + buffer node-ids))) + (while (and owner-id (not (equal owner-id root-id)) + (not (ebox--owned-overflow-coverage-wrapper-node-p + buffer owner-id))) + (setq owner-id (ebox--runtime-parent-id buffer owner-id))) + (when (and owner-id (not (equal owner-id root-id)) + (not (equal (ebox--runtime-parent-id buffer owner-id) + root-id))) + (ebox--patch-op + 'owner-rerender owner-id + :dirty (ebox--merge-dirty-provenance-list render-dirty-set)))))) + +(defun ebox-incremental--dirty-set-has-child-splice-p + (buffer render-dirty-set) + "Return non-nil when RENDER-DIRTY-SET contains a provable child splice." + (cl-some + (lambda (entry) + (and (equal (plist-get entry :changed-keys) '(:children)) + (plist-get entry :child-splice-p) + (ebox-incremental--simple-new-child-splice-p + buffer (plist-get entry :node-id) entry))) + render-dirty-set)) + +(defun ebox-incremental--declarative-tentative-patch-set + (buffer render-dirty-set) + "Return merged smallest-owner candidates for RENDER-DIRTY-SET." + (let ((ebox--patchable-owner-cache (make-hash-table :test 'equal)) + (ebox--runtime-ancestor-cache (make-hash-table :test 'equal)) + (ebox--flex-slot-safety-cache (make-hash-table :test 'equal)) + (index (ebox--make-patch-op-merge-index)) + ops) + (ebox--with-layout-snapshot-detail-context buffer + (if-let ((coalesced-op + (and (not + (ebox-incremental--dirty-set-has-child-splice-p + buffer render-dirty-set)) + (or (ebox-incremental--broad-dirty-op + buffer render-dirty-set) + (ebox-incremental--local-reflow-op + buffer render-dirty-set))))) + (setq ops (list coalesced-op)) + (dolist (entry render-dirty-set) + (ebox--patch-op-merge-index-add + buffer index + (ebox-incremental--declarative-tentative-op buffer entry))) + (setq ops + (ebox-incremental--coalesce-sibling-line-span-ops + buffer (ebox--patch-op-merge-index-result index))))) + (ebox--dedupe-patch-owners buffer ops))) + +(defun ebox-incremental--declarative-next-owner-id + (buffer owner-id dirty) + "Return the next ancestor of OWNER-ID worth proving for DIRTY." + (let* ((root-id (ebox--buffer-root-node-id buffer)) + (ancestor-id + (or (ebox--runtime-parent-id buffer owner-id) root-id)) + (structure-change + (eq (plist-get dirty :dirty-kind) 'structure)) + (overflow-coverage + (plist-get dirty :requires-owned-overflow-coverage)) + (pure-overflow + (equal (plist-get dirty :changed-keys) '(:overflow)))) + (while + (and ancestor-id + (not (equal ancestor-id root-id)) + (or + (and structure-change + (not + (ebox-incremental--owner-can-absorb-line-count-change-p + buffer ancestor-id))) + (and overflow-coverage + (or + (not + (ebox--owned-overflow-coverage-wrapper-node-p + buffer ancestor-id)) + (and (not pure-overflow) + (ebox--partial-slot-owner-p + buffer ancestor-id)))))) + (setq ancestor-id + (or (ebox--runtime-parent-id buffer ancestor-id) root-id))) + ancestor-id)) + +(defun ebox-incremental--declarative-promoted-op (buffer op) + "Return the next wider declarative planning OP after OP failed preparation. +The root owner is the terminal element of this planning lattice. Structural +changes skip ancestors that cannot absorb a line-count change. Overflow +coverage skips wrappers that cannot own the old suffix and partial slots that +reject a merged non-overflow change before rendering." + (let* ((strategy (plist-get op :op)) + (owner-id (plist-get op :owner-id)) + (dirty (plist-get op :dirty)) + (root-id (ebox--buffer-root-node-id buffer))) + (cond + ((memq strategy '(paint-patch child-reorder child-splice span-patch)) + (ebox--patch-op 'owner-rerender owner-id + :dirty dirty + :planning-promoted-from strategy)) + ((and (eq strategy 'owner-rerender) + (not (equal owner-id root-id))) + (when-let ((ancestor-id + (ebox-incremental--declarative-next-owner-id + buffer owner-id dirty))) + (ebox--patch-op 'owner-rerender ancestor-id + :dirty dirty + :planning-promoted-from owner-id))) + (t nil)))) + +(defun ebox-incremental--declarative-op-proof-key (op) + "Return the transaction-local proof cache key for declarative OP." + (let ((dirty (plist-get op :dirty))) + (list (plist-get op :op) + (plist-get op :owner-id) + (plist-get dirty :dirty-kind) + (ebox--dirty-provenance-node-ids dirty) + (plist-get dirty :changed-keys) + (plist-get dirty :old-child-ids) + (plist-get dirty :new-child-ids) + (plist-get dirty :old-region-ids) + (plist-get dirty :new-region-ids) + (plist-get dirty :impact-vector) + (plist-get dirty :requires-owned-overflow-coverage)))) + +(defun ebox-incremental--prepared-op-spans (op) + "Return the published buffer spans proven for prepared OP." + (plist-get (plist-get op :prepared-publication) :spans)) + +(defun ebox-incremental--prepared-span-sets-overlap-p (left right) + "Return non-nil when prepared span sets LEFT and RIGHT overlap." + (cl-some + (lambda (left-span) + (cl-some + (lambda (right-span) + (and (< (car left-span) (cdr right-span)) + (> (cdr left-span) (car right-span)))) + right)) + left)) + +(defun ebox-incremental--prepared-span-set-contains-p (outer inner) + "Return non-nil when every span in INNER is contained by OUTER." + (cl-every + (lambda (inside) + (cl-some + (lambda (outside) + (and (<= (car outside) (car inside)) + (<= (cdr inside) (cdr outside)))) + outer)) + inner)) + +(defun ebox-incremental--first-overlapping-prepared-text-ops (ops) + "Return the first pair of overlapping prepared text-changing OPS." + (catch 'overlap + (let ((remaining ops)) + (while remaining + (let ((left (pop remaining))) + (when (ebox--text-changing-patch-op-p left) + (dolist (right remaining) + (when (and + (ebox--text-changing-patch-op-p right) + (ebox-incremental--prepared-span-sets-overlap-p + (ebox-incremental--prepared-op-spans left) + (ebox-incremental--prepared-op-spans right))) + (throw 'overlap (list left right)))))))))) + +(defun ebox-incremental--overlapping-prepared-owner-id + (buffer left right) + "Return the owner that safely absorbs overlapping prepared LEFT and RIGHT." + (let ((left-spans (ebox-incremental--prepared-op-spans left)) + (right-spans (ebox-incremental--prepared-op-spans right))) + (cond + ((ebox-incremental--prepared-span-set-contains-p left-spans right-spans) + (plist-get left :owner-id)) + ((ebox-incremental--prepared-span-set-contains-p right-spans left-spans) + (plist-get right :owner-id)) + ;; Partially crossing text replacements do not establish a composable + ;; local publication order. Re-prove the complete root instead. + (t (ebox--buffer-root-node-id buffer))))) + +(defun ebox-incremental--merge-overlapping-prepared-ops + (buffer ops overlapping) + "Replace OVERLAPPING prepared OPS with their common owner in OPS." + (let* ((left (car overlapping)) + (right (cadr overlapping)) + (left-owner-id (plist-get left :owner-id)) + (right-owner-id (plist-get right :owner-id)) + (common-owner-id + (ebox-incremental--overlapping-prepared-owner-id + buffer left right)) + (merged + (ebox--patch-op + 'owner-rerender common-owner-id + :dirty + (ebox--merge-dirty-provenance + (plist-get left :dirty) + (plist-get right :dirty)))) + (remaining + (cl-remove-if + (lambda (op) + (let ((owner-id (plist-get op :owner-id))) + (or (equal owner-id left-owner-id) + (equal owner-id right-owner-id)))) + ops))) + (ebox--dedupe-patch-owners + buffer (ebox--merge-patch-op-into-set buffer remaining merged)))) + +(defun ebox-incremental--prepare-declarative-patch-set + (buffer render-dirty-set) + "Return final prepared ops for RENDER-DIRTY-SET in BUFFER. +All owner promotion and rendering finishes before this function returns." + (when render-dirty-set + (let* ((ops + (ebox-incremental--declarative-tentative-patch-set + buffer render-dirty-set)) + (node-count + (hash-table-count + (or (ebox--buffer-node-table buffer) + (make-hash-table)))) + (attempt-limit (max 8 (* 3 (max 1 node-count)))) + (attempt-count 0) + (seen (make-hash-table :test 'equal)) + (proof-cache (make-hash-table :test 'equal)) + (missing-proof (make-symbol "ebox-missing-proof")) + complete) + (unless ops + (error "Ebox declarative dirty set produced no patch owners")) + (ebox--with-layout-snapshot-detail-context buffer + (while (not complete) + (when (> (cl-incf attempt-count) attempt-limit) + (error "Ebox declarative owner planning did not converge")) + (let ((ordered (ebox--patch-set-execution-order buffer ops)) + prepared-ops + failed-op) + (while (and ordered (not failed-op)) + (let* ((op (pop ordered)) + (proof-key + (ebox-incremental--declarative-op-proof-key op)) + (signature proof-key) + (cached (gethash proof-key proof-cache missing-proof)) + (prepared + (if (eq cached missing-proof) + (let ((proof + (ebox-incremental--prepare-declarative-patch-op + buffer op))) + (when proof + (puthash proof-key proof proof-cache)) + proof) + cached))) + (if prepared + (let ((prepared-op (copy-sequence op))) + (setq prepared-op + (plist-put prepared-op + :prepared-publication prepared)) + (push prepared-op prepared-ops)) + (when (gethash signature seen) + (error "Ebox declarative owner planning repeated %S" + signature)) + (puthash signature t seen) + (setq failed-op op)))) + (if (not failed-op) + (let* ((prepared-ops (nreverse prepared-ops)) + (overlapping + (ebox-incremental--first-overlapping-prepared-text-ops + prepared-ops))) + (if (not overlapping) + (setq complete prepared-ops) + (let* ((left (car overlapping)) + (right (cadr overlapping)) + (common-owner-id + (ebox-incremental--overlapping-prepared-owner-id + buffer left right)) + (signature + (list 'overlap + (plist-get left :owner-id) + (plist-get right :owner-id) + common-owner-id))) + (when (gethash signature seen) + (error + "Ebox declarative overlapping owner planning repeated %S" + signature)) + (puthash signature t seen) + (setq ops + (ebox-incremental--merge-overlapping-prepared-ops + buffer ops overlapping))))) + (let ((promoted + (ebox-incremental--declarative-promoted-op + buffer failed-op))) + (unless promoted + (error "Ebox declarative root owner could not be prepared")) + ;; OPS minus the failed op is already pairwise + ;; non-dominated, and one merge preserves that closure, + ;; so a full re-dedupe here would only repeat every + ;; dominance check. + (setq ops + (ebox--merge-patch-op-into-set + buffer (delq failed-op (copy-sequence ops)) + promoted))))))) + complete))) + +(defun ebox-incremental--finalize-declarative-scroll-publication + (scroll-keys) + "Retire stale scroll timers and resume prefetch for committed SCROLL-KEYS." + (dolist (region-id scroll-keys) + (ignore-errors (ebox--scroll-cancel-idle-prefetch region-id)) + (ignore-errors (ebox--smooth-scroll-stop region-id)) + (when (gethash region-id ebox--scroll-global-state) + (ignore-errors (ebox--scroll-schedule-idle-prefetch region-id))))) + +(defun ebox-incremental--commit-report (prepared patch-report state) + "Return the declarative report for PREPARED, PATCH-REPORT, and STATE." + (let* ((dirty-set (plist-get prepared :dirty-set)) + (report + (or (and patch-report (copy-sequence patch-report)) + (ebox--update-report + nil 'no-op :dirty-count 0 :patch-count 0 :patch-ops nil)))) + (setq report (plist-put report :constraint-source 'declarative)) + (setq report + (plist-put report :constraint-root-node-id + (plist-get (plist-get prepared :root) :node-id))) + (setq report (plist-put report :dirty-count (length dirty-set))) + (setq report + (plist-put report :dirty-kinds (ebox--dirty-set-kinds dirty-set))) + (setq report + (plist-put report :dirty-keys + (ebox--dirty-set-changed-keys dirty-set))) + (setq report + (plist-put report :render-scope + (if patch-report 'dirty-owners 'none))) + (setq report + (plist-put report :rendered-owner-ids + (copy-sequence (plist-get report :owner-ids)))) + (setq report + (plist-put report :publication-scope + (if patch-report + (or (plist-get report :publication-scope) + 'minimal-spans) + 'runtime-only))) + (setq report (plist-put report :runtime-published t)) + (setq report + (plist-put report :runtime-revision + (plist-get state :runtime-revision))) + report)) + +(defun ebox-incremental--restore-declarative-publication + (buffer old-state region-keys scroll-keys + region-snapshot scroll-snapshot &optional role-index-restored-p) + "Restore declarative publication state for BUFFER after nonlocal exit. +ROLE-INDEX-RESTORED-P means the transaction journal restored its exact table." + (let ((inhibit-quit t)) + (if (buffer-live-p buffer) + (progn + (puthash buffer old-state ebox--buffer-render-state-table) + (ebox-incremental--restore-hash-snapshot + ebox--region-box-table region-snapshot) + (ebox-incremental--restore-hash-snapshot + ebox--scroll-global-state scroll-snapshot) + (ignore-errors (ebox--refresh-buffer-box-extents buffer)) + (unless role-index-restored-p + (ignore-errors (ebox-buffer-refresh-region-role-spans buffer)))) + ;; `kill-buffer-hook' is authoritative teardown. A transaction that + ;; loses its buffer must never resurrect the old runtime snapshot or + ;; candidate-only region state while unwinding. + (ebox--clear-buffer-render-state buffer) + (dolist (region-id region-keys) + (remhash region-id ebox--region-box-table)) + (dolist (region-id scroll-keys) + (ignore-errors (ebox--scroll-clear-state region-id)) + (ignore-errors (ebox--smooth-scroll-stop region-id))) + (ebox--clear-box-extents-for-region-ids region-keys)))) + +(defun ebox-incremental--empty-logical-candidate-fast-p + (buffer old-state candidate) + "Return non-nil when CANDIDATE may publish as an O(1) runtime no-op. + +A display-signature change is render dirtiness even when the logical tree is +unchanged, so it must use the ordinary declarative transaction." + (and (null (ebox-candidate--replacements candidate)) + (with-current-buffer buffer + (equal (plist-get old-state :display-signature) + (ebox--current-display-signature))))) + +(defun ebox-incremental--empty-logical-candidate-state (old-state) + "Return a new runtime generation sharing every payload in OLD-STATE." + (let ((state (copy-sequence old-state))) + (setq state (plist-put state :logical-candidate-p t)) + (setq state + (plist-put state :runtime-revision + (1+ (or (plist-get old-state :runtime-revision) 0)))) + (setq state (plist-put state :reflow-prewarm-scratch nil)) + state)) + +(defun ebox-incremental--publish-empty-logical-candidate + (buffer old-state) + "Publish one runtime-only generation sharing OLD-STATE's payloads." + (let* ((candidate-state + (ebox-incremental--empty-logical-candidate-state old-state)) + (prepared + (list :root (plist-get old-state :root-node) + :dirty-set nil + :logical-candidate-p t)) + report published-p) + (unwind-protect + (let ((inhibit-quit t)) + (puthash buffer candidate-state ebox--buffer-render-state-table) + (setq report + (ebox-incremental--commit-report + prepared nil candidate-state)) + (plist-put candidate-state :last-update-report report) + (when ebox-incremental--after-declarative-publication + (funcall ebox-incremental--after-declarative-publication report)) + (unless (buffer-live-p buffer) + (error "Ebox declarative target died during publication")) + (setq published-p t) + report) + (unless published-p + (let ((inhibit-quit t)) + (if (buffer-live-p buffer) + (puthash buffer old-state ebox--buffer-render-state-table) + ;; Buffer teardown is authoritative. Never resurrect the old + ;; state after a publication callback kills it. + (remhash buffer ebox--buffer-render-state-table))))))) + +(defun ebox-incremental--commit-declarative-transaction + (buffer old-state prepare-function &optional stale-validator + empty-logical-candidate) + "Run one declarative transaction prepared by PREPARE-FUNCTION in BUFFER. + +STALE-VALIDATOR, when non-nil, runs before and after the mutation notification +and must reject an obsolete logical base without changing publication. +EMPTY-LOGICAL-CANDIDATE enables the O(1) runtime-only publication branch after +the shared stale and mutation-notification preamble." + (unless (buffer-live-p buffer) + (error "Ebox declarative commit requires a live buffer")) + (let ((defer-gc (ebox--buffer-visible-in-graphic-frame-p buffer))) + (when defer-gc + (ebox--deferred-render-gc-enter)) + (unwind-protect + (ebox--with-render-gc + (let ((old-state old-state)) + (unless old-state + (error "Ebox buffer has no rendered runtime: %S" buffer)) + (with-current-buffer buffer + (save-restriction + (widen) + (when ebox-incremental--declarative-commit-buffer + (error "Ebox declarative commit cannot reenter while %S is active" + ebox-incremental--declarative-commit-buffer)) + (when stale-validator + (funcall stale-validator buffer old-state)) + (ebox--cancel-buffer-runtime-prewarm buffer) + ;; Mutation hooks run before candidate ids are allocated. A hook may + ;; advance the shared monotonic allocators by rendering another buffer. + (ebox-incremental--notify-before-runtime-mutation + buffer 'declarative-commit) + (unless (eq old-state (ebox--buffer-render-state buffer)) + (error "Ebox runtime changed during declarative commit notification")) + (when stale-validator + (funcall stale-validator buffer old-state)) + (let ((ebox-incremental--declarative-commit-buffer buffer) + (live-region-box-table ebox--region-box-table) + (live-scroll-state-table ebox--scroll-global-state) + (base-buffer-modified-tick (buffer-modified-tick)) + prepared candidate-state patch-report report patch-set + render-dirty-set + region-keys scroll-keys region-snapshot scroll-snapshot) + (unwind-protect + (if (and empty-logical-candidate + (ebox-incremental--empty-logical-candidate-fast-p + buffer old-state empty-logical-candidate)) + (ebox-incremental--publish-empty-logical-candidate + buffer old-state) + (progn + (setq prepared + (funcall prepare-function buffer old-state)) + (setq candidate-state + (ebox-incremental--candidate-state + old-state + (plist-get prepared :root) + (plist-get prepared :index) + prepared)) + (setq render-dirty-set + (cl-remove-if + (lambda (entry) + (eq (plist-get entry :dirty-kind) 'metadata)) + (plist-get prepared :dirty-set))) + (setq region-keys + (delete-dups + (append + (ebox-incremental--hash-keys + (plist-get old-state :region-id-set)) + (ebox-incremental--hash-keys + (plist-get candidate-state :region-id-set))))) + (setq scroll-keys + (delete-dups + (append + (copy-sequence (plist-get old-state :scroll-region-ids)) + (ebox-incremental--buffer-scroll-keys buffer) + (ebox-incremental--hash-keys + (plist-get prepared :scroll-state-table))))) + (setq region-snapshot + (ebox-incremental--hash-snapshot + live-region-box-table region-keys)) + (setq scroll-snapshot + (ebox-incremental--hash-snapshot + live-scroll-state-table scroll-keys)) + (let ((candidate-region-box-table + (plist-get candidate-state :region-box-table)) + (candidate-scroll-state-table + (plist-get prepared :scroll-state-table)) + (candidate-idle-timer-table + (make-hash-table :test 'equal)) + (candidate-smooth-scroll-table + (make-hash-table :test 'equal)) + old-role-table-to-detach + candidate-role-table-to-detach + old-frozen-role-table + candidate-frozen-role-table + role-table-rollback-snapshot + role-table-journal-p + published-p role-tables-detached-p) + (unwind-protect + (progn + (let ((ebox--region-box-table + candidate-region-box-table) + (ebox--scroll-global-state + candidate-scroll-state-table) + (ebox--scroll-idle-prefetch-timers + candidate-idle-timer-table) + (ebox--smooth-scroll-state-table + candidate-smooth-scroll-table) + ;; Candidate nodes are fresh cons cells. Reuse + ;; pure persistent render entries, but never + ;; replay a cached closure owned by the old tree. + (ebox--render-cache-scroll-state-restorable-p + nil)) + (cl-letf + (((symbol-function + 'ebox--scroll-schedule-idle-prefetch) + (lambda (&rest _) nil))) + ;; Candidate lookups are dynamically isolated while + ;; owner selection and rendering are proven against + ;; the still-published buffer geometry. + (let ((ebox-incremental--buffer-render-state-override + (cons buffer candidate-state)) + (ebox-incremental--candidate-base-state + (cons buffer old-state))) + (setq patch-set + (ebox-incremental--prepare-declarative-patch-set + buffer render-dirty-set))) + (unless (= base-buffer-modified-tick + (buffer-modified-tick)) + (error + "Ebox buffer changed while declarative patches were prepared")) + (when (cl-some + #'ebox--text-changing-patch-op-p patch-set) + (ebox--materialize-box-extent-template buffer) + ;; Prepared spans are now the publication + ;; coordinate source. A shared marker table can + ;; follow the atomic buffer edit directly; its + ;; shallow journal preserves old hash entries for + ;; rollback while scoped refresh replaces only + ;; affected keys. Other table shapes retain the + ;; conservative frozen-coordinate path. + (setq old-role-table-to-detach + (plist-get old-state + :region-role-span-table) + candidate-role-table-to-detach + (plist-get candidate-state + :region-role-span-table)) + (if (and + (eq old-role-table-to-detach + candidate-role-table-to-detach) + (hash-table-p old-role-table-to-detach) + (ebox-buffer--marker-role-span-table-p + old-role-table-to-detach)) + (setq role-table-journal-p t + role-table-rollback-snapshot + (copy-hash-table + old-role-table-to-detach)) + (setq old-frozen-role-table + (ebox-buffer--copy-frozen-region-role-span-table + old-role-table-to-detach) + candidate-frozen-role-table + (ebox-buffer--copy-frozen-region-role-span-table + candidate-role-table-to-detach)))))) + ;; A pending C-g may be delivered only after Ebox, + ;; framework pointers, and the lifecycle job become + ;; one current generation. Ordinary errors still + ;; unwind the atomic change group and restore state. + (let ((inhibit-quit t)) + (when (and old-role-table-to-detach + (not role-table-journal-p)) + (plist-put old-state + :region-role-span-table + old-frozen-role-table) + (plist-put candidate-state + :region-role-span-table + candidate-frozen-role-table) + (ebox-incremental--detach-region-role-span-table + old-role-table-to-detach) + (unless (eq old-role-table-to-detach + candidate-role-table-to-detach) + (ebox-incremental--detach-region-role-span-table + candidate-role-table-to-detach)) + (setq role-tables-detached-p t)) + (let ((ebox--region-box-table + candidate-region-box-table) + (ebox--scroll-global-state + candidate-scroll-state-table) + (ebox--scroll-idle-prefetch-timers + candidate-idle-timer-table) + (ebox--smooth-scroll-state-table + candidate-smooth-scroll-table) + (ebox--render-cache-scroll-state-restorable-p + nil)) + (cl-letf + (((symbol-function + 'ebox--scroll-schedule-idle-prefetch) + (lambda (&rest _) nil))) + (atomic-change-group + (puthash buffer candidate-state + ebox--buffer-render-state-table) + (setq patch-report + (and patch-set + (ebox--with-preserved-buffer-window-state + buffer + (ebox-incremental--publish-prepared-patch-set + buffer patch-set + (length render-dirty-set))))) + (when (and render-dirty-set + (null patch-report)) + (error + "Ebox prepared declarative patches did not publish")) + (plist-put + candidate-state :scroll-region-ids + (ebox-incremental--hash-keys + candidate-scroll-state-table)) + (setq scroll-keys + (delete-dups + (append + scroll-keys + (copy-sequence + (plist-get + candidate-state + :scroll-region-ids))))) + (setq scroll-snapshot + (ebox-incremental--hash-snapshot + live-scroll-state-table scroll-keys)) + (ebox-incremental--retire-removed-region-indexes + buffer old-state candidate-state) + ;; Numeric extent templates describe one exact + ;; root generation. Owner patches maintain the + ;; live marker index and retire the stale template. + (setq-local ebox--box-extent-template nil) + (ebox-incremental--replace-hash-entries + live-region-box-table region-keys + candidate-region-box-table) + (ebox-incremental--replace-hash-entries + live-scroll-state-table scroll-keys + candidate-scroll-state-table) + ;; Logical candidates publish core lookup + ;; indexes immediately but leave viewport + ;; dependency discovery lazy. Rebuilding it + ;; here would traverse the complete tree after + ;; every small replacement. + (unless (plist-get candidate-state + :logical-candidate-p) + (ebox--buffer-viewport-dependent-node-id-axes + buffer)) + (setq report + (ebox-incremental--commit-report + prepared patch-report candidate-state)) + (plist-put candidate-state + :last-update-report report) + (when + ebox-incremental--after-declarative-publication + (funcall + ebox-incremental--after-declarative-publication + report)) + (unless (buffer-live-p buffer) + (error + "Ebox declarative target died during publication")) + (setq published-p t)))) + ;; A pending quit becomes observable only after the + ;; winning generation has retired its old marker and + ;; timer resources as well as promoted cross-layer + ;; pointers. Cleanup itself contains no user code. + (unless (or role-table-journal-p + role-tables-detached-p) + (let ((old-role-table + (plist-get old-state + :region-role-span-table)) + (candidate-role-table + (plist-get candidate-state + :region-role-span-table))) + ;; A runtime-only commit deliberately promotes the + ;; published marker forest by pointer. Retire the + ;; old table only when the candidate owns a distinct + ;; replacement. + (unless (eq old-role-table candidate-role-table) + (ebox-incremental--detach-region-role-span-table + old-role-table)))) + (let ((old-scroll-table + (make-hash-table :test 'equal))) + (dolist (entry scroll-snapshot) + (when (nth 1 entry) + (puthash (car entry) (nth 2 entry) + old-scroll-table))) + (ebox-incremental--detach-scroll-state-table-markers + old-scroll-table)) + (ebox-incremental--finalize-declarative-scroll-publication + scroll-keys) + report)) + (unless published-p + (let ((candidate-role-table + (plist-get candidate-state + :region-role-span-table)) + (old-role-table + (plist-get old-state + :region-role-span-table))) + (if role-table-journal-p + (progn + (ebox-incremental--detach-unshared-region-role-spans + candidate-role-table + role-table-rollback-snapshot) + (ebox-incremental--restore-region-role-span-table + old-role-table-to-detach + role-table-rollback-snapshot)) + ;; Preparation may share the published read-only + ;; marker forest. Rollback must not detach state that + ;; is about to become current again. + (unless (eq candidate-role-table old-role-table) + (ebox-incremental--detach-region-role-span-table + candidate-role-table)))) + (ebox-incremental--detach-scroll-state-table-markers + candidate-scroll-state-table) + (let ((ebox--region-box-table live-region-box-table) + (ebox--scroll-global-state live-scroll-state-table)) + (ebox-incremental--restore-declarative-publication + buffer old-state region-keys scroll-keys + region-snapshot scroll-snapshot + role-table-journal-p))))))) + (ebox--schedule-buffer-runtime-prewarm buffer))))))) + (when defer-gc + (ebox--deferred-render-gc-schedule-restore))))) + +(defun ebox-incremental-commit (buffer next-root) + "Atomically commit newly built declarative NEXT-ROOT into rendered BUFFER." + (let ((old-state (ebox--buffer-render-state buffer))) + (ebox-incremental--commit-declarative-transaction + buffer old-state + (lambda (target state) + (ebox-incremental--prepare-declarative-root + target state next-root))))) + +(defun ebox-incremental-commit-candidate (buffer-or-name candidate) + "Atomically commit one-shot logical CANDIDATE into BUFFER-OR-NAME." + (unless (ebox-candidate-p candidate) + (error "Ebox logical commit requires an Ebox candidate")) + ;; Sealing is deliberately the first state transition. Success, validation + ;; failure, staleness, and a wrong target all consume the transaction. + (when (ebox-candidate--sealed-p candidate) + (error "Ebox candidate is already sealed")) + (setf (ebox-candidate--sealed-p candidate) t) + (let ((buffer (get-buffer buffer-or-name)) + (old-state (ebox-candidate--base-state candidate))) + (unless (buffer-live-p buffer) + (error "Ebox candidate commit requires a live buffer: %S" + buffer-or-name)) + (ebox-incremental--commit-declarative-transaction + buffer old-state + (lambda (target state) + (ebox-incremental--prepare-logical-candidate + target state candidate)) + (lambda (target state) + (ebox-incremental--candidate-assert-current + candidate target state)) + candidate))) + +(provide 'ebox-incremental) + +;;; ebox-incremental.el ends here diff --git a/ebox-layout.el b/ebox-layout.el new file mode 100644 index 0000000..0bc44e5 --- /dev/null +++ b/ebox-layout.el @@ -0,0 +1,2803 @@ +;;; ebox-layout.el --- Generic layout composition for Ebox -*- lexical-binding: t; -*- + +;;; Commentary: +;; Owns generic box, row, column, stack, and spacer layout operations. It does +;; not own flex/grid formatting or Emacs buffer patch execution. + +;;; Code: + +(require 'cl-lib) +(require 'seq) +(require 'subr-x) +(require 'ebox-tree) +(require 'ebox-measure) +(require 'ebox-fragment) +(require 'ebox-render-context) + +(declare-function ebox--render-grid "ebox-grid" (node)) + +(defvar ebox--render-runtime-revision nil + "Current buffer runtime revision for render-local cache validation.") + +(defvar ebox--intrinsic-layout-measurement nil + "Non-nil while measuring intrinsic layout contributions. +Descendants still see the current containing block, but stack/column containers +must not stretch their own outer width to that containing block.") + +(defvar ebox--box-content-render-cache nil + "Dynamic render-pass cache for rendered lazy child content.") + +(defvar ebox--rendered-uniform-width-table + (make-hash-table :test 'eq :weakness 'key) + "Weak map from rendered strings to their proven uniform pixel width.") + +(defvar ebox--transparent-preformatted-box-fast-path-disabled nil + "Non-nil disables transparent preformatted box reuse for diagnostics.") + +(defvar ebox--flat-preformatted-box-fast-path-disabled nil + "Non-nil disables one-pass preformatted wrapper rendering for diagnostics.") + +(defun ebox--record-rendered-uniform-width (rendered pixel-width) + "Record that every line of RENDERED is exactly PIXEL-WIDTH wide." + (when (and (stringp rendered) (numberp pixel-width)) + (puthash rendered pixel-width ebox--rendered-uniform-width-table)) + rendered) + +(defun ebox--every-rendered-line-nonempty-p (rendered) + "Return non-nil when every line in RENDERED can carry text properties." + (let ((start 0) + (length (length rendered)) + valid) + (setq valid (> length 0)) + (while (and valid (<= start length)) + (let ((end (or (string-match "\n" rendered start) length))) + (setq valid (< start end)) + (setq start (1+ end)))) + valid)) + +(defun ebox--finish-preformatted-content-ownership! (rendered region-id) + "Finish line ownership for REGION-ID in pre-owned RENDERED." + (let ((start 0) + (index 0) + (length (length rendered))) + (while (<= start length) + (let ((end (or (string-match "\n" rendered start) length))) + (unless (text-property-not-all + start end 'ebox-content nil rendered) + (add-text-properties + start end + (list 'ebox-content region-id 'ebox-content-idx index) + rendered)) + (when (< end length) + (remove-text-properties + end (1+ end) + '(ebox-content-owner nil ebox-content-owners nil) + rendered)) + (setq start (1+ end) + index (1+ index))))) + rendered) + +(defun ebox--apply-surface-properties (rendered properties) + "Apply text-property PROPERTIES to RENDERED's character surface. + +Newlines are excluded. For each property independently, an existing non-nil +value on an inner rendered surface wins over the enclosing box's value." + (when properties + (unless (and (proper-list-p properties) + (zerop (% (length properties) 2))) + (error "ebox: :surface-properties must be an even property list")) + (let ((tail properties)) + (while tail + (unless (symbolp (car tail)) + (error "ebox: surface property name must be a symbol: %S" + (car tail))) + (setq tail (cddr tail)))) + (let ((line-start 0) + (limit (length rendered))) + (while (< line-start limit) + (let ((line-end (or (string-match "\n" rendered line-start) limit)) + (tail properties)) + (while tail + (let ((property (pop tail)) + (value (pop tail)) + (position line-start)) + ;; A nil text-property value denotes absence. Skipping it also + ;; prevents an enclosing surface from deleting an inner value. + (when value + (while (< position line-end) + (let ((next (or (next-single-property-change + position property rendered line-end) + line-end))) + (unless (get-text-property position property rendered) + (add-text-properties + position next (list property value) rendered)) + (setq position (max next (1+ position)))))))) + (setq line-start + (if (< line-end limit) (1+ line-end) limit)))))) + rendered) + +(defun ebox--render-transparent-preformatted-box (box) + "Render BOX by owning proven exact preformatted child content directly. +Return nil unless BOX has no visual chrome or sizing behavior that requires +the generic box pipeline." + (when (and (not ebox--transparent-preformatted-box-fast-path-disabled) + (ebox--box-content-node box) + (not (eq (ebox-get box :visibility) 'hidden)) + (null (ebox-get box :wrap-mode)) + (eq (ebox-get box :text-align) 'left) + (eq (ebox-get box :vertical-align) 'top) + (null (ebox-get box :height)) + (equal (or (ebox-get box :min-height) 0) 0) + (null (ebox-get box :max-height)) + (eq (ebox-get box :overflow) 'scroll) + (equal (ebox-get box :padding-left-pixel) 0) + (equal (ebox-get box :padding-right-pixel) 0) + (equal (ebox-get box :padding-top-height) 0) + (equal (ebox-get box :padding-bottom-height) 0) + (equal (ebox-get box :margin-left-pixel) 0) + (equal (ebox-get box :margin-right-pixel) 0) + (equal (ebox-get box :margin-top-height) 0) + (equal (ebox-get box :margin-bottom-height) 0) + (equal (ebox-get box :border-left-pixel) 0) + (equal (ebox-get box :border-right-pixel) 0) + (not (ebox-get box :border-top-p)) + (not (ebox-get box :border-bottom-p)) + (null (ebox-get box :color)) + (null (ebox-get box :bgcolor))) + (let* ((content (ebox--box-content box)) + (known-width + (gethash content ebox--rendered-uniform-width-table)) + (content-width (and (numberp known-width) + (ebox--content-pixel box)))) + (when (and (numberp known-width) + (= known-width content-width) + (ebox--every-rendered-line-nonempty-p content)) + (let* ((region-id (or ebox--render-region-id + (ebox--ensure-region-id box))) + (rendered (ebox--add-content-owner content region-id))) + (ebox--finish-preformatted-content-ownership! + rendered region-id) + (ebox--apply-surface-properties + rendered (ebox-get box :surface-properties)) + (puthash region-id box ebox--region-box-table) + (ebox--scroll-clear-state region-id) + (ebox--record-rendered-uniform-width rendered known-width)))))) + +(defun ebox--render-flat-preformatted-box (box) + "Render exact preformatted BOX chrome in one line-oriented pass. +Return nil unless BOX is a marginless wrapper whose content already has its +exact width and requires no vertical clipping, padding, or alignment work." + (when (and (not ebox--flat-preformatted-box-fast-path-disabled) + (plist-get box :ebox-content-width-exact-p) + (not (eq (ebox-get box :visibility) 'hidden)) + (null (ebox--box-content-node box)) + (stringp (ebox-get box :content)) + (null (ebox-get box :wrap-mode)) + (eq (ebox-get box :text-align) 'left) + (eq (ebox-get box :vertical-align) 'top) + (equal (or (ebox-get box :min-height) 0) 0) + (null (ebox-get box :max-height)) + (equal (or (ebox-get box :scroll-offset) 0) 0) + (equal (ebox-get box :margin-left-pixel) 0) + (equal (ebox-get box :margin-right-pixel) 0) + (equal (ebox-get box :margin-top-height) 0) + (equal (ebox-get box :margin-bottom-height) 0)) + (let* ((content (ebox-get box :content)) + (lines (ebox-string-lines content)) + (text-height (length lines)) + (height (ebox-get box :height)) + (overflow (ebox-get box :overflow)) + (exact-fixed-height-p + (and (numberp height) + (eq overflow 'hidden) + (= text-height (ebox--content-height box text-height))))) + (when (or (and (null height) (eq overflow 'scroll)) + exact-fixed-height-p) + (let* ((region-id (or ebox--render-region-id + (ebox--ensure-region-id box))) + (content-pixel (ebox--content-pixel box)) + (padding-line-filler (ebox-pixel-space content-pixel)) + (rendered + (ebox-lines-join + (ebox--window-render-flat-wrapper-chunk-lines + box lines 0 region-id padding-line-filler t t)))) + (puthash region-id box ebox--region-box-table) + (ebox--scroll-clear-state region-id) + (ebox--record-rendered-uniform-width + rendered (+ content-pixel (ebox--side-pixel box)))))))) + +(defvar ebox--scroll-window-render-disabled nil + "Non-nil disables lazy scroll-window rendering for full materialization.") + +(defvar ebox--scroll-window-render-result nil + "Dynamic metadata for the current box's lazy scroll-window render.") + +(defvar ebox--scroll-rendered-content-lines-private-p nil + "Non-nil when scroll content line inputs may receive ownership in place.") + +(defvar ebox--propertize-private-content-line-p) + +(defvar ebox--window-line-renderer-table (make-hash-table :test 'eq) + "Node-type keyed renderers for lazy scroll window line production.") + +(defvar ebox--window-line-prewarmer-table (make-hash-table :test 'eq) + "Node-type keyed prewarmers for yielding lazy scroll production.") + +(defvar ebox--scroll-window-yielding-prewarm-p nil + "Non-nil lets an idle prefix slice prepare one atomic child then yield.") + +(defvar ebox--scroll-window-prewarm-state nil + "Prefix-local state table used by yielding window-line prewarmers.") + +(defvar ebox-scroll-lazy-prefix-lookahead-lines) +(defvar ebox-wheel-scroll-step) +(defvar ebox--scroll-window-initial-lookahead-lines-override nil + "Optional exact lookahead used by latency-sensitive root reflows.") +(defvar ebox--render-cache-allow-viewport-dependent) +(defvar ebox--render-cache-signature-cache) +(defvar ebox--render-cache-table) + +(defun ebox--register-window-line-renderer (type function) + "Register FUNCTION as TYPE's lazy window-line renderer. +FUNCTION receives a node and a line limit. It should return a plist with +`:lines' and `:complete', or nil when the node needs normal rendering. +When returned lines are not already target-width lines, the plist may include +`:pixel-width' so the generic stack window can pad them. Incremental +producers may also return an exact `:prefix-delta' with equal-length rewrite +and append payloads; in that case they must retain the returned list's cons +prefix across extensions." + (unless (symbolp type) + (error "ebox: window-line renderer type must be a symbol: %S" type)) + (unless (functionp function) + (error "ebox: window-line renderer must be a function: %S" function)) + (puthash type function ebox--window-line-renderer-table) + function) + +(defun ebox--register-window-line-prewarmer (type function) + "Register FUNCTION as TYPE's yielding lazy line prewarmer." + (unless (symbolp type) + (error "ebox: window-line prewarmer type must be a symbol: %S" type)) + (unless (functionp function) + (error "ebox: window-line prewarmer must be a function: %S" function)) + (puthash type function ebox--window-line-prewarmer-table) + function) + +(defun ebox--prewarm-node-window-line (node target-width) + "Prepare at most one atomic child of NODE for TARGET-WIDTH. +Return non-nil only when the caller should yield without publishing a line. +Single-child wrappers are transparent to this scheduling boundary." + (when (and ebox--scroll-window-yielding-prewarm-p + (hash-table-p ebox--scroll-window-prewarm-state) + (listp node) + (not (stringp node))) + (let* ((type (plist-get node :ebox-type)) + (prewarmer (gethash type ebox--window-line-prewarmer-table))) + (if prewarmer + (funcall prewarmer node target-width) + (let ((children (ebox-tree--children-raw node))) + (when (and children (null (cdr children))) + (ebox--prewarm-node-window-line (car children) target-width))))))) + +(defun ebox--render-node-window-lines (node limit) + "Return NODE's lazy window lines through the registered type renderer." + (when-let* ((type (and (listp node) (plist-get node :ebox-type))) + (renderer (gethash type ebox--window-line-renderer-table))) + (funcall renderer node limit))) + +(defun ebox--scroll-lazy-prefix-lookahead-lines () + "Return the current lazy scroll prefix lookahead in lines." + (if (boundp 'ebox-scroll-lazy-prefix-lookahead-lines) + (max 0 (or ebox-scroll-lazy-prefix-lookahead-lines 0)) + 8)) + +(defun ebox--scroll-window-initial-lookahead-lines () + "Return extra lazy scroll lines retained after initial render." + (if (numberp ebox--scroll-window-initial-lookahead-lines-override) + (max 0 ebox--scroll-window-initial-lookahead-lines-override) + (let ((prefix-lookahead (ebox--scroll-lazy-prefix-lookahead-lines)) + (wheel-lookahead (if (boundp 'ebox-wheel-scroll-step) + (max 0 (or ebox-wheel-scroll-step 0)) + 16))) + (if (and ebox--defer-scroll-content-index + (boundp 'ebox-wheel-smooth-scroll) + ebox-wheel-smooth-scroll) + (max wheel-lookahead + (+ prefix-lookahead + (if (boundp 'ebox-wheel-smooth-scroll-lines-per-tick) + (max 1 (or ebox-wheel-smooth-scroll-lines-per-tick 1)) + 1))) + (+ prefix-lookahead wheel-lookahead))))) + +(defun ebox--scroll-rendered-content-lines + (box lines region-id &optional start-index) + "Return LINES as rendered scroll content lines for BOX and REGION-ID. +START-INDEX is the first source line index represented by LINES." + (let* ((padding-line-filler + (ebox-pixel-space (ebox--content-pixel box))) + (color (ebox-get box :color)) + (bgcolor (ebox-get box :bgcolor)) + (rendered + (cl-loop for line in lines + for idx from (or start-index 0) + collect + (let* ((colored-p (or color bgcolor)) + (owned-source + (if colored-p + (ebox--propertize-colors + (if (string-empty-p line) + padding-line-filler + line) + color bgcolor) + line))) + (let ((ebox--propertize-private-content-line-p + (or colored-p + ebox--scroll-rendered-content-lines-private-p))) + (ebox--propertize-content-line + owned-source region-id idx padding-line-filler)))))) + rendered)) + +(defun ebox--render-underfilled-simple-scroll-lines + (lines region-id content-height padding-line-filler color bgcolor) + "Finish chrome-free top-aligned underfilled scroll LINES in one pass." + (let ((ebox--propertize-private-content-line-p t) + rendered-lines) + (cl-labels + ((finish-line (line index source-p) + (when source-p + (setq line + (ebox--propertize-content-line + line region-id index padding-line-filler))) + (when (and (string-blank-p line) + (not (ebox--line-has-non-content-properties-p line))) + (let ((content-id (get-text-property 0 'ebox-content line)) + (content-idx + (get-text-property 0 'ebox-content-idx line))) + (setq line + (if content-id + (propertize padding-line-filler + 'ebox-content content-id + 'ebox-content-idx content-idx + 'ebox-content-owner region-id) + (propertize padding-line-filler + 'ebox-content region-id + 'ebox-content-idx 0 + 'ebox-content-owner region-id))))) + ;; `ebox-string-lines' made these source lines private, and blank + ;; normalization above creates a fresh filler. Apply the same face + ;; properties in place instead of copying every viewport-wide line. + (when color + (add-face-text-property + 0 (length line) `(:foreground ,color) t line)) + (when bgcolor + (add-face-text-property + 0 (length line) `(:background ,bgcolor) t line)) + line)) + (cl-loop for line in lines + for index from 0 + do (push (finish-line line index t) rendered-lines)) + (dotimes (_ (max 0 (- content-height (length lines)))) + (push (finish-line padding-line-filler 0 nil) rendered-lines))) + (nreverse rendered-lines))) + +(defun ebox--scroll-rendered-prefix-through-content + (box content-lines content-count region-id + rendered-lines rendered-count rendered-tail) + "Extend RENDERED-LINES through CONTENT-COUNT and return cache metadata. +The result is (LINES COUNT TAIL). Reuse the existing rendered prefix when it +is aligned with CONTENT-LINES; otherwise rebuild the bounded content cache." + (cond + ((>= rendered-count content-count) + (list rendered-lines rendered-count rendered-tail)) + ((and rendered-lines (<= rendered-count content-count)) + (let* ((suffix + (seq-take (nthcdr rendered-count content-lines) + (- content-count rendered-count))) + (rendered-suffix + (ebox--scroll-rendered-content-lines + box suffix region-id rendered-count))) + (pcase-let ((`(,new-lines ,new-tail) + (ebox--line-list-append! + rendered-lines rendered-tail rendered-suffix))) + (list new-lines content-count new-tail)))) + (t + (let ((rebuilt + (ebox--scroll-rendered-content-lines + box content-lines region-id 0))) + (list rebuilt content-count (last rebuilt)))))) + +(defun ebox--lines-justify (string pixel-width &optional align) + "Justify lines of STRING to PIXEL-WIDTH." + (let ((align (or align 'left))) + (if (eq align 'left) + (ebox--maplines + (lambda (line) + (let ((line-width (ebox--string-pixel-width line))) + (if (>= line-width pixel-width) + line + (concat line (ebox-pixel-space + (- pixel-width line-width)))))) + string) + (ebox--maplines + (lambda (line) + (ebox--pixel-reach line pixel-width align)) + string)))) + +(defsubst ebox--cjk-char-p (char) + "Check if CHAR is a CJK (Chinese, Japanese, Korean) character." + (and char + (>= char #x3000) + (or (<= char #x303F) ; CJK Symbols and Punctuation + (and (>= char #x3040) (<= char #x309F)) ; Hiragana + (and (>= char #x30A0) (<= char #x30FF)) ; Katakana + (and (>= char #x3400) (<= char #x4DBF)) ; CJK Extension A + (and (>= char #x4E00) (<= char #x9FFF)) ; CJK Unified Ideographs + (and (>= char #xFF00) (<= char #xFFEF))))) ; Fullwidth Forms + +(defsubst ebox--combining-char-p (char) + "Return non-nil when CHAR is a combining mark." + (and char + (or (and (>= char #x0300) (<= char #x036F)) + (and (>= char #x1AB0) (<= char #x1AFF)) + (and (>= char #x1DC0) (<= char #x1DFF)) + (and (>= char #x20D0) (<= char #x20FF)) + (and (>= char #xFE20) (<= char #xFE2F))))) + +(defsubst ebox--variation-selector-char-p (char) + "Return non-nil when CHAR is a Unicode variation selector." + (and char + (or (and (>= char #xFE00) (<= char #xFE0F)) + (and (>= char #xE0100) (<= char #xE01EF))))) + +(defsubst ebox--emoji-modifier-char-p (char) + "Return non-nil when CHAR is an emoji modifier." + (and char (>= char #x1F3FB) (<= char #x1F3FF))) + +(defsubst ebox--regional-indicator-char-p (char) + "Return non-nil when CHAR is a regional indicator symbol." + (and char (>= char #x1F1E6) (<= char #x1F1FF))) + +(defsubst ebox--grapheme-extension-char-p (char) + "Return non-nil when CHAR extends the previous grapheme cluster." + (or (ebox--combining-char-p char) + (ebox--variation-selector-char-p char) + (ebox--emoji-modifier-char-p char) + (eq char #x200D))) + +(defsubst ebox--grapheme-cluster-end (string start) + "Return the end index of the grapheme cluster at START in STRING. +This handles the common clusters that matter for UI text: combining marks, +variation selectors, emoji modifiers, ZWJ sequences, and regional-indicator +pairs. It is intentionally small; full Unicode segmentation is out of scope." + (let* ((len (length string)) + (end (min len (1+ start)))) + ;; Adjacent ASCII codepoints cannot form any cluster recognized below. + ;; Keep the full Unicode path when a non-ASCII extension follows an ASCII + ;; base, so combining marks retain exactly the same grouping. + (unless (and (< (aref string start) 128) + (or (= end len) (< (aref string end) 128))) + (when (and (< end len) + (ebox--regional-indicator-char-p (aref string start)) + (ebox--regional-indicator-char-p (aref string end))) + (setq end (1+ end))) + (while (and (< end len) + (or (ebox--grapheme-extension-char-p (aref string end)) + (eq (aref string (1- end)) #x200D))) + (setq end (1+ end)))) + end)) + +(defun ebox--grapheme-clusters (string) + "Return STRING as grapheme-cluster metadata plists." + (let ((pos 0) + (len (length string)) + clusters) + (while (< pos len) + (let* ((end (ebox--grapheme-cluster-end string pos)) + (text (substring string pos end))) + (push (list :start pos + :end end + :text text + :width (ebox--string-pixel-width text) + :cjk (ebox--cjk-char-p (aref string pos)) + :space (= (aref string pos) ?\s)) + clusters) + (setq pos end))) + (vconcat (nreverse clusters)))) + +(defun ebox--max-char-pixel (string) + "Return the pixel width of the widest character in STRING." + (if (string-empty-p string) + (ebox--space-pixel-width) + (let ((max-width 0) + (clusters (ebox--grapheme-clusters string))) + (dotimes (i (length clusters)) + (let ((width (plist-get (aref clusters i) :width))) + (when (> width max-width) (setq max-width width)))) + (max max-width (ebox--space-pixel-width))))) + +(defun ebox--wrap-line (line max-pixel wrap-mode) + "Wrap a single LINE to MAX-PIXEL width, preserving text properties. +WRAP-MODE can be `word', `char', or `kp'. +CJK characters are always wrapped by char. +For `kp' mode, see `ebox--wrap-text' which delegates to ekp package. +Returns a list of strings (lines)." + (cond + ((or (null line) (string-empty-p line)) + (list "")) + ;; Fast path: line fits + ((<= (ebox--string-pixel-width line) max-pixel) + (list line)) + ;; Wrap needed + (t + (let* ((clusters (ebox--grapheme-clusters line)) + (cluster-count (length clusters)) + (result-lines '()) + (line-start 0) + (current-width 0) + (index 0)) + (cl-labels ((cluster-start (i) + (plist-get (aref clusters i) :start)) + (cluster-width (i) + (plist-get (aref clusters i) :width)) + (cluster-cjk-p (i) + (plist-get (aref clusters i) :cjk)) + (cluster-space-p (i) + (plist-get (aref clusters i) :space)) + (flush-before (i) + (let ((end (cluster-start i))) + (when (< line-start end) + (push (substring line line-start end) result-lines)) + (setq line-start end current-width 0)))) + (while (< index cluster-count) + (let ((cluster-width (cluster-width index))) + (cond + ;; CJK: Wrap by character + ((cluster-cjk-p index) + (when (> (+ current-width cluster-width) max-pixel) + (flush-before index)) + (setq current-width (+ current-width cluster-width)) + (setq index (1+ index))) + + ;; Word mode: Wrap by word + ((eq wrap-mode 'word) + (let ((word-end index) + (word-width 0)) + ;; Scan word + (while (and (< word-end cluster-count) + (not (cluster-space-p word-end)) + (not (cluster-cjk-p word-end))) + (setq word-width (+ word-width (cluster-width word-end))) + (setq word-end (1+ word-end))) + (cond + ;; Word too long: split by char + ((> word-width max-pixel) + (while (< index word-end) + (let ((cw (cluster-width index))) + (when (> (+ current-width cw) max-pixel) + (flush-before index)) + (setq current-width (+ current-width cw)) + (setq index (1+ index))))) + ;; Word fits on current line + ((<= (+ current-width word-width) max-pixel) + (setq current-width (+ current-width word-width)) + (setq index word-end)) + ;; Word goes to next line + (t + (flush-before index) + (setq current-width word-width) + (setq index word-end))) + ;; Handle trailing space + (when (and (< index cluster-count) (cluster-space-p index)) + (let ((space-width (cluster-width index))) + (if (<= (+ current-width space-width) max-pixel) + (progn + (setq current-width (+ current-width space-width)) + (setq index (1+ index))) + (flush-before index) + (setq index (1+ index)) + (setq line-start + (if (< index cluster-count) + (cluster-start index) + (length line)))))))) + + ;; Char mode + (t + (when (> (+ current-width cluster-width) max-pixel) + (flush-before index)) + (setq current-width (+ current-width cluster-width)) + (setq index (1+ index)))))) + ;; Flush remaining + (when (< line-start (length line)) + (push (substring line line-start) result-lines))) + (nreverse result-lines))))) + +(defun ebox--wrap-text (text max-pixel wrap-mode) + "Wrap multi-line TEXT to MAX-PIXEL using WRAP-MODE. +When WRAP-MODE is nil, return TEXT without pre-wrapping. +When WRAP-MODE is `kp', use Knuth-Plass algorithm via ekp package." + (cond + ((null wrap-mode) + text) + ((eq wrap-mode 'kp) + (if (and (require 'ekp nil t) + (fboundp 'ekp-pixel-justify)) + (ekp-pixel-justify text max-pixel) + (ebox--wrap-text text max-pixel 'word))) + (t + (let* ((lines (ebox-string-lines text)) + (wrapped (mapcan (lambda (l) + (ebox--wrap-line l max-pixel wrap-mode)) + lines))) + (ebox-lines-join wrapped))))) + +;;; ============================================================ +;;; Render: Box Rendering Logic +;;; ============================================================ + +(defun ebox--side-pixel (box &optional side) + "Calculate side pixel width (padding + border + margin). +SIDE can be `left', `right', or nil (both)." + (let ((left (+ (ebox-get box :padding-left-pixel) + (ebox-get box :border-left-pixel) + (ebox-get box :margin-left-pixel))) + (right (+ (ebox-get box :padding-right-pixel) + (ebox-get box :border-right-pixel) + (ebox-get box :margin-right-pixel)))) + (pcase side + ('left left) + ('right right) + (_ (+ left right))))) + +(defun ebox--side-height (box) + "Calculate side height (padding + margin) in lines." + (+ (floor (ebox-get box :padding-top-height)) + (floor (ebox-get box :padding-bottom-height)) + (floor (ebox-get box :margin-top-height)) + (floor (ebox-get box :margin-bottom-height)))) + +(defun ebox--box-sizing-content-pixel (box value) + "Return VALUE converted to content pixels according to BOX sizing." + (when value + (if (eq (ebox-get box :box-sizing) 'border-box) + (max 0 (- value (+ (ebox-get box :padding-left-pixel) + (ebox-get box :padding-right-pixel) + (ebox-get box :border-left-pixel) + (ebox-get box :border-right-pixel)))) + value))) + +(defun ebox--box-sizing-content-height (box value) + "Return VALUE converted to content lines according to BOX sizing." + (when value + (if (eq (ebox-get box :box-sizing) 'border-box) + (max 0 (- value + (+ (floor (ebox-get box :padding-top-height)) + (floor (ebox-get box :padding-bottom-height))))) + value))) + +(defun ebox--viewport-pixel-width (&optional default) + "Return the current viewport width in pixels, or DEFAULT." + (if (and (numberp ebox-viewport-width) + (> ebox-viewport-width 0)) + (floor ebox-viewport-width) + default)) + +(defun ebox--viewport-size-value-p (value) + "Return non-nil when VALUE is a viewport-relative size marker." + (or (eq value 'viewport) + (and (consp value) (eq (car value) 'viewport)))) + +(defun ebox--horizontal-size-pixels (value &optional default) + "Return VALUE as pixels using Ebox horizontal size units. +DEFAULT is returned when VALUE is nil or not a supported horizontal size. +In addition to normal Ebox units, `(viewport)' resolves to +`ebox-viewport-width'." + (cond + ((null value) default) + ((ebox--viewport-size-value-p value) + (ebox--viewport-pixel-width default)) + ((and (consp value) (numberp (car value))) (car value)) + ((numberp value) (* value (ebox--space-pixel-width))) + (t default))) + +(defun ebox--nonnegative-horizontal-size-pixels (value &optional default) + "Return VALUE as non-negative horizontal pixels. +Signal an error when VALUE resolves to a negative width-like size." + (let ((pixels (ebox--horizontal-size-pixels value default))) + (when (and pixels (< pixels 0)) + (error "ebox: width values cannot be negative: %S" value)) + pixels)) + +(defun ebox--viewport-height-lines (&optional fallback) + "Return the dynamically bound viewport height in lines, or FALLBACK." + (if (and (numberp ebox-viewport-height) + (> ebox-viewport-height 0)) + (floor ebox-viewport-height) + fallback)) + +(defun ebox--viewport-height-size-value-p (value) + "Return non-nil when vertical size VALUE depends on viewport height." + (or (eq value 'viewport-height) + (and (consp value) + (or (eq (car value) 'viewport-height) + (and (memq (car value) '(+ -)) + (cl-some #'ebox--viewport-height-size-value-p + (cdr value))))))) + +(defun ebox--resolve-height-expression-lines (value) + "Resolve vertical size expression VALUE to raw outer height lines. +Return nil when VALUE depends on an unavailable viewport height." + (cond + ((numberp value) value) + ((eq value 'viewport-height) + (ebox--viewport-height-lines nil)) + ((and (consp value) + (eq (car value) 'viewport-height)) + (ebox--viewport-height-lines nil)) + ((and (consp value) + (memq (car value) '(+ -))) + (let ((values + (mapcar #'ebox--resolve-height-expression-lines + (cdr value)))) + (when (cl-every #'numberp values) + (pcase (car value) + ('+ (apply #'+ values)) + ('- (if (= (length values) 1) + (- (car values)) + (apply #'- values))))))) + (t + (error "ebox: unsupported height value after expansion: %S" value)))) + +(defun ebox--resolve-size-content-height (box value fallback) + "Resolve vertical size VALUE to BOX content height lines. +FALLBACK is used for nil, auto, or unavailable viewport-height values." + (cond + ((null value) fallback) + ((eq value 'auto) fallback) + ((or (numberp value) + (eq value 'viewport-height) + (and (consp value) + (or (eq (car value) 'viewport-height) + (memq (car value) '(+ -))))) + (if-let ((height (ebox--resolve-height-expression-lines value))) + (ebox--box-sizing-content-height box (max 0 height)) + fallback)) + (t + (error "ebox: unsupported height value after expansion: %S" value)))) + +(defun ebox--content-max-pixel (box) + "Return BOX's max-content width in content pixels." + (let* ((content (ebox--box-content box)) + (lines (ebox-string-lines content))) + (if lines + (apply #'max (mapcar #'ebox--string-pixel-width lines)) + 0))) + +(defun ebox--box-content-node (box) + "Return BOX's lazy child layout node, if any." + (plist-get box :ebox-content-node)) + +(defun ebox--box-content-cache-context () + "Return the current render context for lazy box content caching." + (list ebox-viewport-width + ebox-viewport-height + ebox--intrinsic-layout-measurement + ebox--render-region-id)) + +(defun ebox--render-box-content-node (box node) + "Render NODE as BOX content, using the render-pass cache when available." + (if (null ebox--box-content-render-cache) + (ebox--render-node-as-box-content node box) + (let* ((context (ebox--box-content-cache-context)) + (entries (gethash box ebox--box-content-render-cache)) + (cached (assoc context entries))) + (if cached + (cdr cached) + (let ((content (ebox--render-node-as-box-content node box))) + (puthash box + (cons (cons context content) entries) + ebox--box-content-render-cache) + content))))) + +(defun ebox--box-content (box) + "Return BOX content, rendering any lazy child layout content if present." + (or (when-let ((node (ebox--box-content-node box))) + (ebox--render-box-content-node box node)) + (ebox-get box :content))) + +(defun ebox--line-min-content-pixel (line) + "Return LINE's min-content width in content pixels." + (let ((position 0) + (end (length line)) + (max-width 0) + (run-width 0)) + (cl-labels ((flush-run () + (when (> run-width max-width) + (setq max-width run-width)) + (setq run-width 0))) + (while (< position end) + (let* ((cluster-end + (ebox--grapheme-cluster-end line position)) + (char (aref line position))) + (cond + ;; A separator width never contributes to either adjacent run. + ;; Skip the pixel query instead of measuring and discarding it. + ((= char ?\s) + (flush-run)) + (t + (let ((width + (if (and (< char 128) + (= cluster-end (1+ position)) + (null (text-properties-at position line))) + (ebox--char-pixel-width char) + (ebox--substring-pixel-width + line position cluster-end)))) + (if (ebox--cjk-char-p char) + (progn + (flush-run) + (when (> width max-width) + (setq max-width width))) + (setq run-width (+ run-width width)))))) + (setq position cluster-end))) + (flush-run)) + max-width)) + +(defun ebox--content-min-pixel (box) + "Return BOX's min-content width in content pixels." + (let ((wrap-mode (ebox-get box :wrap-mode))) + (if (null wrap-mode) + (ebox--content-max-pixel box) + (let ((lines (ebox-string-lines (ebox--box-content box))) + (max-width 0)) + (dolist (line lines max-width) + (setq max-width + (max max-width (ebox--line-min-content-pixel line)))))))) + +(defun ebox--stretch-content-pixel (box) + "Return BOX's stretch-fit content width, or nil without a viewport." + (when-let ((viewport (ebox--viewport-pixel-width nil))) + (max 0 (- viewport (ebox--side-pixel box))))) + +(defun ebox--fit-content-pixel (box limit) + "Return BOX's fit-content width in content pixels. +LIMIT is already resolved to content pixels, or nil for stretch-fit." + (let ((min-content (ebox--content-min-pixel box)) + (max-content (ebox--content-max-pixel box)) + (available (or limit + (ebox--stretch-content-pixel box) + (ebox--content-max-pixel box)))) + (max min-content + (min max-content available)))) + +(defun ebox--definite-content-size-pixel (box value) + "Resolve VALUE to BOX content pixels when it is independent of content. +Return nil for intrinsic sizes such as min-content and max-content." + (cond + ((null value) nil) + ((ebox--viewport-size-value-p value) + (when-let ((viewport (ebox--viewport-pixel-width nil))) + (ebox--box-sizing-content-pixel box viewport))) + ((numberp value) + (ebox--box-sizing-content-pixel box value)) + ((memq value '(stretch contain fit-content)) + (ebox--stretch-content-pixel box)) + ((and (consp value) (eq (car value) 'fit-content)) + (let ((limit (cadr value))) + (cond + ((ebox--viewport-size-value-p limit) + (when-let ((viewport (ebox--viewport-pixel-width nil))) + (ebox--box-sizing-content-pixel box viewport))) + ((numberp limit) + (ebox--box-sizing-content-pixel box limit)) + (t nil)))) + (t nil))) + +(defun ebox--wrapper-content-viewport-pixel (props) + "Return PROPS' content viewport for rendering preformatted child layouts. +PROPS are already engine-normalized; do not pass them back through public +property parsers." + (let* ((width-value (ebox-get props :width)) + (min-width (ebox--definite-content-size-pixel + props (ebox-get props :min-width))) + (max-width (ebox--definite-content-size-pixel + props (ebox-get props :max-width))) + (preferred-width + (or (ebox--definite-content-size-pixel props width-value) + (and (or (null width-value) + (eq width-value 'auto) + (eq width-value 'fit-content)) + (ebox--stretch-content-pixel props)) + max-width))) + (when preferred-width + (max (or min-width 0) + (min (or max-width 999999999) + (max 0 preferred-width)))))) + +(defun ebox--render-node-as-box-content (node props) + "Render NODE as preformatted content for a box with PROPS. +When PROPS establish a content-box viewport, nested auto and `(viewport)' +widths resolve against that inner content box instead of the outer caller +viewport. A max-content wrapper instead measures the composite NODE without +an inherited viewport." + (let ((content-viewport (ebox--wrapper-content-viewport-pixel props))) + (cond + ((eq (ebox-get props :width) 'max-content) + (let ((ebox-viewport-width nil) + (ebox--intrinsic-layout-measurement t)) + (ebox-render node))) + (content-viewport + (let ((ebox-viewport-width content-viewport)) + (ebox-render node))) + (t + (ebox-render node))))) + +(defun ebox--resolve-size-content-pixel (box value fallback) + "Resolve a horizontal size VALUE to BOX content pixels. +FALLBACK is used for nil and auto values." + (cond + ((null value) fallback) + ((eq value 'viewport) + (if-let ((viewport (ebox--viewport-pixel-width nil))) + (ebox--box-sizing-content-pixel box viewport) + fallback)) + ((numberp value) + (ebox--box-sizing-content-pixel box value)) + ((eq value 'auto) fallback) + ((eq value 'none) nil) + ((eq value 'min-content) + (ebox--content-min-pixel box)) + ((eq value 'max-content) + (ebox--content-max-pixel box)) + ((eq value 'fit-content) + (ebox--fit-content-pixel box nil)) + ((memq value '(stretch contain)) + (or (ebox--stretch-content-pixel box) fallback)) + ((and (consp value) (eq (car value) 'fit-content)) + (ebox--fit-content-pixel + box (ebox--resolve-size-content-pixel box (cadr value) nil))) + (t + (error "ebox: unsupported width value after expansion: %S" value)))) + +(defun ebox--content-pixel (box) + "Calculate content pixel width, respecting box-sizing." + (let* ((width-value (ebox-get box :width)) + ;; Intrinsic measurement is only a fallback for auto-like widths. + ;; Definite and intrinsic keywords resolve themselves, so eagerly + ;; measuring max-content here only repeats GUI pixel work without + ;; affecting the used width. + (preferred-width + (cond + ((or (null width-value) (eq width-value 'auto)) + (or (ebox--stretch-content-pixel box) + (ebox--content-max-pixel box))) + ((or (eq width-value 'viewport) + (memq width-value '(stretch contain))) + (or (ebox--resolve-size-content-pixel box width-value nil) + (ebox--content-max-pixel box))) + (t + (ebox--resolve-size-content-pixel box width-value nil)))) + (min-width (ebox--resolve-size-content-pixel + box (ebox-get box :min-width) 0)) + (max-width (ebox--resolve-size-content-pixel + box (ebox-get box :max-width) nil))) + (max (or min-width 0) + (min (or max-width 999999999) + (max 0 preferred-width))))) + +(defun ebox--content-height (box text-height) + "Calculate content height, respecting box-sizing." + (let* ((min-height (ebox--resolve-size-content-height + box (ebox-get box :min-height) 0)) + (max-height (ebox--resolve-size-content-height + box (ebox-get box :max-height) nil)) + (fixed-height (ebox--resolve-size-content-height + box (ebox-get box :height) nil)) + (raw-height (or fixed-height text-height))) + (max 1 min-height + (min (or max-height 999999999) raw-height)))) + +(defun ebox--format-content-string (box content) + "Format BOX CONTENT (wrap and justify) returning a string." + (let ((wrap-mode (ebox-get box :wrap-mode)) + (align (ebox-get box :text-align)) + (known-width + (gethash content ebox--rendered-uniform-width-table))) + (if (and (eq align 'left) + (or (and (null wrap-mode) + (or (plist-get box :ebox-content-width-exact-p) + (and (numberp known-width) + (= known-width + (ebox--content-pixel box))))) + (and (memq wrap-mode '(word char)) + (numberp known-width) + (= known-width (ebox--content-pixel box))))) + content + (let* ((pixel-width (ebox--content-pixel box)) + (wrapped-content (ebox--wrap-text content pixel-width wrap-mode))) + ;; kp mode distributes inner gaps, but Ebox still owns the box geometry. + ;; Pad ragged lines (notably the last line) back to the content width + ;; while preserving the requested alignment for those lines. + (ebox--lines-justify wrapped-content pixel-width align))))) + +(defun ebox--format-content (box) + "Format BOX content (wrap and justify) returning a string." + (or (ebox--format-scroll-window-content box) + (ebox--format-content-string box (ebox--box-content box)))) + +(defun ebox--total-pixel (box) + "Get the total pixel width of the BOX." + (+ (ebox--content-pixel box) (ebox--side-pixel box))) + +(defun ebox--total-height (box) + "Get the total height of the BOX in lines." + (let* ((formatted-content (ebox--format-content box)) + (text-height (ebox-string-height formatted-content))) + (+ (ebox--content-height box text-height) (ebox--side-height box)))) + +(defun ebox--node-p (x) + "Return non-nil if X is an ebox layout node." + (and (listp x) (plist-member x :ebox-type))) + +(defun ebox--render-box (box) + "Render BOX to a string. +Internal implementation of `ebox-render' for box nodes." + (or (ebox--render-transparent-preformatted-box box) + (ebox--render-flat-preformatted-box box) + (let* (;; Generate unique region ID for this box. + ;; Priority: + ;; 1. ebox--render-region-id — explicit single-box render override; + ;; never bind it across a recursive layout render + ;; 2. :region-id cached on box plist — survives full-rerender so callers + ;; that stored the id after the first render can keep using it + ;; 3. Fresh id stored back on box plist for all subsequent renders + (region-id (or ebox--render-region-id + (ebox--ensure-region-id box))) + + ;; Format content first + (ebox--scroll-window-render-result nil) + (formatted-content (ebox--format-content box)) + (scroll-window-result ebox--scroll-window-render-result) + (text-height (ebox-string-height formatted-content)) + + ;; Calculate dimensions + (content-pixel (ebox--content-pixel box)) + (content-height (or (plist-get scroll-window-result + :content-height) + (ebox--content-height box text-height))) + + ;; overflow + (overflow (ebox-get box :overflow)) + + ;; Padding + (padding-left (ebox-get box :padding-left-pixel)) + (padding-right (ebox-get box :padding-right-pixel)) + (padding-top (floor (ebox-get box :padding-top-height))) + (padding-bottom (floor (ebox-get box :padding-bottom-height))) + + ;; Margin + (margin-left (ebox-get box :margin-left-pixel)) + (margin-right (ebox-get box :margin-right-pixel)) + (margin-top (floor (ebox-get box :margin-top-height))) + (margin-bottom (floor (ebox-get box :margin-bottom-height))) + + ;; Border Dimensions and Colors + (border-left-pixel (ebox-get box :border-left-pixel)) + (border-left-color (ebox-get box :border-left-color)) + (border-right-pixel (ebox-get box :border-right-pixel)) + (border-right-color (ebox-get box :border-right-color)) + (border-top-p (ebox-get box :border-top-p)) + (border-top-color (ebox-get box :border-top-color)) + (border-bottom-p (ebox-get box :border-bottom-p)) + (border-bottom-color (ebox-get box :border-bottom-color)) + + ;; Style and Layout + (color (ebox-get box :color)) + (bgcolor (ebox-get box :bgcolor)) + (inner-height (+ content-height padding-top padding-bottom)) + (padding-line-filler (ebox-pixel-space content-pixel)) + + (scroll-offset (or (ebox-get box :scroll-offset) 0)) + + ;; Overflow handling + (formatted-lines (ebox-string-lines formatted-content)) + (private-formatted-lines-p + (not (and (eq overflow 'scroll) + (> text-height content-height)))) + (overflow-lines (when (and (eq overflow 'visible) + (> text-height content-height)) + (seq-drop formatted-lines content-height))) + + ;; For scroll mode, take lines starting from scroll-offset + (visible-start + (if (eq overflow 'scroll) + (max 0 + (min scroll-offset + (max 0 (- text-height content-height)))) + 0)) + (visible-end + (min (+ visible-start content-height) text-height)) + (visible-lines + (if (eq overflow 'scroll) + (seq-subseq formatted-lines visible-start visible-end) + (seq-take formatted-lines content-height))) + + (underfilled-simple-scroll-lines + (when (and (eq overflow 'scroll) + (or (null scroll-window-result) + (plist-get scroll-window-result :complete)) + (< text-height content-height) + (= padding-left 0) + (= padding-right 0) + (= padding-top 0) + (= padding-bottom 0) + (= margin-left 0) + (= margin-right 0) + (= margin-top 0) + (= margin-bottom 0) + (= border-left-pixel 0) + (= border-right-pixel 0) + (not border-top-p) + (not border-bottom-p) + (eq (ebox-get box :vertical-align) 'top)) + (ebox--render-underfilled-simple-scroll-lines + formatted-lines region-id content-height padding-line-filler + color bgcolor))) + + (scroll-rendered-lines + (when (and (eq overflow 'scroll) + (>= text-height content-height) + (= padding-left 0) + (= padding-right 0) + (= margin-left 0) + (= margin-right 0) + (= margin-top 0) + (= margin-bottom 0) + (= border-left-pixel 0) + (= border-right-pixel 0) + (eq (ebox-get box :vertical-align) 'top)) + (let ((ebox--scroll-rendered-content-lines-private-p + private-formatted-lines-p)) + (ebox--scroll-rendered-content-lines + box formatted-lines region-id 0)))) + + (simple-scroll-rendered-lines + (or underfilled-simple-scroll-lines + (and scroll-rendered-lines + (= padding-top 0) + (= padding-bottom 0) + (not border-top-p) + (not border-bottom-p) + scroll-rendered-lines))) + + ;; The main content block - 给每行添加 ebox-content 属性用于增量更新 + ;; 注意:空字符串无法承载 text-property,需先替换为有宽度的空白 + (result + (cond + (underfilled-simple-scroll-lines + (ebox-lines-join underfilled-simple-scroll-lines)) + (simple-scroll-rendered-lines + (ebox-lines-join + (seq-subseq simple-scroll-rendered-lines + visible-start visible-end))) + (t + (let ((ebox--propertize-private-content-line-p + private-formatted-lines-p)) + (ebox-lines-join + (cl-loop for line in visible-lines + for idx from 0 + collect (ebox--propertize-content-line + line region-id idx + padding-line-filler)))))))) + + ;; Rendering is the first point where the complete formatted height is + ;; known. Keep the host box and published scroll state on the same + ;; canonical offset after data shrinkage (including underfill), otherwise + ;; a later growth can resurrect an invisible stale position. + (when (eq overflow 'scroll) + (setq scroll-offset visible-start) + ;; Intrinsic measurement renders live runtime subtrees against + ;; placeholder content whose visible-start is always 0; writing + ;; that back would reset the user's real scroll offset. + (unless ebox--intrinsic-layout-measurement + (plist-put box :scroll-offset visible-start))) + + ;; 1. Vertical Alignment of content within the box height. Reused scroll + ;; lines already fill a top-aligned, chrome-free viewport. + (unless simple-scroll-rendered-lines + (setq result (ebox--lines-align-vertical + result content-height + (ebox-get box :vertical-align)))) + + ;; Fill any empty lines generated by alignment with correct width spaces + ;; 保留原有的 ebox-content/ebox-content-idx 属性 + (unless simple-scroll-rendered-lines + (setq result + (ebox--maplines + (lambda (line) + (if (and (string-empty-p (string-trim line)) + (not (ebox--line-has-non-content-properties-p line))) + (let ((content-id (get-text-property 0 'ebox-content line)) + (content-idx + (get-text-property 0 'ebox-content-idx line))) + (if content-id + (propertize padding-line-filler + 'ebox-content content-id + 'ebox-content-idx content-idx + 'ebox-content-owner region-id) + (propertize padding-line-filler + 'ebox-content region-id + 'ebox-content-idx 0 + 'ebox-content-owner region-id))) + line)) + result))) + + ;; 2. Vertical Padding (Top/Bottom) - 添加区域标记 + (when (or (> padding-top 0) (> padding-bottom 0)) + (let ((pt-str (when (> padding-top 0) + (ebox--propertize-region + (ebox--pixel-blank content-pixel padding-top) + 'ebox-pt region-id))) + (pb-str (when (> padding-bottom 0) + (ebox--propertize-region + (ebox--pixel-blank content-pixel padding-bottom) + 'ebox-pb region-id)))) + (setq result (ebox--lines-stack-vertical pt-str result pb-str)))) + + ;; 3. Horizontal Padding (Left/Right) + (let* ((pl-str (when (> padding-left 0) + (ebox--propertize-region + (ebox--pixel-blank padding-left inner-height) + 'ebox-pl region-id))) + (pr-str (when (> padding-right 0) + (ebox--propertize-region + (ebox--pixel-blank padding-right inner-height) + 'ebox-pr region-id)))) + ;; Cache box definition for dynamic content updates + (puthash region-id box ebox--region-box-table) + ;; Cache scroll state only when the box can actually move. Most boxes + ;; use scroll overflow by default but have no hidden lines; keeping them + ;; out of the global scroll table keeps wheel hit-testing cheap. + ;; Intrinsic measurement must leave published scroll state alone: its + ;; placeholder content always looks fully visible and would clear the + ;; live lazy producer state. + (unless ebox--intrinsic-layout-measurement + (if (and (eq overflow 'scroll) + (> text-height content-height)) + (ebox--scroll-set-state + region-id + (append + (list :scroll-offset scroll-offset + :content-lines formatted-lines + :content-height content-height + :rendered-content-lines scroll-rendered-lines + :box box) + (when scroll-window-result + (list :content-lines-complete-p + (plist-get scroll-window-result :complete) + :scroll-window-producer-spec + (plist-get scroll-window-result + :scroll-window-producer-spec) + :render-content-prefix + (plist-get scroll-window-result + :render-content-prefix) + :materialize-content-lines + (plist-get scroll-window-result + :materialize-content-lines))))) + (ebox--scroll-clear-state region-id))) + (when (or pl-str pr-str) + (setq result + (ebox--lines-concat-horizontal pl-str result pr-str)))) + + ;; 4. Foreground and Background Color + (when (and (or color bgcolor) + (not simple-scroll-rendered-lines)) + (setq result + (ebox--maplines + (lambda (line) + (ebox--propertize-colors line color bgcolor)) + result))) + + ;; 5. Left/Right Border - 添加区域标记 + (when (or (> border-left-pixel 0) (> border-right-pixel 0)) + (let ((bl-str (when (> border-left-pixel 0) + (ebox--propertize-region + (ebox--pixel-border border-left-pixel + inner-height border-left-color) + 'ebox-bl region-id))) + (br-str (when (> border-right-pixel 0) + (ebox--propertize-region + (ebox--pixel-border border-right-pixel + inner-height border-right-color) + 'ebox-br region-id)))) + (setq result + (ebox--lines-concat-horizontal + bl-str result br-str)))) + + ;; 6. Top/Bottom Border (implemented as overline/underline) - 添加区域标记 + ;; + ;; Apply horizontal borders after horizontal padding and side borders are + ;; assembled, so the overline/underline spans the same full outer line that + ;; owns the left/right border display spaces. This keeps corners visually + ;; connected when text-scale changes line metrics. + (when (or border-top-p border-bottom-p) + (let ((lines (ebox-string-lines result))) + (when border-top-p + (setf (car lines) + (propertize + (ebox--propertize-overline (car lines) border-top-color) + 'ebox-bt region-id))) + (when border-bottom-p + (setf (car (last lines)) + (propertize + (ebox--propertize-underline (car (last lines)) border-bottom-color) + 'ebox-bb region-id))) + (setq result (ebox-lines-join lines)))) + + ;; The host interaction surface is the border box. Apply it after all + ;; content, padding, and border characters exist, but before margins and + ;; visible overflow are appended. Nested text properties retain priority. + (ebox--apply-surface-properties + result (ebox-get box :surface-properties)) + + ;; 7. Left/Right Margin - 添加区域标记 + (when (or (> margin-left 0) (> margin-right 0)) + (let* ((box-height (ebox-string-height result)) + (ml-str (when (> margin-left 0) + (ebox--propertize-region + (ebox--pixel-blank margin-left box-height) + 'ebox-ml region-id))) + (mr-str (when (> margin-right 0) + (ebox--propertize-region + (ebox--pixel-blank margin-right box-height) + 'ebox-mr region-id)))) + (setq result + (ebox--lines-concat-horizontal + ml-str result mr-str)))) + + ;; 8. Top/Bottom Margin - 添加区域标记 + (when (or (> margin-top 0) (> margin-bottom 0)) + (let ((total-pixel-width (ebox--total-pixel box))) + (let ((mt-str (when (> margin-top 0) + (ebox--propertize-region + (ebox--pixel-blank total-pixel-width margin-top) + 'ebox-mt region-id))) + (mb-str (when (> margin-bottom 0) + (ebox--propertize-region + (ebox--pixel-blank total-pixel-width margin-bottom) + 'ebox-mb region-id)))) + (setq result (ebox--lines-stack-vertical mt-str result mb-str))))) + + ;; 8. Overflow Content (Rendered outside the box if visible) + (when overflow-lines + (let* ((left-space (+ margin-left border-left-pixel padding-left)) + (right-space (+ padding-right border-right-pixel margin-right)) + (overflow-left-pad (ebox-pixel-space left-space)) + (overflow-right-pad (ebox-pixel-space right-space)) + (overflow-text + (mapconcat + (lambda (line) + (concat overflow-left-pad + (if color + (ebox--propertize-color line color) + line) + overflow-right-pad)) + overflow-lines + "\n"))) + (setq result (concat result "\n" overflow-text)))) + (when (and (eq overflow 'scroll) + (> text-height content-height)) + (add-text-properties 0 (length result) + (list 'ebox-scroll-window region-id) + result)) + ;; Visibility suppresses every drop of the box's ink (content, + ;; colors, borders, visible-overflow spill) while margins remain + ;; the same blanks and all metadata properties survive verbatim. + (when (eq (ebox-get box :visibility) 'hidden) + (setq result (ebox--apply-hidden-visibility result))) + result))) + +;;;###autoload +(defun ebox-render (node) + "Render a layout NODE to a multi-line string. +NODE can be: + - a box plist created by `ebox-create' + - a concat node created by `ebox-concat' + - a stack node created by `ebox-stack' + - a flex node created by `ebox-flex' +This is the single public rendering entry point." + (ebox--with-validated-display-cache + (let ((ebox--box-content-render-cache + (or ebox--box-content-render-cache + (make-hash-table :test 'eq)))) + (when (listp node) + (ebox--ensure-node-id node)) + (let ((type (plist-get node :ebox-type))) + (cond + ((eq type 'box) (ebox--render-box node)) + ((eq type 'concat) (ebox--render-concat node)) + ((eq type 'stack) (ebox--render-stack node)) + ((eq type 'flex) (ebox--render-flex node)) + ((eq type 'grid) (ebox--render-grid node)) + ((eq type 'flex-item) (ebox-render (plist-get node :node))) + (t (error "ebox-render: unknown node type %S" type))))))) + +(defun ebox--width-pad (string extra-pixels) + "Append a display-space of EXTRA-PIXELS to the right of every line in STRING. +Used by `ebox--render-stack' to equalise rows to the same total width." + (if (<= extra-pixels 0) + string + (let ((pad (propertize " " 'display `(space :width (,extra-pixels))))) + (ebox--maplines (lambda (line) (concat line pad)) string)))) + +(defun ebox--height-pad (string target-height) + "Pad STRING to TARGET-HEIGHT lines, appending blank pixel-wide lines. +The blank lines match the pixel width of the first line of STRING." + (let* ((lines (ebox-string-lines string)) + (current-h (length lines)) + (extra (- target-height current-h))) + (if (<= extra 0) + string + (let* ((px (ebox--string-pixel-width string)) + (blank (ebox-pixel-space px))) + (ebox-lines-join (append lines (make-list extra blank))))))) + +;;;###autoload +(defun ebox-concat (node1 node2) + "Return a lazy concat node that places NODE1 and NODE2 side by side. +NODE1 and NODE2 must be box/concat/stack nodes created by +`ebox-create', `ebox-concat', or `ebox-stack'. +The result is not rendered until `ebox-render' or `ebox-render-to-buffer' +is called. To compose more than two boxes, nest calls: + (ebox-concat a (ebox-concat b c))" + (list :ebox-type 'concat + :display '(block row) + :children (delq nil (list node1 node2)) + :left node1 + :right node2)) + +;;;###autoload +(defun ebox-spacer (&rest plist) + "Return a blank box node. +PLIST accepts the same properties as `ebox-create', such as :width, +:height, :margin, or :bgcolor." + (apply #'ebox-create :content "" plist)) + +;;;###autoload +(defun ebox-row (&rest nodes) + "Return a lazy horizontal layout node from NODES. +Nil entries are ignored. With zero non-nil nodes, return `ebox-spacer'." + (setq nodes (delq nil nodes)) + (cond + ((null nodes) (ebox-spacer)) + ((null (cdr nodes)) (car nodes)) + (t (list :ebox-type 'concat + :display '(block row) + :children nodes)))) + +(defun ebox--render-concat (node) + "Render a concat NODE to a string (internal, called by `ebox-render'). +Children are rendered; shorter children are padded with blank lines so all +reach the same height before horizontal concatenation." + (let* ((rendered (mapcar #'ebox--render-with-cache + (ebox--layout-children node))) + (max-h (apply #'max (mapcar #'ebox-string-height rendered))) + (line-lists + (mapcar (lambda (string) + (ebox-string-lines + (ebox--height-pad string max-h))) + rendered))) + (ebox-lines-join (apply #'cl-mapcar #'concat line-lists)))) + +;;;###autoload +(defun ebox-stack (node1 node2) + "Return a lazy stack node that places NODE1 above NODE2 vertically. +NODE1 and NODE2 must be box/concat/stack nodes created by +`ebox-create', `ebox-concat', or `ebox-stack'. +The result is not rendered until `ebox-render' or `ebox-render-to-buffer' +is called. To stack more than two boxes, nest calls: + (ebox-stack a (ebox-stack b c))" + (list :ebox-type 'stack + :display '(block column) + :children (delq nil (list node1 node2)) + :top node1 + :bottom node2)) + +;;;###autoload +(defun ebox-column (&rest nodes) + "Return a lazy vertical layout node from NODES. +Nil entries are ignored. With zero non-nil nodes, return `ebox-spacer'." + (setq nodes (delq nil nodes)) + (cond + ((null nodes) (ebox-spacer)) + ((null (cdr nodes)) (car nodes)) + (t (list :ebox-type 'stack + :display '(block column) + :children nodes)))) + +(defun ebox--stack-leaves (node) + "Return NODE's vertical stack leaves in render order." + (let (leaves) + (cl-labels ((collect (current) + (if (and (listp current) + (eq (plist-get current :ebox-type) 'stack)) + (dolist (child (ebox--layout-children current)) + (collect child)) + (push current leaves)))) + (collect node)) + (nreverse leaves))) + +(defun ebox--render-stack-window-leaf-lines (leaf limit target-width) + "Render LEAF up to LIMIT lines for a stack scroll window. +TARGET-WIDTH is the containing block width used to pad narrower leaves." + (if (ebox--prewarm-node-window-line leaf target-width) + (list :lines nil :line-count 0 :line-tail nil :complete nil) + (let* ((window (ebox--render-node-window-lines leaf limit)) + (window-lines (plist-get window :lines)) + (rendered (unless window + (ebox--render-with-cache leaf))) + (width (cond + ((plist-member window :pixel-width) + (plist-get window :pixel-width)) + ((and window (> target-width 0)) + target-width) + ((eq (and (listp leaf) (plist-get leaf :ebox-type)) + 'box) + (ebox--total-pixel leaf)) + (t + (ebox--string-pixel-width rendered)))) + (needs-padding (and (> target-width 0) (< width target-width))) + (lines + (cond + ((and window (not needs-padding)) + window-lines) + (window + (ebox-string-lines + (ebox--width-pad + (ebox-lines-join window-lines) + (- target-width width)))) + (needs-padding + (ebox-string-lines + (ebox--width-pad rendered (- target-width width)))) + (t + (ebox-string-lines rendered))))) + (list :lines lines + :line-count (or (and window (not needs-padding) + (plist-get window :line-count)) + (length lines)) + :line-tail (or (and window (not needs-padding) + (plist-get window :line-tail)) + (last lines)) + :complete (if window + (plist-get window :complete) + t) + :prefix-delta + (and window (not needs-padding) + (plist-get window :prefix-delta)) + :completion-prefix-tail-rewrite-count + (and window + (plist-get + window :completion-prefix-tail-rewrite-count)) + :completion-prefix-tail-rewrite-known-p + (and window + (plist-member + window :completion-prefix-tail-rewrite-count)))))) + +(defun ebox--stack-window-cache-compatible-p + (cache node target-width) + "Return non-nil when CACHE can extend NODE at TARGET-WIDTH." + (and cache + (eq (plist-get cache :node) node) + (equal (plist-get cache :target-width) target-width))) + +(defun ebox--stack-window-cache-put + (node target-width leaves lines line-count line-tail index pending + partial-index partial-count complete) + "Return a stack scroll-window cache plist." + (list :node node + :target-width target-width + :leaves leaves + :lines lines + :line-count line-count + :line-tail line-tail + :index index + :pending-lines pending + :partial-index partial-index + :partial-count partial-count + :complete complete)) + +(defun ebox--line-list-append! (lines tail new-lines) + "Append NEW-LINES to LINES through TAIL and return (LINES TAIL). +The existing prefix cons cells stay stable so lazy consumers can retain a +cursor at their published boundary." + (if (null new-lines) + (list lines tail) + (let ((new-tail (last new-lines))) + (if tail + (setcdr tail new-lines) + (setq lines new-lines)) + (list lines new-tail)))) + +(defun ebox--line-list-rewrite! (lines start replacement) + "Replace LINES at zero-based START with equal-length REPLACEMENT in place." + (let ((cursor (nthcdr start lines))) + (dolist (line replacement) + (unless cursor + (error "Prefix rewrite exceeds cached line list")) + (setcar cursor line) + (setq cursor (cdr cursor)))) + lines) + +(defun ebox--line-list-rewrite-range! + (lines line-count tail range replacement) + "Apply equal-length REPLACEMENT to RANGE in LINES. +The common one-line tail completion uses TAIL directly; other ranges retain a +conservative indexed fallback." + (unless (= (length replacement) (- (cdr range) (car range))) + (error "Prefix rewrite payload does not match its range")) + (if (and (= (length replacement) 1) + (= (car range) (1- line-count)) + (= (cdr range) line-count) + tail) + (setcar tail (car replacement)) + (ebox--line-list-rewrite! lines (car range) replacement)) + lines) + +(defun ebox--prefix-delta-valid-p (delta base-count line-count) + "Return non-nil when DELTA exactly extends BASE-COUNT to LINE-COUNT. +DELTA uses zero-based, half-open line coordinates. Its optional rewrite +range must stay inside the reused prefix; lines after BASE-COUNT are append +only." + (and (plist-get delta :exact-p) + (integerp (plist-get delta :base-count)) + (= (plist-get delta :base-count) base-count) + (<= 0 base-count line-count) + (let ((range (plist-get delta :rewrite-range))) + (or (null range) + (and (consp range) + (integerp (car range)) + (integerp (cdr range)) + (<= 0 (car range)) + (< (car range) (cdr range)) + (<= (cdr range) base-count)))))) + +(defun ebox--prefix-delta-project + (delta base-count full-line-count line-count) + "Project exact DELTA onto an exposed BASE-COUNT line prefix. +FULL-LINE-COUNT is the complete line list described by DELTA, while LINE-COUNT +is the newly exposed prefix length. Lazy sources may cache extra wrapper +chrome or prefetched lines beyond the prefix published to scroll state." + (let* ((delta-base (plist-get delta :base-count)) + (range (plist-get delta :rewrite-range)) + (projected-range + (and range + (< (car range) base-count) + (cons (car range) + (min (cdr range) base-count))))) + (when (and (integerp delta-base) + (ebox--prefix-delta-payload-valid-p + delta delta-base full-line-count) + (<= 0 base-count delta-base) + (<= base-count line-count full-line-count)) + (list :exact-p t + :base-count base-count + :rewrite-range projected-range + :rewrite-lines + (and projected-range + (seq-take + (plist-get delta :rewrite-lines) + (- (cdr projected-range) + (car projected-range)))))))) + +(defun ebox--prefix-delta-payload-valid-p (delta base-count line-count) + "Return non-nil when DELTA has exact rewrite and append payloads." + (and (ebox--prefix-delta-valid-p delta base-count line-count) + (let ((range (plist-get delta :rewrite-range)) + (rewrite-lines (plist-get delta :rewrite-lines)) + (append-lines (plist-get delta :append-lines))) + (and (= (length rewrite-lines) + (if range (- (cdr range) (car range)) 0)) + (= (length append-lines) + (- line-count base-count)))))) + +(defun ebox--render-stack-window-lines (node limit &optional cache) + "Render stack NODE until LIMIT content lines are available. +Return a plist with :lines and :complete. This mirrors the normal stack +renderer for the top window of a scroll container, but stops before hidden +leaves are rendered. When CACHE is supplied, rendering continues from the +last cached leaf instead of walking the stack from the beginning." + (let* ((target-w (or (ebox--viewport-pixel-width nil) 0)) + (cached (and (ebox--stack-window-cache-compatible-p + cache node target-w) + cache)) + (leaves (or (plist-get cached :leaves) + (ebox--stack-leaves node))) + (lines (plist-get cached :lines)) + (count (or (plist-get cached :line-count) + (length lines))) + (line-tail (or (plist-get cached :line-tail) + (last lines))) + (index (or (plist-get cached :index) 0)) + (pending (copy-sequence (plist-get cached :pending-lines))) + (partial-index (plist-get cached :partial-index)) + (partial-count (or (plist-get cached :partial-count) 0)) + (complete (plist-get cached :complete)) + (base-count count) + (delta-exact-p (and cached (>= limit base-count))) + invalid-prefix-cache-p + rewrite-range + rewrite-lines + appended-lines + appended-tail + (ebox--render-cache-allow-viewport-dependent t)) + (unless (and cached complete) + (setq complete nil) + (catch 'limit + (while (< count limit) + (cond + (pending + (let* ((need (- limit count)) + (chunk (seq-take pending need))) + (pcase-let ((`(,new-lines ,new-tail) + (ebox--line-list-append! + appended-lines appended-tail + (copy-sequence chunk)))) + (setq appended-lines new-lines + appended-tail new-tail)) + (setq count (+ count (length chunk))) + (setq pending (nthcdr (length chunk) pending)) + (when pending + (throw 'limit nil)))) + ((>= index (length leaves)) + (setq complete t) + (throw 'limit nil)) + (t + (let* ((leaf (nth index leaves)) + (need (max 1 (- limit count))) + (leaf-limit (if (equal partial-index index) + (+ partial-count need) + need)) + (window + (ebox--render-stack-window-leaf-lines + leaf leaf-limit target-w)) + (leaf-lines (plist-get window :lines)) + (leaf-count (or (plist-get window :line-count) + (length leaf-lines))) + (leaf-complete (plist-get window :complete)) + (leaf-delta (plist-get window :prefix-delta)) + (leaf-prefix-exact-p + (and (equal partial-index index) + (ebox--prefix-delta-payload-valid-p + leaf-delta partial-count leaf-count))) + (completion-tail-rewrite-metadata + (plist-get + window :completion-prefix-tail-rewrite-count)) + (completion-tail-rewrite-known-p + (plist-get + window :completion-prefix-tail-rewrite-known-p)) + (completion-tail-rewrite-metadata-valid-p + (and + completion-tail-rewrite-known-p + (or (null completion-tail-rewrite-metadata) + (and (integerp + completion-tail-rewrite-metadata) + (>= completion-tail-rewrite-metadata 0))))) + (completion-tail-rewrite-count + (if completion-tail-rewrite-metadata-valid-p + (or completion-tail-rewrite-metadata 0) + 0)) + (completion-tail-rewrite-leaf-range + (and (> completion-tail-rewrite-count 0) + (>= partial-count + completion-tail-rewrite-count) + (cons + (- partial-count + completion-tail-rewrite-count) + partial-count))) + (completion-tail-rewrite-from-delta-p + (and leaf-prefix-exact-p + (equal + (plist-get leaf-delta :rewrite-range) + completion-tail-rewrite-leaf-range))) + (new-lines + (cond + (leaf-prefix-exact-p + (plist-get leaf-delta :append-lines)) + ((equal partial-index index) + (nthcdr partial-count leaf-lines)) + (t leaf-lines))) + (chunk (seq-take new-lines need)) + (chunk-count (length chunk))) + ;; A producer may explicitly declare completion-only rewrites + ;; to the tail already stored for a partial leaf. Flex uses one + ;; line for a wrapper's bottom border. The partial leaf is + ;; always the cache tail, so reconcile only that declared suffix + ;; before appending any newly produced lines. + (when (and (equal partial-index index) + (> partial-count 0) + leaf-complete + (or (not + completion-tail-rewrite-metadata-valid-p) + (> completion-tail-rewrite-count 0))) + (if (and completion-tail-rewrite-metadata-valid-p + (> partial-count 0) + (>= leaf-count partial-count) + (>= count completion-tail-rewrite-count) + (>= partial-count completion-tail-rewrite-count)) + (progn + (if rewrite-range + (setq delta-exact-p nil) + (setq rewrite-range + (cons + (- count completion-tail-rewrite-count) + count))) + (setq rewrite-lines + (if completion-tail-rewrite-from-delta-p + (plist-get leaf-delta :rewrite-lines) + (seq-subseq + leaf-lines + (- partial-count + completion-tail-rewrite-count) + partial-count)))) + ;; A producer that declares an impossible rewrite cannot + ;; safely reuse any cached prefix: the cached strings may + ;; already be stale even if downstream consumers rebuild. + (setq invalid-prefix-cache-p t) + (throw 'limit nil))) + (pcase-let ((`(,new-lines ,new-tail) + (ebox--line-list-append! + appended-lines appended-tail + (copy-sequence chunk)))) + (setq appended-lines new-lines + appended-tail new-tail)) + (setq count (+ count chunk-count)) + (cond + ((and leaf-complete + (<= (length new-lines) need)) + (setq index (1+ index)) + (setq partial-index nil) + (setq partial-count 0) + (when (> (length new-lines) chunk-count) + (setq pending (nthcdr chunk-count new-lines)) + (throw 'limit nil))) + (leaf-complete + (setq index (1+ index)) + (setq partial-index nil) + (setq partial-count 0) + (setq pending (nthcdr chunk-count new-lines)) + (throw 'limit nil)) + (t + (setq partial-index index) + (setq partial-count (+ partial-count chunk-count)) + (throw 'limit nil))))))))) + (if invalid-prefix-cache-p + (ebox--render-stack-window-lines node limit nil) + (let ((inhibit-quit t)) + (when rewrite-range + (ebox--line-list-rewrite-range! + lines base-count line-tail rewrite-range rewrite-lines)) + (pcase-let ((`(,new-lines ,new-tail) + (ebox--line-list-append! + lines line-tail appended-lines))) + (setq lines new-lines + line-tail new-tail))) + (let* ((result-lines (if (<= count limit) + lines + (seq-take lines limit))) + (result-count (min count limit)) + (cache + (ebox--stack-window-cache-put + node target-w leaves lines count line-tail index pending + partial-index partial-count complete))) + (list :lines result-lines + :line-count result-count + :line-tail (if (= result-count count) + line-tail + (last result-lines)) + :complete complete + :cache cache + :prefix-delta + (list :exact-p + (and delta-exact-p + (<= base-count result-count)) + :base-count base-count + :rewrite-range rewrite-range + :rewrite-lines rewrite-lines + :append-lines appended-lines)))))) + +(defun ebox--scroll-window-content-height (box) + "Return BOX's definite scroll content height, or nil." + (when-let ((fixed-height + (ebox--resolve-size-content-height + box (ebox-get box :height) nil))) + (ebox--content-height box fixed-height))) + +(defun ebox--scroll-window-materializer + (box viewport-width viewport-height) + "Return a function that materializes BOX scroll content." + (lambda (&rest _) + (let ((ebox-viewport-width viewport-width) + (ebox-viewport-height viewport-height) + (ebox--scroll-window-render-disabled t) + (ebox--scroll-window-render-result nil)) + (let* ((full (ebox--format-content box)) + (lines (ebox-string-lines full))) + (list :content-lines lines + :content-height (ebox--content-height box (length lines)) + :box box))))) + +(defun ebox--scroll-window-producer-spec + (prefix-viewport-width materialize-viewport-width viewport-height) + "Return portable dimensions for one lazy scroll producer pair." + (list :prefix-viewport-width prefix-viewport-width + :materialize-viewport-width materialize-viewport-width + :viewport-height viewport-height)) + +(defun ebox--scroll-window-prefix-renderer + (box source viewport-width viewport-height + &optional initial-source-cache initial-content-lines) + "Return a function that renders BOX scroll content up to a line limit." + (let* ((source-cache initial-source-cache) + (content-lines-cache initial-content-lines) + (content-lines-count (length initial-content-lines)) + (content-lines-tail (last initial-content-lines)) + (source-lines-head + (plist-get initial-source-cache :rendered-lines)) + (source-lines-boundary-tail + (and (> content-lines-count 0) + (nthcdr (1- content-lines-count) source-lines-head))) + (rendered-content-lines-cache nil) + (rendered-content-lines-count 0) + (rendered-content-lines-tail nil) + (signature-cache (make-hash-table :test 'eq)) + ;; The lazy prefix belongs to one validated display signature. Keep + ;; its render-local measurement caches across idle slices so repeated + ;; card chrome and labels are not sent back through GUI font + ;; measurement on every callback. + (pixel-width-cache + (or ebox--render-string-pixel-width-cache + (make-hash-table :test 'equal))) + (max-pixel-width-cache + (or ebox--render-string-max-pixel-width-cache + (make-hash-table :test 'eq))) + (recached-source-node-cache + (or ebox--render-recached-source-node-cache + (make-hash-table :test 'eq))) + (prewarm-state (make-hash-table :test 'eq)) + (source-revision ebox--render-runtime-revision) + (complete-cache nil)) + (lambda (state region-id limit) + (let ((prefix-dirty + (or (plist-get state :lazy-scroll-prefix-dirty) + (not (equal source-revision + ebox--render-runtime-revision))))) + (when prefix-dirty + (setq source-cache nil) + (setq content-lines-cache nil) + (setq content-lines-count 0) + (setq content-lines-tail nil) + (setq source-lines-head nil) + (setq source-lines-boundary-tail nil) + (setq rendered-content-lines-cache nil) + (setq rendered-content-lines-count 0) + (setq rendered-content-lines-tail nil) + (setq signature-cache (make-hash-table :test 'eq)) + (clrhash pixel-width-cache) + (clrhash max-pixel-width-cache) + (clrhash recached-source-node-cache) + (clrhash prewarm-state) + (setq complete-cache nil) + (setq source-revision ebox--render-runtime-revision)) + (let ((ebox-viewport-width viewport-width) + (ebox-viewport-height viewport-height) + (ebox--render-cache-signature-cache signature-cache) + (ebox--render-string-pixel-width-cache pixel-width-cache) + (ebox--render-string-max-pixel-width-cache + max-pixel-width-cache) + (ebox--render-recached-source-node-cache + recached-source-node-cache) + (ebox--scroll-window-prewarm-state prewarm-state) + (ebox--scroll-window-render-disabled t) + (ebox--scroll-window-render-result nil)) + (unless rendered-content-lines-cache + (unless prefix-dirty + (setq rendered-content-lines-cache + (plist-get state :rendered-content-lines)) + (setq rendered-content-lines-count + (length rendered-content-lines-cache)) + (setq rendered-content-lines-tail + (last rendered-content-lines-cache)))) + (let* ((limit (max 1 limit)) + (content-height (ebox--scroll-window-content-height box)) + (old-count content-lines-count) + (cached-hit + (and content-lines-cache + (or complete-cache + (>= content-lines-count limit))))) + ;; The producer may have returned a bounded private chunk beyond + ;; the published scroll state. Paint that small suffix before a + ;; source extension so the exact delta can append instead of + ;; rebuilding every rendered line from zero. + (when (and (not cached-hit) + rendered-content-lines-cache + (< rendered-content-lines-count content-lines-count)) + (pcase-let + ((`(,lines ,count ,tail) + (ebox--scroll-rendered-prefix-through-content + box content-lines-cache content-lines-count region-id + rendered-content-lines-cache + rendered-content-lines-count + rendered-content-lines-tail))) + (setq rendered-content-lines-cache lines + rendered-content-lines-count count + rendered-content-lines-tail tail))) + (unless cached-hit + (condition-case err + (let* ((window (ebox--render-scroll-window-source + source limit source-cache)) + (next-source-cache (plist-get window :cache)) + (next-complete-cache (plist-get window :complete)) + (source-lines-exact-p + (ebox--scroll-window-source-lines-exact-p + box window)) + (formatted-lines + (ebox--scroll-window-formatted-lines box window)) + (full-line-count + (if source-lines-exact-p + (or (plist-get window :line-count) + (length formatted-lines)) + (length formatted-lines))) + (new-count + (if (plist-get window :complete) + full-line-count + (min full-line-count limit))) + (content-prefix-delta + (and source-lines-exact-p + (eq formatted-lines source-lines-head) + (ebox--prefix-delta-project + (plist-get window :prefix-delta) + old-count full-line-count new-count))) + (append-count (- new-count old-count)) + (source-append-start + (if source-lines-boundary-tail + (cdr source-lines-boundary-tail) + source-lines-head)) + (candidate-appended-lines + (and content-prefix-delta + (>= append-count 0) + (seq-take source-append-start append-count))) + (exact-extension-p + (and content-prefix-delta + (= (length candidate-appended-lines) + append-count) + rendered-content-lines-cache + (= rendered-content-lines-count old-count)))) + (if exact-extension-p + (let* ((rewrite-range + (plist-get content-prefix-delta + :rewrite-range)) + (rewrite-lines + (plist-get content-prefix-delta + :rewrite-lines)) + (rendered-rewrite-lines + (and rewrite-range + (ebox--scroll-rendered-content-lines + box rewrite-lines region-id + (car rewrite-range)))) + (rendered-append-lines + (ebox--scroll-rendered-content-lines + box candidate-appended-lines region-id + old-count))) + ;; All rendering that can signal completes before + ;; published content/rendered lists are mutated. + (unless (and (= (length rendered-rewrite-lines) + (length rewrite-lines)) + (= (length rendered-append-lines) + append-count)) + (error + "Prefix renderer returned inconsistent delta")) + (let ((inhibit-quit t)) + (when rewrite-range + (ebox--line-list-rewrite-range! + content-lines-cache old-count + content-lines-tail rewrite-range + rewrite-lines)) + (pcase-let ((`(,new-lines ,new-tail) + (ebox--line-list-append! + content-lines-cache + content-lines-tail + candidate-appended-lines))) + (setq content-lines-cache new-lines + content-lines-tail new-tail)) + (when rewrite-range + (ebox--line-list-rewrite-range! + rendered-content-lines-cache old-count + rendered-content-lines-tail rewrite-range + rendered-rewrite-lines)) + (pcase-let ((`(,new-lines ,new-tail) + (ebox--line-list-append! + rendered-content-lines-cache + rendered-content-lines-tail + rendered-append-lines))) + (setq rendered-content-lines-cache new-lines + rendered-content-lines-tail new-tail)) + (setq content-lines-count new-count) + (setq rendered-content-lines-count new-count) + (when (> append-count 0) + (setq source-lines-boundary-tail + (nthcdr (1- append-count) + source-append-start))))) + (let* ((new-content-lines + (seq-take formatted-lines new-count)) + (new-rendered-lines + (ebox--scroll-rendered-content-lines + box new-content-lines region-id 0))) + ;; Commit the rebuilt pair only after rendering succeeds. + (setq content-lines-cache new-content-lines + content-lines-count new-count + content-lines-tail (last new-content-lines) + rendered-content-lines-cache new-rendered-lines + rendered-content-lines-count new-count + rendered-content-lines-tail + (last new-rendered-lines)) + (if source-lines-exact-p + (setq source-lines-head formatted-lines + source-lines-boundary-tail + (and (> new-count 0) + (nthcdr (1- new-count) + formatted-lines))) + (setq source-lines-head nil + source-lines-boundary-tail nil)))) + (setq source-cache next-source-cache + complete-cache next-complete-cache)) + ((error quit) + ;; A producer may have extended private stack/flex caches in + ;; place before a later wrapper or face renderer signalled. + ;; Published content remains intact; discard every private + ;; continuation so the next attempt starts from that prefix. + (setq source-cache nil + source-lines-head nil + source-lines-boundary-tail nil + complete-cache nil + signature-cache (make-hash-table :test 'eq)) + (when (hash-table-p ebox--render-cache-table) + (clrhash ebox--render-cache-table)) + (signal (car err) (cdr err))))) + (let* ((published-count + (if complete-cache + content-lines-count + (min content-lines-count limit))) + (content-lines + (if (= published-count content-lines-count) + content-lines-cache + (seq-take content-lines-cache published-count)))) + (when (< rendered-content-lines-count published-count) + (pcase-let + ((`(,lines ,count ,tail) + (ebox--scroll-rendered-prefix-through-content + box content-lines-cache content-lines-count region-id + rendered-content-lines-cache + rendered-content-lines-count + rendered-content-lines-tail))) + (setq rendered-content-lines-cache lines + rendered-content-lines-count count + rendered-content-lines-tail tail))) + (list :content-lines + content-lines + :content-height content-height + :rendered-content-lines + (if (= published-count rendered-content-lines-count) + rendered-content-lines-cache + (seq-take rendered-content-lines-cache + published-count)) + :complete complete-cache + :box box)))))))) + +(defun ebox--scroll-window-stack-source (node) + "Return lazy scroll stack source metadata for NODE, or nil." + (cond + ((eq (plist-get node :ebox-type) 'stack) + (list :stack node)) + ((and (eq (plist-get node :ebox-type) 'box) + (eq (plist-get (plist-get node :ebox-content-node) + :ebox-type) + 'stack)) + (list :stack (plist-get node :ebox-content-node) + :wrapper node)))) + +(defun ebox--scroll-window-rebind-state-producers (state box) + "Rebind lazy scroll producers in STATE to candidate runtime BOX. +The stored producer descriptor carries only viewport dimensions; source nodes +are always resolved from BOX so no closure can retain a replaced runtime." + (when (or (plist-get state :render-content-prefix) + (plist-get state :materialize-content-lines)) + (let* ((spec (plist-get state :scroll-window-producer-spec)) + (prefix-viewport-width + (plist-get spec :prefix-viewport-width)) + (materialize-viewport-width + (plist-get spec :materialize-viewport-width)) + (viewport-height (plist-get spec :viewport-height)) + (source + (ebox--scroll-window-stack-source + (ebox--box-content-node box)))) + (unless (and (numberp prefix-viewport-width) + (> prefix-viewport-width 0) + (numberp materialize-viewport-width) + (> materialize-viewport-width 0) + (numberp viewport-height) + (> viewport-height 0)) + (error "Ebox lazy scroll state lacks a portable producer descriptor")) + (if source + (progn + (when (plist-get state :render-content-prefix) + (setq state + (plist-put + state :render-content-prefix + (ebox--scroll-window-prefix-renderer + box source prefix-viewport-width viewport-height)))) + (when (plist-get state :materialize-content-lines) + (setq state + (plist-put + state :materialize-content-lines + (ebox--scroll-window-materializer + box materialize-viewport-width viewport-height))))) + ;; A changed candidate may no longer have a lazy source. Its dirty + ;; render will install the new state; never carry old-tree closures + ;; across that preparation boundary. + (setq state (plist-put state :render-content-prefix nil)) + (setq state (plist-put state :materialize-content-lines nil))))) + state) + +(defun ebox--scroll-window-source-lines-exact-p (box window) + "Return non-nil when WINDOW lines can be used as BOX formatted content." + (and (plist-get window :content-width-exact-p) + (null (ebox-get box :wrap-mode)) + (eq (ebox-get box :text-align) 'left) + (= (ebox--content-pixel box) + (or (ebox--viewport-pixel-width nil) 0)))) + +(defun ebox--scroll-window-formatted-lines (box window) + "Return formatted scroll content lines for BOX from WINDOW." + (if (ebox--scroll-window-source-lines-exact-p box window) + (plist-get window :lines) + (ebox-string-lines + (ebox--format-content-string + box + (or (plist-get window :rendered) + (ebox-lines-join (plist-get window :rendered-lines))))))) + +(defun ebox--scroll-window-wrapper-supported-p (box) + "Return non-nil when BOX can wrap an incomplete lazy scroll prefix." + (or (null box) + (and (null (ebox-get box :height)) + (or (null (ebox-get box :min-height)) + (equal (ebox-get box :min-height) 0)) + (null (ebox-get box :max-height)) + (null (ebox-get box :wrap-mode)) + (eq (ebox-get box :text-align) 'left) + (eq (ebox-get box :vertical-align) 'top)))) + +(defun ebox--scroll-window-render-wrapper-full + (wrapper content content-width-exact-p) + "Render WRAPPER around CONTENT through the normal box renderer." + (let* ((copy (copy-sequence wrapper)) + (region-id (ebox--ensure-region-id wrapper))) + (plist-put copy :region-id region-id) + (plist-put copy :content content) + (plist-put copy :ebox-content-node nil) + (plist-put copy :ebox-content-width-exact-p content-width-exact-p) + (prog1 (ebox-render copy) + (puthash region-id wrapper ebox--region-box-table)))) + +(defun ebox--window-render-flat-wrapper-chunk-lines + (box content-lines start-index region-id padding-line-filler + &optional include-top include-bottom) + "Render a marginless BOX wrapper around CONTENT-LINES in one pass. +START-INDEX and REGION-ID identify the wrapped content lines. INCLUDE-TOP +adds top padding and border chrome. INCLUDE-BOTTOM adds bottom padding and +border chrome. The caller guarantees that BOX has no horizontal margins and +no included vertical margin." + (let* ((padding-left (ebox-get box :padding-left-pixel)) + (padding-right (ebox-get box :padding-right-pixel)) + (padding-top (if include-top + (floor (ebox-get box :padding-top-height)) + 0)) + (padding-bottom (if include-bottom + (floor (ebox-get box :padding-bottom-height)) + 0)) + (border-left-pixel (ebox-get box :border-left-pixel)) + (border-right-pixel (ebox-get box :border-right-pixel)) + (border-top-p (and include-top (ebox-get box :border-top-p))) + (border-top-color (ebox-get box :border-top-color)) + (border-bottom-p + (and include-bottom (ebox-get box :border-bottom-p))) + (border-bottom-color (ebox-get box :border-bottom-color)) + (color (ebox-get box :color)) + (bgcolor (ebox-get box :bgcolor)) + (padding-left-line + (when (> padding-left 0) + (let ((line + (ebox--propertize-region + (ebox--pixel-blank padding-left 1) + 'ebox-pl region-id))) + (if (or color bgcolor) + (ebox--propertize-colors line color bgcolor) + line)))) + (padding-right-line + (when (> padding-right 0) + (let ((line + (ebox--propertize-region + (ebox--pixel-blank padding-right 1) + 'ebox-pr region-id))) + (if (or color bgcolor) + (ebox--propertize-colors line color bgcolor) + line)))) + (border-left-line + (when (> border-left-pixel 0) + (ebox--propertize-region + (ebox--pixel-border border-left-pixel 1 + (ebox-get box :border-left-color)) + 'ebox-bl region-id))) + (border-right-line + (when (> border-right-pixel 0) + (ebox--propertize-region + (ebox--pixel-border border-right-pixel 1 + (ebox-get box :border-right-color)) + 'ebox-br region-id)))) + (let* ((top-core-line + (when (> padding-top 0) + (let ((line + (ebox--propertize-region + padding-line-filler 'ebox-pt region-id))) + (if (or color bgcolor) + (ebox--propertize-colors line color bgcolor) + line)))) + (top-lines + (when top-core-line + (cl-loop repeat padding-top + collect + (concat border-left-line padding-left-line + top-core-line padding-right-line + border-right-line)))) + (bottom-core-line + (when (> padding-bottom 0) + (let ((line + (ebox--propertize-region + padding-line-filler 'ebox-pb region-id))) + (if (or color bgcolor) + (ebox--propertize-colors line color bgcolor) + line)))) + (bottom-lines + (when bottom-core-line + (cl-loop repeat padding-bottom + collect + (concat border-left-line padding-left-line + bottom-core-line padding-right-line + border-right-line)))) + (wrapped-lines + (cl-loop + for source-line in content-lines + for idx from start-index + collect + (let* ((blank-replacement-p + (and (string-blank-p source-line) + (not (ebox--line-has-non-content-properties-p + source-line)))) + (line + (if blank-replacement-p + (let ((content-id + (get-text-property + 0 'ebox-content source-line)) + (content-idx + (get-text-property + 0 'ebox-content-idx source-line))) + (if content-id + (propertize padding-line-filler + 'ebox-content content-id + 'ebox-content-idx content-idx + 'ebox-content-owner region-id) + (propertize padding-line-filler + 'ebox-content region-id + 'ebox-content-idx idx + 'ebox-content-owner region-id))) + (let* ((colored-p (or color bgcolor)) + (owned-source + (if colored-p + (ebox--propertize-colors + source-line color bgcolor) + source-line))) + (let ((ebox--propertize-private-content-line-p + colored-p)) + (ebox--propertize-content-line + owned-source region-id idx + padding-line-filler)))))) + (when (and blank-replacement-p (or color bgcolor)) + (setq line (ebox--propertize-colors line color bgcolor))) + (concat border-left-line padding-left-line line + padding-right-line border-right-line)))) + (lines (nconc top-lines wrapped-lines bottom-lines))) + (when (and border-top-p lines) + (setcar lines + (propertize + (ebox--propertize-overline (car lines) border-top-color) + 'ebox-bt region-id))) + (when (and border-bottom-p lines) + (let ((tail (last lines))) + (setcar tail + (propertize + (ebox--propertize-underline + (car tail) border-bottom-color) + 'ebox-bb region-id)))) + (when-let ((properties (ebox-get box :surface-properties))) + (setq lines + (mapcar (lambda (line) + (ebox--apply-surface-properties line properties)) + lines))) + lines))) + +(defun ebox--scroll-window-render-wrapper-chunk-lines + (box content-lines start-index include-top) + "Render BOX wrapper chrome around newly appended CONTENT-LINES. +START-INDEX is the first content-line index inside BOX. INCLUDE-TOP means the +chunk begins at the wrapper top and should include top padding, margin, and +border chrome. Bottom chrome is intentionally omitted because this helper is +only used for incomplete lazy prefixes." + (if (not box) + content-lines + (let* ((region-id (ebox--ensure-region-id box)) + (content-pixel (ebox--content-pixel box)) + (padding-line-filler (ebox-pixel-space content-pixel)) + (padding-left (ebox-get box :padding-left-pixel)) + (padding-right (ebox-get box :padding-right-pixel)) + (padding-top (if include-top + (floor (ebox-get box :padding-top-height)) + 0)) + (margin-left (ebox-get box :margin-left-pixel)) + (margin-right (ebox-get box :margin-right-pixel)) + (margin-top (if include-top + (floor (ebox-get box :margin-top-height)) + 0)) + (border-left-pixel (ebox-get box :border-left-pixel)) + (border-left-color (ebox-get box :border-left-color)) + (border-right-pixel (ebox-get box :border-right-pixel)) + (border-right-color (ebox-get box :border-right-color)) + (border-top-p (and include-top + (ebox-get box :border-top-p))) + (border-top-color (ebox-get box :border-top-color)) + (color (ebox-get box :color)) + (bgcolor (ebox-get box :bgcolor)) + (inner-height (+ (length content-lines) padding-top)) + result) + (puthash region-id box ebox--region-box-table) + (if (and (= margin-left 0) + (= margin-right 0) + (= margin-top 0)) + (ebox--window-render-flat-wrapper-chunk-lines + box content-lines start-index region-id padding-line-filler + include-top) + (progn + (setq result + (ebox-lines-join + (cl-loop for line in content-lines + for idx from start-index + collect (ebox--propertize-content-line + line region-id idx + padding-line-filler)))) + (setq result + (ebox--maplines + (lambda (line) + (if (and + (string-empty-p (string-trim line)) + (not (ebox--line-has-non-content-properties-p line))) + (let ((content-id + (get-text-property 0 'ebox-content line)) + (content-idx + (get-text-property 0 'ebox-content-idx line))) + (if content-id + (propertize padding-line-filler + 'ebox-content content-id + 'ebox-content-idx content-idx + 'ebox-content-owner region-id) + (propertize padding-line-filler + 'ebox-content region-id + 'ebox-content-idx 0 + 'ebox-content-owner region-id))) + line)) + result)) + (when (> padding-top 0) + (setq result + (ebox--lines-stack-vertical + (ebox--propertize-region + (ebox--pixel-blank content-pixel padding-top) + 'ebox-pt region-id) + result))) + (let ((pl-str (when (> padding-left 0) + (ebox--propertize-region + (ebox--pixel-blank padding-left inner-height) + 'ebox-pl region-id))) + (pr-str (when (> padding-right 0) + (ebox--propertize-region + (ebox--pixel-blank padding-right inner-height) + 'ebox-pr region-id)))) + (when (or pl-str pr-str) + (setq result + (ebox--lines-concat-horizontal pl-str result pr-str)))) + (when (or color bgcolor) + (setq result + (ebox--maplines + (lambda (line) + (ebox--propertize-colors line color bgcolor)) + result))) + (when (or (> border-left-pixel 0) (> border-right-pixel 0)) + (let ((bl-str (when (> border-left-pixel 0) + (ebox--propertize-region + (ebox--pixel-border border-left-pixel + inner-height + border-left-color) + 'ebox-bl region-id))) + (br-str (when (> border-right-pixel 0) + (ebox--propertize-region + (ebox--pixel-border border-right-pixel + inner-height + border-right-color) + 'ebox-br region-id)))) + (setq result + (ebox--lines-concat-horizontal bl-str result br-str)))) + (when border-top-p + (let ((lines (ebox-string-lines result))) + (setf (car lines) + (propertize + (ebox--propertize-overline (car lines) border-top-color) + 'ebox-bt region-id)) + (setq result (ebox-lines-join lines)))) + (ebox--apply-surface-properties + result (ebox-get box :surface-properties)) + (when (or (> margin-left 0) (> margin-right 0)) + (let* ((box-height (ebox-string-height result)) + (ml-str (when (> margin-left 0) + (ebox--propertize-region + (ebox--pixel-blank margin-left box-height) + 'ebox-ml region-id))) + (mr-str (when (> margin-right 0) + (ebox--propertize-region + (ebox--pixel-blank margin-right box-height) + 'ebox-mr region-id)))) + (setq result + (ebox--lines-concat-horizontal ml-str result mr-str)))) + (when (> margin-top 0) + (setq result + (ebox--lines-stack-vertical + (ebox--propertize-region + (ebox--pixel-blank (ebox--total-pixel box) margin-top) + 'ebox-mt region-id) + result))) + (ebox-string-lines result)))))) + +(defun ebox--render-scroll-window-source (source limit &optional cache) + "Render lazy scroll SOURCE until LIMIT parent content lines are available." + (let* ((stack (plist-get source :stack)) + (wrapper (plist-get source :wrapper)) + (cached-source-lines (plist-get cache :source-lines)) + (cached-count (or (plist-get cache :source-count) + (length cached-source-lines))) + (cached-rendered-lines (plist-get cache :rendered-lines)) + (cached-rendered-count + (or (plist-get cache :rendered-count) + (length cached-rendered-lines))) + (cached-rendered-tail + (or (plist-get cache :rendered-tail) + (last cached-rendered-lines))) + (content-viewport (and wrapper + (ebox--wrapper-content-viewport-pixel + wrapper))) + (window (let ((ebox-viewport-width + (or content-viewport ebox-viewport-width))) + (ebox--render-stack-window-lines + stack limit (plist-get cache :stack)))) + (source-lines (plist-get window :lines)) + (source-count (or (plist-get window :line-count) + (length source-lines))) + (complete (plist-get window :complete)) + (stack-delta (plist-get window :prefix-delta)) + (stack-prefix-exact-p + (and (eq source-lines cached-source-lines) + (ebox--prefix-delta-payload-valid-p + stack-delta cached-count source-count))) + (rewrite-range + (and stack-prefix-exact-p + (plist-get stack-delta :rewrite-range))) + (wrapper-prefix-count + (- cached-rendered-count cached-count)) + (wrapper-reuse-p + (and wrapper + (not complete) + stack-prefix-exact-p + (ebox--scroll-window-wrapper-supported-p wrapper) + cached-source-lines + cached-rendered-lines + (>= wrapper-prefix-count 0))) + (mapped-rewrite-range + (and wrapper-reuse-p rewrite-range + (if (= (car rewrite-range) 0) + (cons 0 (+ wrapper-prefix-count + (cdr rewrite-range))) + (cons (+ wrapper-prefix-count + (car rewrite-range)) + (+ wrapper-prefix-count + (cdr rewrite-range)))))) + (rewritten-lines + (and wrapper-reuse-p rewrite-range + (ebox--scroll-window-render-wrapper-chunk-lines + wrapper + (plist-get stack-delta :rewrite-lines) + (car rewrite-range) + (= (car rewrite-range) 0)))) + (appended-source-lines + (and wrapper-reuse-p + (plist-get stack-delta :append-lines))) + (appended-lines + (and appended-source-lines + (ebox--scroll-window-render-wrapper-chunk-lines + wrapper appended-source-lines cached-count nil))) + (wrapper-prefix-exact-p + (and wrapper-reuse-p + (or (null mapped-rewrite-range) + (= (length rewritten-lines) + (- (cdr mapped-rewrite-range) + (car mapped-rewrite-range)))) + (= (length appended-lines) + (length appended-source-lines)))) + rendered-lines + rendered-count + rendered-tail + prefix-delta) + (cond + ((not wrapper) + (setq rendered-lines source-lines + rendered-count source-count + rendered-tail (plist-get window :line-tail) + prefix-delta + (if stack-prefix-exact-p + stack-delta + (list :exact-p nil :base-count cached-count + :rewrite-range nil :rewrite-lines nil + :append-lines nil)))) + (wrapper-prefix-exact-p + (let ((inhibit-quit t)) + (when mapped-rewrite-range + (ebox--line-list-rewrite-range! + cached-rendered-lines cached-rendered-count cached-rendered-tail + mapped-rewrite-range rewritten-lines)) + (pcase-let ((`(,new-lines ,new-tail) + (ebox--line-list-append! + cached-rendered-lines cached-rendered-tail + appended-lines))) + (setq rendered-lines new-lines + rendered-tail new-tail)) + (setq rendered-count + (+ cached-rendered-count (length appended-lines)))) + (setq prefix-delta + (list :exact-p t + :base-count cached-rendered-count + :rewrite-range mapped-rewrite-range + :rewrite-lines rewritten-lines + :append-lines appended-lines))) + (t + (setq rendered-lines + (if (and (not complete) + (ebox--scroll-window-wrapper-supported-p wrapper)) + (ebox--scroll-window-render-wrapper-chunk-lines + wrapper source-lines 0 t) + (ebox-string-lines + (ebox--scroll-window-render-wrapper-full + wrapper (ebox-lines-join source-lines) t)))) + (setq rendered-count (length rendered-lines) + rendered-tail (last rendered-lines) + prefix-delta + (list :exact-p nil + :base-count cached-rendered-count + :rewrite-range nil + :rewrite-lines nil + :append-lines nil)))) + (list :complete complete + :cache (list :stack (plist-get window :cache) + :source-lines source-lines + :source-count source-count + :rendered-lines rendered-lines + :rendered-count rendered-count + :rendered-tail rendered-tail) + :lines rendered-lines + :line-count rendered-count + :line-tail rendered-tail + :content-width-exact-p t + :rendered-lines rendered-lines + :prefix-delta prefix-delta))) + +(defun ebox--format-scroll-window-content (box) + "Return a lazily rendered scroll content window for BOX, or nil." + (let ((content-height (ebox--scroll-window-content-height box)) + (scroll-offset (max 0 (or (ebox-get box :scroll-offset) 0))) + (content-viewport (ebox--wrapper-content-viewport-pixel box)) + (source (ebox--scroll-window-stack-source + (ebox--box-content-node box)))) + (when (and (not ebox--scroll-window-render-disabled) + (eq (ebox-get box :overflow) 'scroll) + (ebox--viewport-height-size-value-p (ebox-get box :height)) + content-height + (> content-height 0) + (ebox--viewport-pixel-width nil) + source) + (if ebox--intrinsic-layout-measurement + (progn + (setq ebox--scroll-window-render-result + (list :content-height content-height + :measurement-placeholder t)) + (or (ebox--pixel-blank (ebox--content-pixel box) + content-height) + "")) + (let ((ebox-viewport-width + (or content-viewport ebox-viewport-width))) + (let* ((target-lines + (+ scroll-offset + content-height 1 + (ebox--scroll-window-initial-lookahead-lines))) + (window (ebox--render-scroll-window-source + source target-lines)) + (formatted-lines + (ebox--scroll-window-formatted-lines box window)) + (viewport-width ebox-viewport-width) + (viewport-height ebox-viewport-height)) + (setq ebox--scroll-window-render-result + (list :complete (plist-get window :complete) + :content-height content-height + :scroll-window-producer-spec + (ebox--scroll-window-producer-spec + viewport-width viewport-width viewport-height) + :render-content-prefix + (ebox--scroll-window-prefix-renderer + box source viewport-width viewport-height + (plist-get window :cache) + formatted-lines) + :materialize-content-lines + (ebox--scroll-window-materializer + box viewport-width viewport-height))) + (ebox-lines-join + (if (plist-get window :complete) + formatted-lines + (seq-take formatted-lines target-lines))))))))) + +(defun ebox--render-stack (node) + "Render a stack NODE to a string (internal, called by `ebox-render'). + +Stack leaves are rendered independently. When a containing-block viewport is +bound, narrower rows are padded to that available width, while wider definite +children keep their own width and overflow only on their own rows. During +intrinsic measurement, descendants still receive the containing block but the +stack itself uses its natural max child width." + (let* ((rendered-items + (mapcar (lambda (leaf) + (let ((rendered (ebox--render-with-cache leaf))) + (cons rendered (ebox--string-pixel-width rendered)))) + (ebox--stack-leaves node))) + (max-w (apply #'max (mapcar #'cdr rendered-items))) + (target-w (if ebox--intrinsic-layout-measurement + max-w + (or (ebox--viewport-pixel-width nil) max-w)))) + (let ((rendered + (apply #'ebox--lines-stack-vertical + (mapcar (lambda (item) + (let ((rendered (car item)) + (width (cdr item))) + (if (< width target-w) + (ebox--width-pad rendered (- target-w width)) + rendered))) + rendered-items)))) + ;; When no child overflows the containing block, stack padding proves + ;; every output line already has the exact target width. A wrapping box + ;; can then preserve this preformatted content without measuring every + ;; line again. Wider children deliberately keep their own overflow rows + ;; and therefore must not publish uniform-width metadata. + (if (<= max-w target-w) + (ebox--record-rendered-uniform-width rendered target-w) + rendered)))) + +(provide 'ebox-layout) + +;;; ebox-layout.el ends here diff --git a/ebox-measure.el b/ebox-measure.el new file mode 100644 index 0000000..d18782e --- /dev/null +++ b/ebox-measure.el @@ -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 diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el new file mode 100644 index 0000000..c498e22 --- /dev/null +++ b/ebox-native-reflow.el @@ -0,0 +1,3765 @@ +;;; ebox-native-reflow.el --- Optional Rust root reflow backend -*- lexical-binding: t; -*- + +;;; Commentary: +;; Private loader and bounded-session wrapper for the optional Ebox Rust +;; dynamic module. Background preparation remains root-only; eligible +;; side-effect-free owner candidates may also use the synchronous proof entry +;; without taking publication ownership. Callers must keep ordinary Elisp +;; rendering as the exact correctness fallback. + +;;; Code: + +(require 'cl-lib) +(require 'compile) +(require 'json) +(require 'subr-x) +(require 'ebox-measure) +(require 'ebox-render-context) + +(declare-function ebox-native--module-version "ebox_native_reflow" ()) +(declare-function ebox-native--module-layout-ready-p "ebox_native_reflow" ()) +(declare-function ebox-native--module-create-session "ebox_native_reflow" + (workers max-jobs max-results max-result-bytes)) +(declare-function ebox-native--module-submit "ebox_native_reflow" + (session generation payload)) +(declare-function ebox-native--module-ready-p "ebox_native_reflow" + (session generation key)) +(declare-function ebox-native--module-take "ebox_native_reflow" + (session generation key)) +(declare-function ebox-native--module-render-proof "ebox_native_reflow" + (payload)) +(declare-function ebox-native--module-confirm-frame "ebox_native_reflow" + (session generation key confirmed-revision)) +(declare-function ebox-native--module-attach-readiness-channel + "ebox_native_reflow" (session process)) +(declare-function ebox-native--module-detach-readiness-channel + "ebox_native_reflow" (session)) +(declare-function ebox-native--module-cancel "ebox_native_reflow" + (session generation)) +(declare-function ebox-native--module-stats "ebox_native_reflow" (session)) +(declare-function ebox-native--module-release "ebox_native_reflow" (session)) +(declare-function ebox--grapheme-clusters "ebox-layout" (string)) +(declare-function ebox--format-content-string "ebox-layout" (box content)) +(declare-function ebox--ensure-node-id "ebox" (node)) +(declare-function ebox--buffer-render-state "ebox-incremental" (buffer)) +(declare-function ebox--ensure-region-id "ebox" (box)) +(declare-function ebox--literal-root-pixel-width "ebox" (box)) +(declare-function ebox--root-region-box "ebox" (root region-id)) +(declare-function ebox--flex-axis "ebox-flex" (props)) +(declare-function ebox--flex-container-content-props + "ebox-flex" (props raw-props wrapper-box)) +(declare-function ebox--flex-item-props "ebox-flex" (item)) +(declare-function ebox--flex-item-source-node "ebox-tree" (item)) +(declare-function ebox--flex-line-value "ebox-flex" (value &optional default)) +(declare-function ebox--content-min-pixel "ebox-layout" (box)) +(declare-function ebox--horizontal-size-pixels + "ebox-layout" (value &optional default)) +(declare-function ebox--layout-children "ebox-tree" (node)) +(declare-function ebox-buffer-side-border-face + "ebox-buffer-backend" (color)) +(declare-function ebox--space-pixel-width "ebox-measure" ()) +(declare-function ebox-get "ebox" (box key)) +(declare-function ebox-lines-join "ebox" (lines)) +(declare-function ebox-pixel-space "ebox-measure" (pixel-width)) +(declare-function ebox-string-lines "ebox" (string)) +(declare-function ebox-string-pixel-width "ebox-measure" (string)) +(declare-function ebox-display-signature "ebox-measure" ()) +(declare-function ebox--register-rendered-root-metadata + "ebox" (rendered prepared)) + +(defvar ebox--flex-content-min-width-table) + +(defconst ebox-native-reflow-abi-version "7:2:9" + "Version tuple shared by the native module, layout IR, and render tape.") + +(defconst ebox-native-reflow--minimum-rust-version "1.82.0" + "Minimum Cargo and rustc release supported by the native source.") + +(defun ebox-native-reflow--abi-directory-name () + "Return the filesystem directory name for the current native ABI." + (replace-regexp-in-string ":" "-" ebox-native-reflow-abi-version)) + +(defun ebox-native-reflow--system-directory-name () + "Return a filesystem-safe name for the current build target." + (replace-regexp-in-string + "[^[:alnum:]._-]" "_" (or system-configuration "unknown-system"))) + +(defun ebox-native-reflow--rust-architecture () + "Return the Rust architecture matching the current Emacs binary." + (pcase (car (split-string (or system-configuration "") "-" t)) + ((or "aarch64" "arm64") "aarch64") + ((or "x86_64" "amd64") "x86_64") + ((or "i686" "i386") "i686") + (architecture architecture))) + +(defun ebox-native-reflow--rust-target () + "Return the Rust target triple matching the current Emacs binary." + (when-let ((architecture (ebox-native-reflow--rust-architecture))) + (pcase system-type + ('darwin (format "%s-apple-darwin" architecture)) + ('gnu/linux (format "%s-unknown-linux-gnu" architecture)) + ('windows-nt (format "%s-pc-windows-msvc" architecture)) + (_ (or system-configuration architecture))))) + +(defun ebox-native-reflow--default-module-directory () + "Return the default user-owned directory for the native module." + (file-name-as-directory + (expand-file-name + (format "ebox/native/%s/%s" + (ebox-native-reflow--system-directory-name) + (ebox-native-reflow--abi-directory-name)) + user-emacs-directory))) + +(defgroup ebox-native-reflow nil + "Optional Rust preparation for expensive Ebox root reflows." + :group 'ebox + :prefix "ebox-native-reflow-") + +(defcustom ebox-native-reflow-module-path + (ebox-native-reflow--default-module-directory) + "File or directory where Ebox loads its optional native module. +The default is an ABI- and platform-specific directory below +`user-emacs-directory'. A directory value contains the platform module +filename; a file value names the module directly. Ebox never builds the +module while loading the package." + :type '(choice (file :tag "Module file") + (directory :tag "Module directory")) + :group 'ebox-native-reflow) + +(defcustom ebox-native-reflow-max-jobs 512 + "Maximum queued jobs retained by one native reflow session." + :type 'positive-integer + :group 'ebox-native-reflow) + +(defcustom ebox-native-reflow-max-results 512 + "Maximum ready results retained by one native reflow session." + :type 'positive-integer + :group 'ebox-native-reflow) + +(defcustom ebox-native-reflow-max-result-bytes (* 64 1024 1024) + "Maximum bytes retained by ready results in one native session." + :type 'positive-integer + :group 'ebox-native-reflow) + +(defvar ebox-native-reflow--load-attempted-p nil + "Non-nil after this Emacs tried to load the optional native module.") + +(defvar ebox-native-reflow--available-p nil + "Non-nil when the optional native module is loaded and ABI-compatible.") + +(defvar ebox-native-reflow--load-error nil + "Last native module load error, or nil.") + +(defvar ebox-native-reflow--loaded-module-path nil + "Absolute path of the native module loaded in this Emacs process.") + +(defvar ebox-native-reflow--loaded-module-hash nil + "SHA-256 hash of the native module loaded in this Emacs process.") + +(defconst ebox-native-reflow--library-directory + (file-name-directory + (or load-file-name + (locate-library "ebox-native-reflow") + buffer-file-name + default-directory)) + "Directory containing the loaded Ebox native reflow library.") + +(defconst ebox-native-reflow--source-files + '("Cargo.toml" + "Cargo.lock" + "build.rs" + "src/lib.rs" + "src/layout.rs" + "c/ebox_module.c" + "vendor/emacs-30/emacs-module.h") + "Files required to compile the bundled native module source.") + +(defvar ebox-native-reflow--last-build-report nil + "Result plist from the most recent `ebox-native-build' invocation.") + +(defvar ebox-native-reflow--build-process nil + "Live asynchronous process started by `ebox-native-build', or nil.") + +(defvar ebox-native-reflow--last-owner-proof-error nil + "Most recent rejected native owner proof error.") + +(defconst ebox-native-reflow--build-buffer-name "*Ebox Native Build*" + "Compilation buffer used by `ebox-native-build'.") + +(defvar-local ebox-native-status--diagnosis nil + "Diagnosis displayed in the current native status buffer.") + +(cl-defstruct (ebox-native-reflow-session + (:constructor ebox-native-reflow--make-session)) + handle + generation + styles + layout-package + readiness-process + readiness-preparation + released-p) + +(cl-defstruct (ebox-native-reflow-preparation + (:constructor ebox-native-reflow--make-preparation)) + native-session + native-session-id + generation + buffer + kind + region-id + source-root + source-revision + expected-revision + display-signature + context-hash + layout-package + key-table + frame-cache + materialization-queue + specs + ready-function + error-function + ready-timer + idle-watch-p + last-spec + light-p + window-start-line + release-native-session-p + final-stats + stopped-p + error) + +(cl-defstruct (ebox-native-reflow-layout-scene + (:constructor ebox-native-reflow--make-layout-scene)) + "Render-owned flat source snapshot for one immutable native layout." + buffer + render-state + root + node-postorder + flex-content-min-widths + display-signature + runtime-revision) + +(cl-defstruct (ebox-native-reflow-layout-builder + (:constructor ebox-native-reflow--make-layout-builder)) + "Interruptible flat Layout IR builder over a captured render scene." + scene + fragments + styles + index + package + flat-node-visits + tree-node-visits + string-pixel-width-cache + string-max-pixel-width-cache + property-templates + property-template-ids) + +(defvar ebox-native-reflow--compile-styles nil + "Dynamically bound ordered style registry for one Layout IR compile.") + +(defvar ebox-native-reflow--compile-property-templates nil + "Dynamically bound ordered opaque text-property registry.") + +(defvar ebox-native-reflow--compile-property-template-ids nil + "Dynamically bound opaque property-template lookup table.") + +(defconst ebox-native-reflow--unsafe-opaque-properties + '(category + composition + display + insert-behind-hooks + insert-in-front-hooks + invisible + line-prefix + modification-hooks + wrap-prefix) + "Text properties that cannot cross the native opaque-template boundary. +These properties can change layout indirectly or execute code during string +materialization. Editing metadata such as `keymap', `mouse-face', +`help-echo', `read-only', sticky boundaries, closures, and application-owned +properties remains opaque to Rust and is restored by Emacs after proof +validation.") + +(defvar ebox-native-reflow--compile-root-node nil + "Root node whose native layout document is currently being compiled.") + +(defconst ebox-native-reflow--tape-magic + (unibyte-string ?E ?B ?X ?T)) +(defconst ebox-native-reflow--tape-version 9) +(defconst ebox-native-reflow--tape-header-length 112) +(defconst ebox-native-reflow--tape-flag-ok 1) +(defconst ebox-native-reflow--tape-flag-complete 2) +(defconst ebox-native-reflow--tape-flag-patch 4) +(defconst ebox-native-reflow--tape-body-preamble-length 24) +(defconst ebox-native-reflow--tape-patch-body-preamble-length 32) +(defconst ebox-native-reflow--tape-patch-descriptor-length 32) +(defconst ebox-native-reflow--tape-metadata-role-symbols + '(content content-owner pt pb pl pr mt mb ml mr bt bb bl br) + "Root role symbols accepted in Rust tape-v9 metadata.") + +(defun ebox-native-reflow--module-filename () + "Return the platform module filename produced by Cargo." + (concat (unless (eq system-type 'windows-nt) "lib") + "ebox_native_reflow" + (or module-file-suffix + (pcase system-type + ('darwin ".dylib") + ('windows-nt ".dll") + (_ ".so"))))) + +(defun ebox-native-reflow--expand-candidate (candidate) + "Return module path represented by CANDIDATE." + (when (and candidate (not (string-empty-p candidate))) + (let ((expanded (expand-file-name candidate))) + (if (or (file-directory-p expanded) + (directory-name-p candidate)) + (expand-file-name (ebox-native-reflow--module-filename) expanded) + expanded)))) + +(defun ebox-native-reflow--candidate-paths () + "Return the sole configured native module candidate path." + (when-let ((candidate + (ebox-native-reflow--expand-candidate + ebox-native-reflow-module-path))) + (list candidate))) + +(defun ebox-native-reflow--pending-path (module-path) + "Return MODULE-PATH's pending replacement path." + (concat module-path ".pending")) + +(defun ebox-native-reflow--previous-path (module-path) + "Return MODULE-PATH's previous known-good path." + (concat module-path ".previous")) + +(defun ebox-native-reflow--promote-pending (module-path) + "Promote MODULE-PATH's pending replacement before the first module load. +Return non-nil when a pending module was promoted. If filesystem promotion +fails after moving the current module aside, restore that previous module." + (let ((pending (ebox-native-reflow--pending-path module-path)) + (previous (ebox-native-reflow--previous-path module-path))) + (when (file-exists-p pending) + (let ((had-current (file-exists-p module-path))) + (condition-case err + (progn + (make-directory (file-name-directory module-path) t) + (when had-current + (rename-file module-path previous t)) + (rename-file pending module-path t) + t) + (error + (when (and had-current + (not (file-exists-p module-path)) + (file-exists-p previous)) + (rename-file previous module-path t)) + (signal (car err) (cdr err)))))))) + +(defun ebox-native-reflow--file-sha256 (file) + "Return FILE's SHA-256 digest, or nil when FILE is unreadable." + (when (file-readable-p file) + (with-temp-buffer + (set-buffer-multibyte nil) + (insert-file-contents-literally file) + (secure-hash 'sha256 (current-buffer))))) + +(defun ebox-native-reflow--source-directory () + "Return the bundled native module source directory." + (expand-file-name "native" ebox-native-reflow--library-directory)) + +(defun ebox-native-reflow--build-directory () + "Return the private Cargo build directory for the current Emacs target." + (file-name-as-directory + (expand-file-name + (format "ebox/native-build/%s/%s" + (ebox-native-reflow--system-directory-name) + (ebox-native-reflow--abi-directory-name)) + user-emacs-directory))) + +(defun ebox-native-reflow--command-result (program &rest arguments) + "Run PROGRAM with ARGUMENTS and return its exit status and output." + (when program + (with-temp-buffer + (let ((status + (condition-case err + (apply #'process-file program nil '(t t) nil arguments) + (file-error + (insert (error-message-string err)) + 127)))) + (list :status status :output (string-trim (buffer-string))))))) + +(defun ebox-native-reflow--command-output (program &rest arguments) + "Return successful PROGRAM output for ARGUMENTS, or nil." + (let ((result (apply #'ebox-native-reflow--command-result + program arguments))) + (when (and result (eq 0 (plist-get result :status))) + (plist-get result :output)))) + +(defun ebox-native-reflow--tool-host (output) + "Return the Rust host triple parsed from verbose tool OUTPUT." + (when (and output + (string-match "^host: \\([^[:space:]\n]+\\)" output)) + (match-string 1 output))) + +(defun ebox-native-reflow--tool-version (output) + "Return the Cargo or rustc version line from verbose tool OUTPUT." + (cl-find-if + (lambda (line) + (string-match-p "\\`\\(?:cargo\\|rustc\\) " line)) + (split-string (or output "") "\n" t))) + +(defun ebox-native-reflow--tool-release (output) + "Return the numeric Cargo or rustc release parsed from OUTPUT." + (when-let ((line (ebox-native-reflow--tool-version output))) + (when (string-match + "\\`\\(?:cargo\\|rustc\\) \\([0-9]+\\(?:\\.[0-9]+\\)+\\)" + line) + (match-string 1 line)))) + +(defun ebox-native-reflow--supported-toolchain-p + (cargo-output rustc-output) + "Return non-nil when CARGO-OUTPUT and RUSTC-OUTPUT meet Ebox's MSRV." + (let ((cargo-release (ebox-native-reflow--tool-release cargo-output)) + (rustc-release (ebox-native-reflow--tool-release rustc-output))) + (and cargo-release + rustc-release + (not (version< cargo-release + ebox-native-reflow--minimum-rust-version)) + (not (version< rustc-release + ebox-native-reflow--minimum-rust-version))))) + +(defun ebox-native-reflow--rustup-toolchains (rustup) + "Return installed toolchain names reported by RUSTUP." + (when-let ((output + (ebox-native-reflow--command-output rustup "toolchain" "list"))) + (mapcar (lambda (line) (car (split-string line " " t))) + (split-string output "\n" t)))) + +(defun ebox-native-reflow--matching-rustup-toolchain + (rustup target &optional supported-only) + "Return an installed RUSTUP toolchain whose native host is TARGET. +When SUPPORTED-ONLY is non-nil, require Cargo and rustc to meet Ebox's MSRV." + (let* ((names (ebox-native-reflow--rustup-toolchains rustup)) + (stable (format "stable-%s" target)) + (ordered (if (member stable names) + (cons stable (delete stable (copy-sequence names))) + names))) + (cl-find-if + (lambda (name) + (when (string-suffix-p target name) + (let ((cargo-output + (ebox-native-reflow--command-output + rustup "run" name "cargo" "-vV")) + (rustc-output + (ebox-native-reflow--command-output + rustup "run" name "rustc" "-vV"))) + (and (equal (ebox-native-reflow--tool-host cargo-output) target) + (equal (ebox-native-reflow--tool-host rustc-output) target) + (or (not supported-only) + (ebox-native-reflow--supported-toolchain-p + cargo-output rustc-output)))))) + ordered))) + +(defun ebox-native-reflow--rustup-toolchain-info (rustup name target) + "Return build metadata for RUSTUP toolchain NAME targeting TARGET." + (let ((cargo-output + (ebox-native-reflow--command-output + rustup "run" name "cargo" "-vV")) + (rustc-output + (ebox-native-reflow--command-output + rustup "run" name "rustc" "-vV"))) + (list :name name + :target target + :host target + :cargo-command (list rustup "run" name "cargo") + :rustc-command (list rustup "run" name "rustc") + :cargo-version (ebox-native-reflow--tool-version cargo-output) + :rustc-version (ebox-native-reflow--tool-version rustc-output)))) + +(defun ebox-native-reflow--select-rust-toolchain () + "Return a Rust/Cargo toolchain native to the running Emacs target. +The result is a plist containing command prefixes, paths, versions, and host. +An installed matching rustup toolchain is selected automatically when the +default tools run under a different architecture or are too old." + (let* ((target (ebox-native-reflow--rust-target)) + (cargo (executable-find "cargo")) + (rustc (executable-find "rustc")) + (cargo-output (ebox-native-reflow--command-output cargo "-vV")) + (rustc-output (ebox-native-reflow--command-output rustc "-vV")) + (cargo-host (ebox-native-reflow--tool-host cargo-output)) + (rustc-host (ebox-native-reflow--tool-host rustc-output)) + (default-toolchain + (and cargo rustc + (equal cargo-host target) + (equal rustc-host target) + (list :name "default" + :target target + :host target + :cargo-command (list cargo) + :rustc-command (list rustc) + :cargo-version + (ebox-native-reflow--tool-version cargo-output) + :rustc-version + (ebox-native-reflow--tool-version rustc-output))))) + (cond + ((and default-toolchain + (ebox-native-reflow--supported-toolchain-p + cargo-output rustc-output)) + default-toolchain) + ((when-let* ((rustup (executable-find "rustup")) + (name (ebox-native-reflow--matching-rustup-toolchain + rustup target t))) + (ebox-native-reflow--rustup-toolchain-info rustup name target))) + (default-toolchain default-toolchain) + ((when-let* ((rustup (executable-find "rustup")) + (name (ebox-native-reflow--matching-rustup-toolchain + rustup target))) + (ebox-native-reflow--rustup-toolchain-info rustup name target))) + (t nil)))) + +(defun ebox-native-reflow--c-compiler () + "Return the configured or first available C compiler executable." + (let* ((configured (getenv "CC")) + (configured-command + (car (ignore-errors (split-string-and-unquote configured))))) + (or (and configured-command + (or (and (file-name-absolute-p configured-command) + (file-executable-p configured-command) + configured-command) + (executable-find configured-command))) + (cl-loop for name in (if (eq system-type 'windows-nt) + '("cl" "clang" "gcc") + '("cc" "clang" "gcc")) + thereis (executable-find name))))) + +(defun ebox-native-reflow--writable-parent-p (path) + "Return non-nil when PATH or its nearest existing parent is writable." + (let ((parent (file-name-directory (expand-file-name path)))) + (while (and parent (not (file-exists-p parent))) + (let ((next (file-name-directory (directory-file-name parent)))) + (setq parent (unless (equal next parent) next)))) + (and parent (file-directory-p parent) (file-writable-p parent)))) + +(defun ebox-native-reflow--file-status (path) + "Return a diagnostic plist describing PATH." + (let ((attributes (and path (file-attributes path)))) + (list :path path + :exists (and attributes t) + :readable (and path (file-readable-p path)) + :size (and attributes (file-attribute-size attributes)) + :modified (and attributes + (file-attribute-modification-time attributes)) + :hash (and path (file-regular-p path) + (ebox-native-reflow--file-sha256 path))))) + +(defun ebox-native-reflow--issue (code message) + "Return a diagnostic issue with CODE and MESSAGE." + (list :code code :message message)) + +(defun ebox-native--diagnose () + "Return one complete native build, installation, and runtime diagnosis. +This function owns environment checks shared by `ebox-native-status' and +`ebox-native-build'. It never changes files, installs tools, or loads the +module." + (let* ((target (ebox-native-reflow--rust-target)) + (source-directory (ebox-native-reflow--source-directory)) + (missing-sources + (cl-remove-if + #'file-readable-p + (mapcar (lambda (file) (expand-file-name file source-directory)) + ebox-native-reflow--source-files))) + (module-path (car (ebox-native-reflow--candidate-paths))) + (pending-path (and module-path + (ebox-native-reflow--pending-path module-path))) + (previous-path (and module-path + (ebox-native-reflow--previous-path module-path))) + (build-directory (ebox-native-reflow--build-directory)) + (toolchain (ebox-native-reflow--select-rust-toolchain)) + (compiler (ebox-native-reflow--c-compiler)) + errors + warnings) + (unless (and (fboundp 'module-load) module-file-suffix) + (push (ebox-native-reflow--issue + 'dynamic-modules-unsupported + "This Emacs was built without dynamic module support.") + errors)) + (unless target + (push (ebox-native-reflow--issue + 'unknown-rust-target + (format "Cannot map Emacs system configuration %S to Rust." + system-configuration)) + errors)) + (when missing-sources + (push (ebox-native-reflow--issue + 'native-source-incomplete + (format "Bundled native source is incomplete; missing: %s" + (mapconcat #'identity missing-sources ", "))) + errors)) + (unless module-path + (push (ebox-native-reflow--issue + 'module-path-invalid + "`ebox-native-reflow-module-path' does not resolve to a file.") + errors)) + (when (and module-path + (not (ebox-native-reflow--writable-parent-p module-path))) + (push (ebox-native-reflow--issue + 'module-directory-not-writable + (format "Module directory is not writable: %s" + (file-name-directory module-path))) + errors)) + (unless (ebox-native-reflow--writable-parent-p build-directory) + (push (ebox-native-reflow--issue + 'build-directory-not-writable + (format "Build directory is not writable: %s" build-directory)) + errors)) + (unless toolchain + (push (ebox-native-reflow--issue + 'native-rust-toolchain-missing + (format + (concat "No Rust/Cargo toolchain native to Emacs target %s was " + "found. Install it with: rustup toolchain install stable-%s") + target target)) + errors)) + (when (and toolchain + (not (ebox-native-reflow--supported-toolchain-p + (plist-get toolchain :cargo-version) + (plist-get toolchain :rustc-version)))) + (push (ebox-native-reflow--issue + 'native-rust-toolchain-too-old + (format + (concat "Rust/Cargo %s or newer is required for target %s " + "(found %s; %s). Install or update it with: " + "rustup toolchain install stable-%s") + ebox-native-reflow--minimum-rust-version + target + (or (plist-get toolchain :cargo-version) "unknown Cargo") + (or (plist-get toolchain :rustc-version) "unknown rustc") + target)) + errors)) + (unless compiler + (push (ebox-native-reflow--issue + 'c-compiler-missing + "No C compiler was found (checked CC, cc, clang, and gcc).") + errors)) + (unless (and module-path (file-readable-p module-path)) + (push (ebox-native-reflow--issue + 'module-not-installed + "The compiled native module is not installed yet.") + warnings)) + (when (and pending-path (file-exists-p pending-path)) + (push (ebox-native-reflow--issue + 'restart-required + "A newly compiled module is pending; restart Emacs to load it.") + warnings)) + (let ((errors (nreverse errors)) + (warnings (nreverse warnings))) + (list + :ok (null errors) + :errors errors + :warnings warnings + :platform (list :system-type system-type + :system-configuration system-configuration + :rust-target target + :module-suffix module-file-suffix + :dynamic-modules-p + (and (fboundp 'module-load) module-file-suffix t)) + :source (list :directory source-directory + :complete-p (null missing-sources) + :missing missing-sources) + :toolchain toolchain + :compiler compiler + :paths (list :configured ebox-native-reflow-module-path + :default-p + (and (stringp ebox-native-reflow-module-path) + (equal + (expand-file-name ebox-native-reflow-module-path) + (expand-file-name + (ebox-native-reflow--default-module-directory)))) + :module (ebox-native-reflow--file-status module-path) + :pending (ebox-native-reflow--file-status pending-path) + :previous (ebox-native-reflow--file-status previous-path) + :build-directory build-directory) + :runtime (list :load-attempted-p + ebox-native-reflow--load-attempted-p + :available-p ebox-native-reflow--available-p + :loaded-path ebox-native-reflow--loaded-module-path + :loaded-hash ebox-native-reflow--loaded-module-hash + :load-error ebox-native-reflow--load-error) + :last-build ebox-native-reflow--last-build-report)))) + +(defun ebox-native-reflow--build-command (diagnosis) + "Return the native Cargo command described by DIAGNOSIS." + (let* ((toolchain (plist-get diagnosis :toolchain)) + (target (plist-get (plist-get diagnosis :platform) :rust-target)) + (source (plist-get diagnosis :source)) + (paths (plist-get diagnosis :paths))) + (append + (plist-get toolchain :cargo-command) + (list "build" + "--locked" + "--manifest-path" (expand-file-name "Cargo.toml" + (plist-get source :directory)) + "--release" + "--target" target + "--target-dir" (plist-get paths :build-directory))))) + +(defun ebox-native-reflow--build-artifact (diagnosis) + "Return the Cargo output module path described by DIAGNOSIS." + (let ((target (plist-get (plist-get diagnosis :platform) :rust-target)) + (build-directory + (plist-get (plist-get diagnosis :paths) :build-directory))) + (expand-file-name + (format "%s/release/%s" target (ebox-native-reflow--module-filename)) + build-directory))) + +(defun ebox-native-reflow--format-command (command) + "Return shell-readable COMMAND text for logs only." + (mapconcat #'shell-quote-argument command " ")) + +(defun ebox-native-reflow--prepare-build-buffer (diagnosis command clean) + "Prepare and return the compilation buffer for DIAGNOSIS and COMMAND. +CLEAN records whether this invocation discarded the private Cargo cache." + (let ((buffer (get-buffer-create ebox-native-reflow--build-buffer-name))) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (compilation-mode) + (insert (format "Ebox native module build\n\nTarget: %s\nToolchain: %s\nClean build: %s\nCommand: %s\n\n" + (plist-get (plist-get diagnosis :platform) + :rust-target) + (plist-get (plist-get diagnosis :toolchain) :name) + (if clean "yes" "no") + (ebox-native-reflow--format-command command))))) + buffer)) + +(defun ebox-native-reflow--safe-build-directory-p (directory) + "Return non-nil when DIRECTORY is inside Ebox's private build root." + (file-in-directory-p + (expand-file-name directory) + (file-name-as-directory + (expand-file-name "ebox/native-build" user-emacs-directory)))) + +(defun ebox-native-reflow--clean-build-directory (directory) + "Delete Ebox's private Cargo build DIRECTORY after validating its scope." + (unless (ebox-native-reflow--safe-build-directory-p directory) + (error "Refusing to clean unexpected build directory: %s" directory)) + (when (file-directory-p directory) + (delete-directory directory t))) + +(defun ebox-native-reflow--copy-atomically (source destination) + "Copy SOURCE to DESTINATION by renaming a same-directory temporary file." + (make-directory (file-name-directory destination) t) + (let ((temporary (make-temp-file (concat destination ".new-")))) + (unwind-protect + (progn + (copy-file source temporary t t nil t) + (rename-file temporary destination t) + (setq temporary nil) + destination) + (when (and temporary (file-exists-p temporary)) + (delete-file temporary))))) + +(defun ebox-native-reflow--install-canonical (artifact module-path) + "Atomically install ARTIFACT at MODULE-PATH and retain the old module." + (let ((previous (ebox-native-reflow--previous-path module-path)) + (had-current (file-exists-p module-path))) + (make-directory (file-name-directory module-path) t) + (condition-case err + (progn + (when had-current + (rename-file module-path previous t)) + (ebox-native-reflow--copy-atomically artifact module-path)) + (error + (when (and had-current + (not (file-exists-p module-path)) + (file-exists-p previous)) + (rename-file previous module-path t)) + (signal (car err) (cdr err)))))) + +(defun ebox-native-reflow--rollback-canonical (module-path had-current) + "Roll back MODULE-PATH after a failed load when HAD-CURRENT was non-nil." + (let ((previous (ebox-native-reflow--previous-path module-path))) + (when (file-exists-p module-path) + (delete-file module-path)) + (when (and had-current (file-exists-p previous)) + (rename-file previous module-path t)))) + +(defun ebox-native-reflow--install-artifact (artifact module-path) + "Install ARTIFACT for MODULE-PATH and return an installation report. +If this Emacs already loaded a different module image, place the replacement +at the pending path so the current canonical file remains untouched until the +next Emacs start." + (let* ((artifact-hash (ebox-native-reflow--file-sha256 artifact)) + (loaded-hash + (or ebox-native-reflow--loaded-module-hash + (and ebox-native-reflow--loaded-module-path + (ebox-native-reflow--file-sha256 + ebox-native-reflow--loaded-module-path)))) + (loaded-p ebox-native-reflow--available-p) + (had-current (file-exists-p module-path))) + (cond + ((and loaded-p (equal artifact-hash loaded-hash)) + (if (and had-current + (equal artifact-hash + (ebox-native-reflow--file-sha256 module-path))) + (list :status 'succeeded :installation 'unchanged + :module module-path :restart-required-p nil) + (let ((pending (ebox-native-reflow--pending-path module-path))) + (ebox-native-reflow--copy-atomically artifact pending) + (list :status 'succeeded :installation 'pending + :module module-path :pending pending + :restart-required-p t)))) + (loaded-p + (let ((pending (ebox-native-reflow--pending-path module-path))) + (ebox-native-reflow--copy-atomically artifact pending) + (list :status 'succeeded :installation 'pending + :module module-path :pending pending :restart-required-p t))) + (t + (ebox-native-reflow--install-canonical artifact module-path) + (setq ebox-native-reflow--load-attempted-p nil + ebox-native-reflow--available-p nil + ebox-native-reflow--load-error nil + ebox-native-reflow--loaded-module-path nil + ebox-native-reflow--loaded-module-hash nil) + (if (ebox-native-reflow-load) + (list :status 'succeeded :installation 'loaded + :module module-path :restart-required-p nil) + (let ((load-error ebox-native-reflow--load-error)) + (ebox-native-reflow--rollback-canonical module-path had-current) + (list :status 'failed :phase 'load :installation 'rolled-back + :module module-path :error load-error))))))) + +(defun ebox-native-reflow--append-build-log (format-string &rest arguments) + "Append FORMAT-STRING and ARGUMENTS to the native build buffer." + (when-let ((buffer (get-buffer ebox-native-reflow--build-buffer-name))) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (goto-char (point-max)) + (insert (apply #'format format-string arguments)))))) + +(defun ebox-native-reflow--refresh-status-buffer () + "Refresh an existing native status buffer without selecting it." + (when-let ((buffer (get-buffer "*Ebox Native Status*"))) + (with-current-buffer buffer + (let ((inhibit-read-only t) + (diagnosis (ebox-native--diagnose))) + (erase-buffer) + (setq ebox-native-status--diagnosis diagnosis) + (ebox-native-reflow--insert-status diagnosis) + (goto-char (point-min)))))) + +(defun ebox-native-reflow--finish-build (process) + "Finalize native build PROCESS, install its artifact, and record status." + (let* ((exit-status (process-exit-status process)) + (artifact (process-get process :ebox-artifact)) + (module-path (process-get process :ebox-module-path)) + (started (process-get process :ebox-started)) + (command (process-command process)) + report) + (cond + ((not (zerop exit-status)) + (setq report + (list :status 'failed :phase 'compile :exit-status exit-status + :command command :started started :finished (current-time) + :error (format "Cargo exited with status %s" exit-status)))) + ((not (file-readable-p artifact)) + (setq report + (list :status 'failed :phase 'artifact :exit-status exit-status + :command command :started started :finished (current-time) + :artifact artifact + :error (format "Cargo succeeded but did not produce %s" + artifact)))) + (t + (setq report + (condition-case err + (append + (ebox-native-reflow--install-artifact artifact module-path) + (list :phase 'install :exit-status exit-status + :command command :started started + :finished (current-time) :artifact artifact)) + (error + (list :status 'failed :phase 'install + :exit-status exit-status :command command + :started started :finished (current-time) + :artifact artifact :error (error-message-string err))))))) + (setq ebox-native-reflow--last-build-report report + ebox-native-reflow--build-process nil) + (ebox-native-reflow--append-build-log + "\nEbox native build %s: %s\n" + (plist-get report :status) + (or (plist-get report :error) + (pcase (plist-get report :installation) + ('loaded "installed and loaded in this Emacs") + ('pending "installed as pending; restart Emacs to activate") + ('unchanged "module already matches the loaded image") + (_ "completed")))) + (ebox-native-reflow--refresh-status-buffer) + (if (eq (plist-get report :status) 'succeeded) + (message "Ebox native build succeeded%s" + (if (plist-get report :restart-required-p) + "; restart Emacs to activate it" + "")) + (message "Ebox native build failed: %s" + (plist-get report :error))))) + +(defun ebox-native-reflow--build-sentinel (process _event) + "Handle completion of native build PROCESS." + (when (memq (process-status process) '(exit signal)) + (ebox-native-reflow--finish-build process))) + +(defun ebox-native-reflow--show-preflight-errors (diagnosis clean) + "Display DIAGNOSIS errors for a blocked build requested with CLEAN." + (let ((buffer (get-buffer-create ebox-native-reflow--build-buffer-name))) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (compilation-mode) + (insert "Ebox native module build blocked by environment checks.\n\n") + (when clean + (insert "The clean build was not started.\n\n")) + (dolist (issue (plist-get diagnosis :errors)) + (insert (format "[%s] %s\n" + (plist-get issue :code) + (plist-get issue :message)))))) + (display-buffer buffer))) + +;;;###autoload +(defun ebox-native-build (&optional clean) + "Compile and install the bundled native module asynchronously. +The command always runs the shared environment checks first and requires a +Rust/Cargo toolchain native to the current Emacs architecture. With prefix +argument CLEAN, remove only Ebox's private Cargo build cache before compiling. +Build output and exact errors appear in `*Ebox Native Build*'." + (interactive "P") + (when (process-live-p ebox-native-reflow--build-process) + (user-error "An Ebox native build is already running")) + (let* ((diagnosis (ebox-native--diagnose)) + (clean (and clean t))) + (unless (plist-get diagnosis :ok) + (setq ebox-native-reflow--last-build-report + (list :status 'failed :phase 'preflight + :started (current-time) :finished (current-time) + :errors (plist-get diagnosis :errors))) + (ebox-native-reflow--show-preflight-errors diagnosis clean) + (user-error "Ebox native build is blocked; see %s" + ebox-native-reflow--build-buffer-name)) + (let* ((paths (plist-get diagnosis :paths)) + (build-directory (plist-get paths :build-directory)) + (module-path (plist-get (plist-get paths :module) :path)) + (command (ebox-native-reflow--build-command diagnosis)) + (artifact (ebox-native-reflow--build-artifact diagnosis)) + (buffer (ebox-native-reflow--prepare-build-buffer + diagnosis command clean))) + (condition-case err + (progn + (when clean + (ebox-native-reflow--clean-build-directory build-directory)) + (make-directory build-directory t) + (let ((default-directory + (plist-get (plist-get diagnosis :source) :directory))) + (setq ebox-native-reflow--last-build-report + (list :status 'running :phase 'compile :command command + :started (current-time) :artifact artifact) + ebox-native-reflow--build-process + (make-process + :name "ebox-native-build" + :buffer buffer + :command command + :connection-type 'pipe + :noquery t))) + (process-put ebox-native-reflow--build-process + :ebox-artifact artifact) + (process-put ebox-native-reflow--build-process + :ebox-module-path module-path) + (process-put ebox-native-reflow--build-process + :ebox-started (current-time)) + (set-process-sentinel ebox-native-reflow--build-process + #'ebox-native-reflow--build-sentinel) + (display-buffer buffer) + (message "Ebox native build started with %s" + (plist-get (plist-get diagnosis :toolchain) :name)) + ebox-native-reflow--build-process) + (error + (setq ebox-native-reflow--build-process nil + ebox-native-reflow--last-build-report + (list :status 'failed :phase 'start :command command + :started (current-time) :finished (current-time) + :error (error-message-string err))) + (ebox-native-reflow--append-build-log + "\nEbox native build failed to start: %s\n" + (error-message-string err)) + (display-buffer buffer) + (user-error "Ebox native build failed to start: %s" + (error-message-string err))))))) + +(defun ebox-native-reflow--clean-build () + "Run `ebox-native-build' after clearing Ebox's private Cargo cache." + (interactive) + (ebox-native-build t)) + +(defvar ebox-native-status-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map special-mode-map) + (define-key map (kbd "g") #'ebox-native-status) + (define-key map (kbd "b") #'ebox-native-build) + (define-key map (kbd "B") #'ebox-native-reflow--clean-build) + map) + "Keymap for `ebox-native-status-mode'.") + +(define-derived-mode ebox-native-status-mode special-mode "Ebox-Native" + "Major mode for the Ebox native module status report.") + +(defun ebox-native-reflow--status-value (value) + "Return a compact printable representation of VALUE." + (cond + ((eq value t) "yes") + ((null value) "no") + ((stringp value) value) + (t (format "%S" value)))) + +(defun ebox-native-reflow--insert-status-row (label value) + "Insert one status row with LABEL and VALUE." + (insert (format " %-22s %s\n" label + (ebox-native-reflow--status-value value)))) + +(defun ebox-native-reflow--insert-issues (title issues) + "Insert diagnostic ISSUES below TITLE." + (when issues + (insert (propertize (concat title "\n") 'face 'bold)) + (dolist (issue issues) + (insert (format " - [%s] %s\n" + (plist-get issue :code) + (plist-get issue :message)))) + (insert "\n"))) + +(defun ebox-native-reflow--overall-status (diagnosis) + "Return the user-facing overall state for DIAGNOSIS." + (let* ((runtime (plist-get diagnosis :runtime)) + (paths (plist-get diagnosis :paths)) + (last-build (plist-get diagnosis :last-build))) + (cond + ((not (plist-get diagnosis :ok)) "blocked") + ((eq (plist-get last-build :status) 'running) "building") + ((plist-get (plist-get paths :pending) :exists) "restart required") + ((plist-get runtime :available-p) "loaded") + ((plist-get (plist-get paths :module) :exists) "installed, not loaded") + (t "ready to build")))) + +(defun ebox-native-reflow--insert-status (diagnosis) + "Insert a human-readable native status report for DIAGNOSIS." + (let* ((platform (plist-get diagnosis :platform)) + (source (plist-get diagnosis :source)) + (toolchain (plist-get diagnosis :toolchain)) + (paths (plist-get diagnosis :paths)) + (module (plist-get paths :module)) + (pending (plist-get paths :pending)) + (previous (plist-get paths :previous)) + (runtime (plist-get diagnosis :runtime)) + (last-build (plist-get diagnosis :last-build))) + (insert (propertize "Ebox Native Module Status\n" 'face 'bold)) + (insert (format "Overall: %s\n\n" + (ebox-native-reflow--overall-status diagnosis))) + (insert (propertize "Environment\n" 'face 'bold)) + (ebox-native-reflow--insert-status-row + "Emacs target" (plist-get platform :system-configuration)) + (ebox-native-reflow--insert-status-row + "Rust target" (plist-get platform :rust-target)) + (ebox-native-reflow--insert-status-row + "Dynamic modules" (plist-get platform :dynamic-modules-p)) + (ebox-native-reflow--insert-status-row + "Native sources" (plist-get source :complete-p)) + (ebox-native-reflow--insert-status-row + "C compiler" (plist-get diagnosis :compiler)) + (ebox-native-reflow--insert-status-row + "Rust toolchain" (and toolchain (plist-get toolchain :name))) + (ebox-native-reflow--insert-status-row + "Minimum Rust/Cargo" ebox-native-reflow--minimum-rust-version) + (ebox-native-reflow--insert-status-row + "Cargo" (and toolchain (plist-get toolchain :cargo-version))) + (ebox-native-reflow--insert-status-row + "rustc" (and toolchain (plist-get toolchain :rustc-version))) + (insert "\n" (propertize "Paths\n" 'face 'bold)) + (ebox-native-reflow--insert-status-row + "Configuration" (if (plist-get paths :default-p) "automatic" "custom")) + (ebox-native-reflow--insert-status-row "Source" (plist-get source :directory)) + (ebox-native-reflow--insert-status-row "Build" (plist-get paths :build-directory)) + (ebox-native-reflow--insert-status-row "Module" (plist-get module :path)) + (ebox-native-reflow--insert-status-row "Installed" (plist-get module :exists)) + (ebox-native-reflow--insert-status-row "Pending" (plist-get pending :exists)) + (ebox-native-reflow--insert-status-row "Previous" (plist-get previous :exists)) + (insert "\n" (propertize "Current Emacs\n" 'face 'bold)) + (ebox-native-reflow--insert-status-row + "Load attempted" (plist-get runtime :load-attempted-p)) + (ebox-native-reflow--insert-status-row + "Module loaded" (plist-get runtime :available-p)) + (ebox-native-reflow--insert-status-row + "Loaded path" (plist-get runtime :loaded-path)) + (ebox-native-reflow--insert-status-row + "Load error" (plist-get runtime :load-error)) + (insert "\n") + (when last-build + (insert (propertize "Last Build\n" 'face 'bold)) + (ebox-native-reflow--insert-status-row + "Status" (plist-get last-build :status)) + (ebox-native-reflow--insert-status-row + "Phase" (plist-get last-build :phase)) + (ebox-native-reflow--insert-status-row + "Installation" (plist-get last-build :installation)) + (ebox-native-reflow--insert-status-row + "Restart required" (plist-get last-build :restart-required-p)) + (ebox-native-reflow--insert-status-row + "Error" (plist-get last-build :error)) + (insert "\n")) + (ebox-native-reflow--insert-issues + "Errors" (plist-get diagnosis :errors)) + (ebox-native-reflow--insert-issues + "Notices" (plist-get diagnosis :warnings)) + (insert (concat "Keys: g refresh status, b build/rebuild, " + "B clean rebuild, q quit.\n")))) + +;;;###autoload +(defun ebox-native-status () + "Display native toolchain, installation, and runtime loading status." + (interactive) + (unless ebox-native-reflow--load-attempted-p + (ebox-native-reflow-load)) + (let ((diagnosis (ebox-native--diagnose)) + (buffer (get-buffer-create "*Ebox Native Status*"))) + (with-current-buffer buffer + (ebox-native-status-mode) + (let ((inhibit-read-only t)) + (erase-buffer) + (setq ebox-native-status--diagnosis diagnosis) + (ebox-native-reflow--insert-status diagnosis) + (goto-char (point-min)))) + (pop-to-buffer buffer) + buffer)) + +(defun ebox-native-reflow-load () + "Load the optional native module and return non-nil on ABI success." + (unless ebox-native-reflow--load-attempted-p + (setq ebox-native-reflow--load-attempted-p t + ebox-native-reflow--available-p nil + ebox-native-reflow--load-error nil + ebox-native-reflow--loaded-module-path nil + ebox-native-reflow--loaded-module-hash nil) + (let ((candidate (car (ebox-native-reflow--candidate-paths)))) + (condition-case err + (progn + (when candidate + (ebox-native-reflow--promote-pending candidate)) + (if (not (and candidate (file-readable-p candidate))) + (setq ebox-native-reflow--load-error 'module-not-found) + (module-load candidate) + (unless (and (fboundp 'ebox-native--module-version) + (equal (ebox-native--module-version) + ebox-native-reflow-abi-version)) + (error "Ebox native module ABI mismatch")) + (setq ebox-native-reflow--available-p t + ebox-native-reflow--loaded-module-path + (expand-file-name candidate) + ebox-native-reflow--loaded-module-hash + (ebox-native-reflow--file-sha256 candidate)))) + (error + (setq ebox-native-reflow--load-error + (error-message-string err)))))) + ebox-native-reflow--available-p) + +(defun ebox-native-reflow-module-version () + "Return native module, IR, and tape versions as a plist." + (when (ebox-native-reflow-load) + (pcase (mapcar #'string-to-number + (split-string (ebox-native--module-version) ":" t)) + (`(,module ,ir ,tape) + (list :module module :ir ir :tape tape))))) + +(defun ebox-native-reflow-layout-ready-p () + "Return non-nil when the loaded module implements native layout." + (and (ebox-native-reflow-load) + (ebox-native--module-layout-ready-p))) + +(defun ebox-native-reflow-runtime-report () + "Return exact evidence for the native module active in this Emacs." + (let* ((ready (ebox-native-reflow-layout-ready-p)) + (module-abi + (and ebox-native-reflow--available-p + (ebox-native--module-version)))) + (list :available-p ebox-native-reflow--available-p + :layout-ready-p (and ready t) + :abi ebox-native-reflow-abi-version + :module-abi module-abi + :module-version (and module-abi + (ebox-native-reflow-module-version)) + :configured-module-path ebox-native-reflow-module-path + :loaded-module-path ebox-native-reflow--loaded-module-path + :loaded-module-hash ebox-native-reflow--loaded-module-hash + :load-error ebox-native-reflow--load-error))) + +(cl-defun ebox-native-reflow-create-session + (&key workers + (max-jobs ebox-native-reflow-max-jobs) + (max-results ebox-native-reflow-max-results) + (max-result-bytes ebox-native-reflow-max-result-bytes)) + "Create one bounded private native session." + (unless (ebox-native-reflow-load) + (error "Ebox native reflow is unavailable: %S" + ebox-native-reflow--load-error)) + (let* ((available (max 1 (num-processors))) + (workers (or workers (max 1 (- available 2))))) + (dolist (value (list workers max-jobs max-results max-result-bytes)) + (unless (and (integerp value) (> value 0)) + (error "Native reflow capacities must be positive integers: %S" + value))) + (ebox-native-reflow--make-session + :handle (ebox-native--module-create-session + workers max-jobs max-results max-result-bytes) + :generation 0))) + +(defun ebox-native-reflow--live-handle (session) + "Return SESSION's live module handle or signal an error." + (unless (and (ebox-native-reflow-session-p session) + (not (ebox-native-reflow-session-released-p session)) + (ebox-native-reflow-session-handle session)) + (error "Native reflow session has been released")) + (ebox-native-reflow-session-handle session)) + +(defun ebox-native-reflow--control-json (frames) + "Return versioned control JSON for FRAMES plists." + (unless (and (listp frames) frames) + (error "Native reflow requires at least one frame")) + (json-serialize + (list :version 1 + :frames + (vconcat + (mapcar + (lambda (frame) + (let ((key (plist-get frame :key)) + (payload (plist-get frame :payload)) + (delay (or (plist-get frame :delay-ms) 0))) + (unless (integerp key) + (error "Native reflow frame key must be an integer: %S" key)) + (unless (stringp payload) + (error "Native reflow frame payload must be a string")) + (list :key key :payload payload :delay-ms delay))) + frames))))) + +(defun ebox-native-reflow--validate-property-template (properties label) + "Validate opaque text PROPERTIES for LABEL." + (unless (and (proper-list-p properties) + (zerop (% (length properties) 2))) + (error "Native reflow %s properties must be an even plist" label)) + (let ((tail properties)) + (while tail + (let ((property (pop tail))) + (unless (symbolp property) + (error "Native reflow %s property name must be a symbol: %S" + label property)) + (when (or (eq property 'ebox-native-property-template-ids) + (memq property + ebox-native-reflow--unsafe-opaque-properties)) + (error "Native reflow %s cannot carry unsafe property %S" + label property)) + (pop tail))))) + +(defun ebox-native-reflow--register-property-template (properties label) + "Register opaque text PROPERTIES for LABEL and return its native id." + (when properties + (ebox-native-reflow--validate-property-template properties label) + (let* ((entry (list :kind (intern label) + :properties (copy-sequence properties))) + (missing (make-symbol "missing-property-template")) + (existing + (if (hash-table-p + ebox-native-reflow--compile-property-template-ids) + (gethash + entry ebox-native-reflow--compile-property-template-ids + missing) + (or (cl-position + entry ebox-native-reflow--compile-property-templates + :test #'equal) + missing)))) + (if (not (eq existing missing)) + existing + (let ((id (length ebox-native-reflow--compile-property-templates))) + (setq ebox-native-reflow--compile-property-templates + (append ebox-native-reflow--compile-property-templates + (list entry))) + (when (hash-table-p + ebox-native-reflow--compile-property-template-ids) + (puthash + entry id ebox-native-reflow--compile-property-template-ids)) + id))))) + +(defun ebox-native-reflow--compile-cluster (cluster &optional source-template-id) + "Compile measured grapheme CLUSTER to strict JSON-ready IR." + (append + (list :text (plist-get cluster :text) + :width (plist-get cluster :width) + :cjk (if (plist-get cluster :cjk) t :false) + :space (if (plist-get cluster :space) t :false) + :pixel-space :false) + (when source-template-id + (list :source-template-id source-template-id)))) + +(defun ebox-native-reflow--display-space-width (properties) + "Return the exact pixel-space width encoded by PROPERTIES, or nil." + (when (and (equal (length properties) 2) + (eq (car properties) 'display)) + (let ((display (cadr properties))) + (when (and (consp display) (eq (car display) 'space)) + (ebox--horizontal-size-pixels + (plist-get (cdr display) :width) nil))))) + +(defun ebox-native-reflow--compile-formatted-clusters (line) + "Compile LINE while preserving exact pixel display spaces." + (let ((position 0) + (length (length line)) + clusters) + (while (< position length) + (let* ((properties (text-properties-at position line)) + (next (or (next-property-change position line) length))) + (if properties + (let ((width (ebox-native-reflow--display-space-width properties))) + (unless (and width (> width 0) + (string-blank-p + (substring-no-properties line position next))) + (error "Native reflow unsupported formatted text properties")) + (push (list :text " " :width width :cjk :false :space t + :pixel-space t) + clusters)) + (dolist (cluster + (append + (ebox--grapheme-clusters + (substring-no-properties line position next)) + nil)) + (push (ebox-native-reflow--compile-cluster cluster) clusters))) + (setq position next))) + (vconcat (nreverse clusters)))) + +(defun ebox-native-reflow--compile-source-clusters (line) + "Compile LINE while preserving arbitrary source text properties opaquely." + (let ((position 0) + (length (length line)) + clusters) + (while (< position length) + (let* ((properties (text-properties-at position line)) + (next (or (next-property-change position line) length)) + (template-id + (ebox-native-reflow--register-property-template + properties "source"))) + (dolist (cluster (append (ebox--grapheme-clusters + (substring line position next)) + nil)) + (setq cluster + (plist-put cluster :text + (substring-no-properties + (plist-get cluster :text)))) + (push (ebox-native-reflow--compile-cluster cluster template-id) + clusters)) + (setq position next))) + (vconcat (nreverse clusters)))) + +(defun ebox-native-reflow--compile-text (text &optional formatted-p) + "Compile TEXT to premeasured line and grapheme IR. +When FORMATTED-P is non-nil, preserve exact display spaces produced by KP." + (list + :lines + (vconcat + (mapcar + (lambda (line) + (list :clusters + (if formatted-p + (ebox-native-reflow--compile-formatted-clusters line) + (ebox-native-reflow--compile-source-clusters line)))) + (ebox-string-lines text))))) + +(defun ebox-native-reflow--compile-size (value axis nil-kind) + "Compile normalized size VALUE for AXIS, using NIL-KIND when absent." + (cond + ((null value) + (if (member nil-kind '("pixels" "lines")) + (list :kind nil-kind :value 0) + (list :kind nil-kind))) + ((numberp value) + (list :kind (if (eq axis 'horizontal) "pixels" "lines") + :value (floor value))) + ((and (consp value) (null (cdr value)) (numberp (car value))) + (list :kind (if (eq axis 'horizontal) "pixels" "lines") + :value (floor (car value)))) + ((memq value '(auto none content min-content max-content fit-content + stretch contain viewport viewport-height)) + (list :kind (symbol-name value))) + ((and (consp value) + (memq (car value) '(viewport viewport-height)) + (null (cdr value))) + (list :kind (symbol-name (car value)))) + ((and (eq axis 'horizontal) + (consp value) + (eq (car value) 'fit-content) + (or (null (cdr value)) + (null (cddr value)))) + (append + (list :kind "fit-content") + (when (cadr value) + (list :limit + (ebox-native-reflow--compile-size + (cadr value) axis "none"))))) + ((and (eq axis 'vertical) + (consp value) + (memq (car value) '(+ -)) + (cdr value)) + (list :kind (if (eq (car value) '+) "add" "subtract") + :values + (vconcat + (mapcar + (lambda (operand) + (ebox-native-reflow--compile-size operand axis "none")) + (cdr value))))) + (t + (error "Native reflow unsupported %s size: %S" axis value)))) + +(defun ebox-native-reflow--symbol-name (value fallback allowed label) + "Return VALUE as an allowed string, or FALLBACK for nil." + (let ((value (or value fallback))) + (unless (memq value allowed) + (error "Native reflow unsupported %s: %S" label value)) + (symbol-name value))) + +(defun ebox-native-reflow--register-style (mode face) + "Return the stable numeric id for a MODE and FACE operation." + (let* ((entry (list :mode mode :face face)) + (existing (cl-position entry ebox-native-reflow--compile-styles + :test #'equal))) + (or existing + (prog1 (length ebox-native-reflow--compile-styles) + (setq ebox-native-reflow--compile-styles + (append ebox-native-reflow--compile-styles + (list entry))))))) + +(defun ebox-native-reflow--compile-add-face-style (face) + "Register additive FACE and return its id, or nil." + (and face (ebox-native-reflow--register-style 'add face))) + +(defun ebox-native-reflow--compile-border-face-style (width color) + "Register the set-face operation for a border of WIDTH and COLOR." + (and (> width 0) + (ebox-native-reflow--register-style + 'set (ebox-buffer-side-border-face color)))) + +(defun ebox-native-reflow--fixed-horizontal-pixels (value) + "Return VALUE's fixed horizontal pixel count, or nil when responsive." + (cond + ((numberp value) (floor value)) + ((and (consp value) (null (cdr value)) (numberp (car value))) + (floor (car value))))) + +(defun ebox-native-reflow--compile-kp-content (box) + "Freeze fixed-width KP BOX content through the exact Elisp formatter. +Knuth-Plass output can contain redistributed display spaces that Rust does not +recompute. A fixed box remains invariant across viewport reflow, so compiling +that already formatted, unwrapped content is exact." + (unless (ebox-native-reflow--fixed-horizontal-pixels + (ebox-get box :width)) + (error "Native reflow requires a fixed width for kp wrapping")) + (ebox--format-content-string box (or (ebox-get box :content) ""))) + +(defun ebox-native-reflow--transparent-stack-content-exact-p + (box source-child) + "Return non-nil when BOX may reuse exact preformatted SOURCE-CHILD lines. +This mirrors the static half of `ebox--render-transparent-preformatted-box'. +Rust still verifies the dynamic equal-width and nonempty-line proof per frame." + (and (eq box ebox-native-reflow--compile-root-node) + (eq (plist-get source-child :ebox-type) 'stack) + (null (ebox-get box :wrap-mode)) + (eq (ebox-get box :text-align) 'left) + (eq (ebox-get box :vertical-align) 'top) + (null (ebox-get box :height)) + (equal (or (ebox-get box :min-height) 0) 0) + (null (ebox-get box :max-height)) + (eq (ebox-get box :overflow) 'scroll) + (cl-every + (lambda (key) (equal (or (ebox-get box key) 0) 0)) + '(:padding-left-pixel :padding-right-pixel + :padding-top-height :padding-bottom-height + :margin-left-pixel :margin-right-pixel + :margin-top-height :margin-bottom-height + :border-left-pixel :border-right-pixel)) + (not (ebox-get box :border-top-p)) + (not (ebox-get box :border-bottom-p)) + (null (ebox-get box :color)) + (null (ebox-get box :bgcolor)))) + +(defun ebox-native-reflow--compile-box + (box &optional child-override child-override-p child-resolved-p) + "Compile normalized Ebox BOX to strict layout IR." + (let* ((source-child (plist-get box :ebox-content-node)) + (child (if (or child-override-p child-resolved-p) + child-override + (and source-child + (ebox-native-reflow--compile-node source-child)))) + (wrap-mode (or (ebox-get box :wrap-mode) 'none)) + (kp-content + (and (not child) + (eq wrap-mode 'kp) + (ebox-native-reflow--compile-kp-content box))) + (region-id (ebox--ensure-region-id box)) + (padding-left (floor (or (ebox-get box :padding-left-pixel) 0))) + (padding-right (floor (or (ebox-get box :padding-right-pixel) 0))) + (padding-top (floor (or (ebox-get box :padding-top-height) 0))) + (padding-bottom (floor (or (ebox-get box :padding-bottom-height) 0))) + (margin-left (floor (or (ebox-get box :margin-left-pixel) 0))) + (margin-right (floor (or (ebox-get box :margin-right-pixel) 0))) + (margin-top (floor (or (ebox-get box :margin-top-height) 0))) + (margin-bottom (floor (or (ebox-get box :margin-bottom-height) 0))) + (border-left (floor (or (ebox-get box :border-left-pixel) 0))) + (border-right (floor (or (ebox-get box :border-right-pixel) 0))) + (foreground-style + (ebox-native-reflow--compile-add-face-style + (and (ebox-get box :color) + (list :foreground (ebox-get box :color))))) + (background-style + (ebox-native-reflow--compile-add-face-style + (and (ebox-get box :bgcolor) + (list :background (ebox-get box :bgcolor))))) + (border-left-style + (ebox-native-reflow--compile-border-face-style + border-left (ebox-get box :border-left-color))) + (border-right-style + (ebox-native-reflow--compile-border-face-style + border-right (ebox-get box :border-right-color))) + (border-top-style + (and (ebox-get box :border-top-p) + (ebox-native-reflow--compile-add-face-style + (list :overline (or (ebox-get box :border-top-color) t))))) + (border-bottom-style + (and (ebox-get box :border-bottom-p) + (ebox-native-reflow--compile-add-face-style + (list :underline + (append (list :position t) + (when (ebox-get box :border-bottom-color) + (list :color + (ebox-get box :border-bottom-color)))))))) + (surface-template-id + (ebox-native-reflow--register-property-template + (ebox-get box :surface-properties) "surface"))) + (list + :type "box" + :region-id region-id + :content (if child + :null + (ebox-native-reflow--compile-text + (or kp-content (ebox-get box :content) "") + (and kp-content t))) + :child (or child :null) + :content-width-exact + (if (or child-override-p + (ebox-native-reflow--transparent-stack-content-exact-p + box source-child)) + t + :false) + :width (ebox-native-reflow--compile-size + (ebox-get box :width) 'horizontal "auto") + :min-width (ebox-native-reflow--compile-size + (ebox-get box :min-width) 'horizontal "pixels") + :max-width (ebox-native-reflow--compile-size + (ebox-get box :max-width) 'horizontal "none") + :height (ebox-native-reflow--compile-size + (ebox-get box :height) 'vertical "auto") + :min-height (ebox-native-reflow--compile-size + (ebox-get box :min-height) 'vertical "lines") + :max-height (ebox-native-reflow--compile-size + (ebox-get box :max-height) 'vertical "none") + :box-sizing + (ebox-native-reflow--symbol-name + (ebox-get box :box-sizing) 'border-box + '(border-box content-box) "box-sizing") + :padding-left padding-left + :padding-right padding-right + :padding-top padding-top + :padding-bottom padding-bottom + :margin-left margin-left + :margin-right margin-right + :margin-top margin-top + :margin-bottom margin-bottom + :border-left border-left + :border-right border-right + :foreground-style (or foreground-style :null) + :background-style (or background-style :null) + :border-left-style (or border-left-style :null) + :border-right-style (or border-right-style :null) + :border-top-style (or border-top-style :null) + :border-bottom-style (or border-bottom-style :null) + :surface-template-id (or surface-template-id :null) + :text-align + (ebox-native-reflow--symbol-name + (if kp-content 'left (ebox-get box :text-align)) + 'left '(left center right) "text-align") + :vertical-align + (ebox-native-reflow--symbol-name + (ebox-get box :vertical-align) 'top '(top center bottom) + "vertical-align") + :overflow + (ebox-native-reflow--symbol-name + (ebox-get box :overflow) 'scroll '(scroll hidden visible) "overflow") + :wrap-mode + (ebox-native-reflow--symbol-name + (if kp-content 'none wrap-mode) 'none '(none word char) "wrap-mode") + :scroll-offset (max 0 (floor (or (ebox-get box :scroll-offset) 0)))))) + +(defun ebox-native-reflow--compile-flex-align (value fallback label) + "Compile flex alignment VALUE with FALLBACK for LABEL." + (ebox-native-reflow--symbol-name + value fallback + '(auto normal stretch flex-start flex-end center start end self-start + self-end left right top bottom baseline space-between space-around + space-evenly) + label)) + +(defun ebox-native-reflow--compile-flex-item + (item axis &optional compiled-node compiled-node-p + content-min-width content-min-width-p) + "Compile flex ITEM metadata and source for AXIS." + (let* ((source (ebox--flex-item-source-node item)) + (props (ebox--flex-item-props item)) + (order (plist-get props :order)) + (grow (plist-get props :flex-grow)) + (shrink (plist-get props :flex-shrink)) + (compiled (if compiled-node-p + compiled-node + (ebox-native-reflow--compile-node source))) + (missing (make-symbol "missing-native-content-min")) + (captured-content-min + (if (and (not content-min-width-p) + (hash-table-p ebox--flex-content-min-width-table)) + (gethash source ebox--flex-content-min-width-table missing) + missing))) + (unless (eq captured-content-min missing) + (setq content-min-width captured-content-min + content-min-width-p t)) + (unless (integerp order) + (error "Native reflow flex order must be an integer: %S" order)) + (unless (and (numberp grow) (>= grow 0) + (numberp shrink) (>= shrink 0)) + (error "Native reflow flex factors must be nonnegative numbers")) + ;; Rust consults this fact only for shrinkable wrapped boxes on a row + ;; axis. The ordinary flex renderer already measured it for the first + ;; visible frame; a flat scene reuses that exact value instead of rendering + ;; the source subtree again during native package construction. + (when (and (eq axis 'row) + (eq (plist-get source :ebox-type) 'box) + (ebox-get source :wrap-mode) + ;; A composite child already exists in the compiled fragment; + ;; Rust derives its intrinsic minimum directly from that child + ;; when the visible Elisp render did not measure it yet. + (or content-min-width-p + (null (plist-get source :ebox-content-node)))) + (unless content-min-width-p + (setq content-min-width (ebox--content-min-pixel source))) + (setq compiled + (plist-put compiled :content-min-width + (max 0 (floor content-min-width))))) + (list :node compiled + :order order + :grow grow + :shrink shrink + :basis + (ebox-native-reflow--compile-size + (plist-get props :flex-basis) + (if (eq axis 'row) 'horizontal 'vertical) + "auto") + :align-self + (ebox-native-reflow--compile-flex-align + (plist-get props :align-self) 'auto "align-self")))) + +(defun ebox-native-reflow--compile-flex + (node &optional fragments flex-content-min-widths) + "Compile normalized flex NODE, translating its wrapper to a box node." + (let* ((wrapper (plist-get node :box)) + (props (ebox--flex-container-content-props + (plist-get node :props) + (plist-get node :raw-props) + wrapper)) + (_dynamic-wrapper-size + (when wrapper + ;; The surrounding native box supplies its resolved content width + ;; to the child for each frame. Do not freeze the compiler's + ;; ambient viewport into a multi-frame layout document. + (plist-put props :width nil) + (when (plist-member (plist-get node :raw-props) :height) + (plist-put props :height 'viewport-height)))) + (axis (ebox--flex-axis props)) + (inner + (list + :type "flex" + :direction + (ebox-native-reflow--symbol-name + (plist-get props :flex-direction) 'row + '(row row-reverse column column-reverse) "flex-direction") + :wrap + (ebox-native-reflow--symbol-name + (plist-get props :flex-wrap) 'nowrap + '(nowrap wrap wrap-reverse) "flex-wrap") + :justify + (ebox-native-reflow--compile-flex-align + (plist-get props :justify-content) 'flex-start + "justify-content") + :align-items + (ebox-native-reflow--compile-flex-align + (plist-get props :align-items) 'stretch "align-items") + :align-content + (ebox-native-reflow--compile-flex-align + (plist-get props :align-content) 'stretch "align-content") + :width + (ebox-native-reflow--compile-size + (plist-get props :width) 'horizontal "auto") + :height + (ebox-native-reflow--compile-size + (plist-get props :height) 'vertical "auto") + :row-gap + (or (ebox--flex-line-value (plist-get props :row-gap) 0) 0) + :column-gap + (or (ebox--horizontal-size-pixels + (plist-get props :column-gap) 0) + 0) + :items + (vconcat + (mapcar (lambda (item) + (if fragments + (let* ((source (ebox--flex-item-source-node item)) + (missing (make-symbol "missing-content-min")) + (content-min + (if flex-content-min-widths + (gethash source + flex-content-min-widths + missing) + missing))) + (ebox-native-reflow--compile-flex-item + item axis + (ebox-native-reflow--compiled-scene-child + fragments source) + t + content-min + (not (eq content-min missing)))) + (ebox-native-reflow--compile-flex-item item axis))) + (plist-get node :children)))))) + (if wrapper + (ebox-native-reflow--compile-box wrapper inner t) + inner))) + +(defun ebox-native-reflow--compile-node (node) + "Compile supported normalized Ebox NODE to strict layout IR." + (pcase (and (listp node) (plist-get node :ebox-type)) + ('box (ebox-native-reflow--compile-box node)) + ('concat + (list :type "row" + :children + (vconcat (mapcar #'ebox-native-reflow--compile-node + (ebox--layout-children node))))) + ('stack + (list :type "column" + :children + (vconcat (mapcar #'ebox-native-reflow--compile-node + (ebox--layout-children node))))) + ('flex (ebox-native-reflow--compile-flex node)) + ('flex-item (ebox-native-reflow--compile-node (plist-get node :node))) + (_ (error "Native reflow unsupported node type: %S" + (and (listp node) (plist-get node :ebox-type)))))) + +(defun ebox-native-reflow--native-node-supported-p (node) + "Return non-nil when NODE contains only native-supported layout nodes. +Grid deliberately stays on the ordinary Ebox renderer until the native +backend has a matching two-dimensional layout contract." + (cond + ((or (null node) (stringp node) (not (listp node))) t) + ((eq (plist-get node :ebox-type) 'grid) nil) + ((eq (plist-get node :ebox-type) 'box) + (ebox-native-reflow--native-node-supported-p + (plist-get node :ebox-content-node))) + ((memq (plist-get node :ebox-type) '(concat stack)) + (cl-every #'ebox-native-reflow--native-node-supported-p + (ebox--layout-children node))) + ((eq (plist-get node :ebox-type) 'flex) + (cl-every #'ebox-native-reflow--native-node-supported-p + (append (when-let ((box (plist-get node :box))) + (list box)) + (plist-get node :children)))) + ((eq (plist-get node :ebox-type) 'flex-item) + (ebox-native-reflow--native-node-supported-p (plist-get node :node))) + (t nil))) + +(defun ebox-native-reflow--compiled-scene-child (fragments node) + "Return NODE's compiled fragment from FRAGMENTS, failing closed." + (let ((missing (make-symbol "missing-native-scene-fragment"))) + (let ((fragment (gethash node fragments missing))) + (when (eq fragment missing) + (error "Native render scene is missing a child fragment")) + fragment))) + +(defun ebox-native-reflow--compile-scene-node + (node fragments flex-content-min-widths) + "Compile one flat scene NODE from already compiled FRAGMENTS." + (pcase (and (listp node) (plist-get node :ebox-type)) + ('box + (let ((child (plist-get node :ebox-content-node))) + (ebox-native-reflow--compile-box + node + (and child + (ebox-native-reflow--compiled-scene-child fragments child)) + nil t))) + ('concat + (list :type "row" + :children + (vconcat + (mapcar + (lambda (child) + (ebox-native-reflow--compiled-scene-child fragments child)) + (ebox--layout-children node))))) + ('stack + (list :type "column" + :children + (vconcat + (mapcar + (lambda (child) + (ebox-native-reflow--compiled-scene-child fragments child)) + (ebox--layout-children node))))) + ('flex + (ebox-native-reflow--compile-flex + node fragments flex-content-min-widths)) + ('flex-item + (ebox-native-reflow--compiled-scene-child + fragments (plist-get node :node))) + (_ (error "Native reflow unsupported scene node type: %S" + (and (listp node) (plist-get node :ebox-type)))))) + +(defun ebox-native-reflow--layout-package-from-root + (root styles &optional property-templates) + "Return one immutable native package from compiled ROOT and STYLES. +PROPERTY-TEMPLATES are Emacs-owned plists addressed by opaque native ids." + (list :document + (list :version 2 + :space-width (ebox--space-pixel-width) + :style-count (length styles) + :property-template-count (length property-templates) + :styles + (vconcat + (mapcar + (lambda (descriptor) + (list :mode + (symbol-name (plist-get descriptor :mode)) + :face (plist-get descriptor :face))) + styles)) + :root root) + :styles (vconcat styles) + :property-templates (vconcat property-templates))) + +(defun ebox-native-reflow-capture-layout-scene (buffer) + "Capture BUFFER's render-owned flat native scene without walking its tree." + (unless (buffer-live-p buffer) + (error "Native layout scene needs a live buffer")) + (let* ((state (ebox--buffer-render-state buffer)) + (root (and state (plist-get state :root-node))) + (postorder (and state (plist-get state :native-node-postorder)))) + (unless (and root (vectorp postorder) (> (length postorder) 0) + (eq root (aref postorder (1- (length postorder))))) + (error "Native layout scene is absent from the render transaction")) + (with-current-buffer buffer + (ebox-native-reflow--make-layout-scene + :buffer buffer + :render-state state + :root root + :node-postorder postorder + :flex-content-min-widths + (plist-get state :flex-content-min-widths) + :display-signature (ebox-display-signature) + :runtime-revision (or (plist-get state :runtime-revision) 0))))) + +(defun ebox-native-reflow-layout-scene-new-builder (scene) + "Return a fresh interruptible builder for captured layout SCENE." + (unless (ebox-native-reflow-layout-scene-p scene) + (error "Native layout builder needs a captured scene")) + (ebox-native-reflow--make-layout-builder + :scene scene + :fragments (make-hash-table :test 'eq) + :styles nil + :index 0 + :flat-node-visits 0 + :tree-node-visits 0 + :string-pixel-width-cache (make-hash-table :test 'equal) + :string-max-pixel-width-cache (make-hash-table :test 'eq) + :property-templates nil + :property-template-ids (make-hash-table :test 'equal))) + +(defun ebox-native-reflow--layout-builder-current-p (builder) + "Return non-nil when BUILDER still identifies its live render scene." + (let* ((scene (ebox-native-reflow-layout-builder-scene builder)) + (buffer (ebox-native-reflow-layout-scene-buffer scene))) + (and (buffer-live-p buffer) + (eq (ebox--buffer-render-state buffer) + (ebox-native-reflow-layout-scene-render-state scene)) + (with-current-buffer buffer + (equal (ebox-display-signature) + (ebox-native-reflow-layout-scene-display-signature + scene)))))) + +(defun ebox-native-reflow-layout-builder-step (builder budget-seconds) + "Advance flat layout BUILDER for at most BUDGET-SECONDS. +Nil BUDGET-SECONDS drains the builder. At least one source node is processed +per call, and no call recursively visits the captured Ebox tree." + (unless (ebox-native-reflow-layout-builder-p builder) + (error "Native layout builder state is invalid")) + (or (ebox-native-reflow-layout-builder-package builder) + (progn + (unless (ebox-native-reflow--layout-builder-current-p builder) + (error "Native layout scene became stale before compilation")) + (let* ((scene (ebox-native-reflow-layout-builder-scene builder)) + (buffer (ebox-native-reflow-layout-scene-buffer scene)) + (nodes (ebox-native-reflow-layout-scene-node-postorder scene)) + (fragments + (ebox-native-reflow-layout-builder-fragments builder)) + (started (float-time)) + processed-p) + (ebox--with-buffer-render-context buffer + (ebox--validate-display-cache) + (let ((ebox--display-cache-validated t) + (ebox--render-display-signature + (ebox-native-reflow-layout-scene-display-signature scene)) + (ebox--render-string-pixel-width-cache + (ebox-native-reflow-layout-builder-string-pixel-width-cache + builder)) + (ebox--render-string-max-pixel-width-cache + (ebox-native-reflow-layout-builder-string-max-pixel-width-cache + builder)) + (ebox-native-reflow--compile-styles + (ebox-native-reflow-layout-builder-styles builder)) + (ebox-native-reflow--compile-property-templates + (ebox-native-reflow-layout-builder-property-templates + builder)) + (ebox-native-reflow--compile-property-template-ids + (ebox-native-reflow-layout-builder-property-template-ids + builder)) + (ebox-native-reflow--compile-root-node + (ebox-native-reflow-layout-scene-root scene))) + (while + (and (< (ebox-native-reflow-layout-builder-index builder) + (length nodes)) + (or (not processed-p) + (null budget-seconds) + (< (- (float-time) started) budget-seconds))) + (let* ((index + (ebox-native-reflow-layout-builder-index builder)) + (node (aref nodes index)) + (fragment + (ebox-native-reflow--compile-scene-node + node fragments + (ebox-native-reflow-layout-scene-flex-content-min-widths + scene)))) + (puthash node fragment fragments) + (setf (ebox-native-reflow-layout-builder-index builder) + (1+ index) + (ebox-native-reflow-layout-builder-flat-node-visits + builder) + (1+ (ebox-native-reflow-layout-builder-flat-node-visits + builder))) + (setq processed-p t))) + (setf (ebox-native-reflow-layout-builder-styles builder) + ebox-native-reflow--compile-styles) + (setf (ebox-native-reflow-layout-builder-property-templates + builder) + ebox-native-reflow--compile-property-templates) + (when (= (ebox-native-reflow-layout-builder-index builder) + (length nodes)) + (setf + (ebox-native-reflow-layout-builder-package builder) + (ebox-native-reflow--layout-package-from-root + (ebox-native-reflow--compiled-scene-child + fragments + (ebox-native-reflow-layout-scene-root scene)) + ebox-native-reflow--compile-styles + ebox-native-reflow--compile-property-templates))))) + (ebox-native-reflow-layout-builder-package builder))))) + +(defun ebox-native-reflow--compile-layout-package (node) + "Compile NODE to a native document plus its Emacs-owned style registry." + (unless (fboundp 'ebox--grapheme-clusters) + (error "Native reflow layout compiler requires Ebox")) + (ebox--with-validated-display-cache + (let ((ebox-native-reflow--compile-styles nil) + (ebox-native-reflow--compile-property-templates nil) + (ebox-native-reflow--compile-property-template-ids + (make-hash-table :test 'equal)) + (ebox-native-reflow--compile-root-node node)) + (let ((root (ebox-native-reflow--compile-node node))) + (ebox-native-reflow--layout-package-from-root + root ebox-native-reflow--compile-styles + ebox-native-reflow--compile-property-templates))))) + +(defun ebox-native-reflow-compile-layout-ir (node) + "Compile NODE to one complete versioned native layout document." + (plist-get (ebox-native-reflow--compile-layout-package node) :document)) + +(defun ebox-native-reflow--serialize-layout-control (control) + "Serialize layout CONTROL without penalizing ordinary wide documents." + (condition-case err + (json-serialize control) + (error + (unless + (equal (error-message-string err) + "Maximum JSON serialization depth exceeded") + (signal (car err) (cdr err))) + (let ((json-false :false) + (json-null :null)) + (encode-coding-string + (json-encode control) 'utf-8-unix))))) + +(defun ebox-native-reflow--layout-control-json (document frames) + "Return strict control JSON for optional layout DOCUMENT and frame contexts." + (unless (and (listp frames) frames) + (error "Native reflow requires at least one layout frame")) + (let ((control + (list + :version 1 + :frames + (vconcat + (mapcar + (lambda (frame) + (let ((key (plist-get frame :key)) + (viewport-width (plist-get frame :viewport-width)) + (viewport-width-known + (if (plist-member frame :viewport-width-known) + (plist-get frame :viewport-width-known) + t)) + (viewport-height (plist-get frame :viewport-height)) + (root-width (or (plist-get frame :root-width) + (plist-get frame :viewport-width))) + (root-width-override-p (plist-member frame :root-width)) + (patch-p (plist-member frame :base-viewport-width)) + (base-viewport-width + (plist-get frame :base-viewport-width)) + (base-viewport-width-known + (if (plist-member frame :base-viewport-width-known) + (plist-get frame :base-viewport-width-known) + t)) + (base-viewport-height + (plist-get frame :base-viewport-height)) + (base-root-width + (or (plist-get frame :base-root-width) + (plist-get frame :base-viewport-width))) + (base-root-width-override-p + (plist-member frame :base-root-width)) + (runtime-revision (or (plist-get frame :runtime-revision) 0)) + (context-hash (or (plist-get frame :context-hash) 0)) + (complete (if (plist-member frame :complete) + (plist-get frame :complete) + t)) + (root-metadata + (if (plist-member frame :root-metadata) + (plist-get frame :root-metadata) + t)) + (delay (or (plist-get frame :delay-ms) 0))) + (unless (integerp key) + (error "Native reflow layout key must be an integer: %S" key)) + (unless (and (integerp viewport-width) (> viewport-width 0) + (integerp viewport-height) (> viewport-height 0)) + (error "Native reflow layout viewport must be positive integers")) + (unless (memq viewport-width-known '(t nil :false)) + (error "Native reflow viewport-width-known flag must be boolean")) + (unless (and (integerp root-width) (> root-width 0)) + (error "Native reflow layout root width must be a positive integer")) + (when patch-p + (unless (and (integerp base-viewport-width) + (> base-viewport-width 0) + (integerp base-viewport-height) + (> base-viewport-height 0) + (integerp base-root-width) + (> base-root-width 0)) + (error "Native reflow patch base geometry must be positive integers"))) + (when patch-p + (unless (memq base-viewport-width-known '(t nil :false)) + (error "Native reflow base viewport-width-known flag must be boolean"))) + (unless (and (integerp runtime-revision) (>= runtime-revision 0)) + (error "Native reflow runtime revision must be nonnegative")) + (unless (integerp context-hash) + (error "Native reflow context hash must be an integer")) + (unless (memq complete '(t nil :false)) + (error "Native reflow complete flag must be boolean")) + (unless (memq root-metadata '(t nil :false)) + (error "Native reflow root-metadata flag must be boolean")) + (append + (list :key key + :viewport-width viewport-width + :viewport-width-known + (if (eq viewport-width-known t) t :false) + :viewport-height viewport-height + :root-width root-width + :root-width-override + (if root-width-override-p t :false) + :patch (if patch-p t :false) + :runtime-revision runtime-revision + :context-hash context-hash + :complete (if (eq complete t) t :false) + :root-metadata + (if (eq root-metadata t) t :false) + :delay-ms delay) + (when patch-p + (list + :base-viewport-width base-viewport-width + :base-viewport-width-known + (if (eq base-viewport-width-known t) t :false) + :base-viewport-height base-viewport-height + :base-root-width base-root-width + :base-root-width-override + (if base-root-width-override-p t :false)))))) + frames))))) + (when document + (setq control (plist-put control :document document))) + (ebox-native-reflow--serialize-layout-control control))) + +(defun ebox-native-reflow-submit-layout + (session generation node frames &optional layout-package) + "Compile NODE and submit all layout FRAMES for SESSION GENERATION. +LAYOUT-PACKAGE may reuse a caller-validated document for the same NODE." + (unless (and (integerp generation) (>= generation 0)) + (error "Native reflow generation must be a nonnegative integer")) + (let* ((package (or layout-package + (ebox-native-reflow--compile-layout-package node))) + (register-layout-p + (not (eq package + (ebox-native-reflow-session-layout-package session)))) + (accepted + (ebox-native--module-submit + (ebox-native-reflow--live-handle session) + generation + (ebox-native-reflow--layout-control-json + (and register-layout-p (plist-get package :document)) frames)))) + (setf (ebox-native-reflow-session-generation session) generation) + (setf (ebox-native-reflow-session-styles session) + (plist-get package :styles)) + (setf (ebox-native-reflow-session-layout-package session) package) + accepted)) + +(defun ebox-native-reflow--tape-cursor (payload &optional position) + "Return a bounded binary cursor over unibyte tape PAYLOAD." + (unless (and (stringp payload) (not (multibyte-string-p payload))) + (error "Native reflow tape must be an unibyte string")) + (vector payload (or position 0) (length payload))) + +(defun ebox-native-reflow--tape-require (cursor count) + "Require COUNT readable bytes at CURSOR and return its position." + (let ((position (aref cursor 1)) + (end (aref cursor 2))) + (unless (and (integerp count) (>= count 0) + (<= count (- end position))) + (error "Native reflow tape is truncated")) + position)) + +(defun ebox-native-reflow--tape-read-u16 (cursor) + "Read one little-endian unsigned 16-bit value from CURSOR." + (let* ((position (ebox-native-reflow--tape-require cursor 2)) + (bytes (aref cursor 0)) + (value (+ (aref bytes position) + (ash (aref bytes (1+ position)) 8)))) + (aset cursor 1 (+ position 2)) + value)) + +(defun ebox-native-reflow--tape-read-u8 (cursor) + "Read one unsigned byte from CURSOR." + (let* ((position (ebox-native-reflow--tape-require cursor 1)) + (value (aref (aref cursor 0) position))) + (aset cursor 1 (1+ position)) + value)) + +(defun ebox-native-reflow--tape-read-u32 (cursor) + "Read one little-endian unsigned 32-bit value from CURSOR." + (let* ((position (ebox-native-reflow--tape-require cursor 4)) + (bytes (aref cursor 0)) + (value (+ (aref bytes position) + (ash (aref bytes (+ position 1)) 8) + (ash (aref bytes (+ position 2)) 16) + (ash (aref bytes (+ position 3)) 24)))) + (aset cursor 1 (+ position 4)) + value)) + +(defun ebox-native-reflow--tape-read-u64 (cursor) + "Read one little-endian unsigned 64-bit value from CURSOR." + (let* ((position (ebox-native-reflow--tape-require cursor 8)) + (bytes (aref cursor 0)) + (value 0)) + (dotimes (index 8) + (setq value (+ value (ash (aref bytes (+ position index)) + (* index 8))))) + (aset cursor 1 (+ position 8)) + value)) + +(defun ebox-native-reflow--tape-read-i64 (cursor) + "Read one little-endian signed 64-bit value from CURSOR." + (let ((value (ebox-native-reflow--tape-read-u64 cursor))) + (if (>= value #x8000000000000000) + (- value #x10000000000000000) + value))) + +(defun ebox-native-reflow--tape-read-bytes (cursor count) + "Read COUNT raw bytes from CURSOR." + (let* ((position (ebox-native-reflow--tape-require cursor count)) + (end (+ position count)) + (bytes (substring (aref cursor 0) position end))) + (aset cursor 1 end) + bytes)) + +(defun ebox-native-reflow--tape-decode-utf8 (bytes label) + "Strictly decode UTF-8 BYTES for LABEL." + (let ((decoded + (condition-case nil + (decode-coding-string bytes 'utf-8 t) + (error nil)))) + (unless (and decoded + (equal bytes (encode-coding-string decoded 'utf-8 t))) + (error "Native reflow tape has invalid UTF-8 %s" label)) + decoded)) + +(defun ebox-native-reflow--tape-read-header (cursor styles expected) + "Read and validate a fixed tape header from CURSOR. +STYLES is the Emacs-owned style vector and EXPECTED is an optional identity +plist supplied by the caller." + (let* ((magic (ebox-native-reflow--tape-read-bytes cursor 4)) + (version (ebox-native-reflow--tape-read-u16 cursor)) + (flags (ebox-native-reflow--tape-read-u16 cursor)) + (header-length (ebox-native-reflow--tape-read-u32 cursor)) + (total-length (ebox-native-reflow--tape-read-u64 cursor)) + (session-id (ebox-native-reflow--tape-read-u64 cursor)) + (generation (ebox-native-reflow--tape-read-u64 cursor)) + (key (ebox-native-reflow--tape-read-i64 cursor)) + (runtime-revision (ebox-native-reflow--tape-read-u64 cursor)) + (context-hash (ebox-native-reflow--tape-read-i64 cursor)) + (viewport-width (ebox-native-reflow--tape-read-i64 cursor)) + (viewport-height (ebox-native-reflow--tape-read-i64 cursor)) + (root-width (ebox-native-reflow--tape-read-i64 cursor)) + (style-count (ebox-native-reflow--tape-read-u32 cursor)) + (line-count (ebox-native-reflow--tape-read-u32 cursor)) + (character-count (ebox-native-reflow--tape-read-u64 cursor)) + (body-length (ebox-native-reflow--tape-read-u64 cursor)) + (reserved (ebox-native-reflow--tape-read-u32 cursor)) + (ok-p (not (zerop (logand flags ebox-native-reflow--tape-flag-ok)))) + (complete-p + (not (zerop (logand flags ebox-native-reflow--tape-flag-complete)))) + (patch-p + (not (zerop (logand flags ebox-native-reflow--tape-flag-patch)))) + (header + (list :session-id session-id :generation generation :key key + :runtime-revision runtime-revision + :context-hash context-hash + :viewport-width viewport-width + :viewport-height viewport-height :root-width root-width + :style-count style-count :line-count line-count + :character-count character-count :complete complete-p + :patch patch-p :ok ok-p))) + (unless (equal magic ebox-native-reflow--tape-magic) + (error "Native reflow tape magic mismatch")) + (unless (= version ebox-native-reflow--tape-version) + (error "Native reflow tape version mismatch: %S" version)) + (unless (zerop (logand flags + (lognot + (logior ebox-native-reflow--tape-flag-ok + ebox-native-reflow--tape-flag-complete + ebox-native-reflow--tape-flag-patch)))) + (error "Native reflow tape has unknown flags")) + (unless (= header-length ebox-native-reflow--tape-header-length) + (error "Native reflow tape header length mismatch")) + (unless (and (= total-length (aref cursor 2)) + (= body-length (- total-length header-length))) + (error "Native reflow tape byte length mismatch")) + (unless (zerop reserved) + (error "Native reflow tape header has reserved bits")) + (unless (and (> session-id 0) (> viewport-width 0) + (> viewport-height 0) (> root-width 0)) + (error "Native reflow tape has invalid frame identity")) + (dolist (field '(:session-id :generation :key :runtime-revision + :context-hash :viewport-width :viewport-height + :root-width :complete)) + (when (and expected (plist-member expected field) + (not (equal (plist-get expected field) + (plist-get header field)))) + (error "Native reflow tape identity mismatch for %S" field))) + (if ok-p + (progn + (unless (> line-count 0) + (error "Native reflow tape has no lines")) + (unless (= style-count (length styles)) + (error "Native reflow tape style table mismatch"))) + (unless (and (zerop style-count) (zerop line-count) + (zerop character-count) (not patch-p)) + (error "Native reflow error tape has invalid counts"))) + header)) + +(defun ebox-native-reflow--tape-style-faces (styles) + "Return validated face values represented by style vector STYLES." + (mapcar + (lambda (descriptor) + (unless (and (listp descriptor) + (memq (plist-get descriptor :mode) '(set add)) + (plist-member descriptor :face)) + (error "Native reflow has an invalid style descriptor")) + (plist-get descriptor :face)) + (append styles nil))) + +(defun ebox-native-reflow--tape-valid-face-p (face style-faces) + "Return non-nil when FACE is composed only from STYLE-FACES." + (or (cl-find face style-faces :test #'equal) + (and (proper-list-p face) + (> (length face) 1) + (cl-every + (lambda (entry) + (cl-find entry style-faces :test #'equal)) + face)))) + +(defun ebox-native-reflow--property-template-symbols (property-templates) + "Return property symbols allowed by PROPERTY-TEMPLATES." + (let (symbols) + (when property-templates + (dotimes (index (length property-templates)) + (let ((tail (plist-get (aref property-templates index) :properties))) + (ebox-native-reflow--validate-property-template + tail "materialized") + (while tail + (cl-pushnew (pop tail) symbols) + (pop tail))))) + symbols)) + +(defun ebox-native-reflow--face-list (face) + "Return FACE as a face property list preserving priority." + (cond + ((null face) nil) + ((and (listp face) (not (keywordp (car face)))) face) + (t (list face)))) + +(defun ebox-native-reflow--apply-source-face (rendered start end face) + "Prepend source FACE to RENDERED from START to END." + (let ((position start)) + (while (< position end) + (let* ((next (or (next-single-property-change + position 'face rendered end) + end)) + (existing (get-text-property position 'face rendered)) + (combined + (if existing + (append (ebox-native-reflow--face-list face) + (ebox-native-reflow--face-list existing)) + face))) + (put-text-property position next 'face combined rendered) + (setq position (max next (1+ position))))))) + +(defun ebox-native-reflow--add-absent-nil-source-property + (rendered start end property) + "Add nil source PROPERTY only where RENDERED has no such property." + (let ((position start)) + (while (< position end) + (let* ((next (or (next-property-change position rendered end) end)) + (properties (text-properties-at position rendered))) + (unless (plist-member properties property) + (add-text-properties + position next (list property nil) rendered)) + (setq position (max next (1+ position))))))) + +(defun ebox-native-reflow--apply-property-template-span + (rendered start end template) + "Apply opaque TEMPLATE to RENDERED from START to END." + (let ((kind (plist-get template :kind)) + (tail (plist-get template :properties))) + (while tail + (let ((property (pop tail)) + (value (pop tail)) + (position start)) + (if (and (eq kind 'source) (null value)) + (ebox-native-reflow--add-absent-nil-source-property + rendered start end property) + (when value + (if (and (eq kind 'source) (eq property 'face)) + (ebox-native-reflow--apply-source-face + rendered start end value) + (while (< position end) + (let ((next (or (next-single-property-change + position property rendered end) + end))) + (unless (get-text-property position property rendered) + (add-text-properties + position next (list property value) rendered)) + (setq position (max next (1+ position)))))))))))) + +(defun ebox-native-reflow--expand-property-templates + (rendered property-templates) + "Expand native opaque template ids in RENDERED using PROPERTY-TEMPLATES." + (let ((position 0) + (limit (length rendered)) + (template-count (and property-templates (length property-templates)))) + (while (< position limit) + (let* ((ids + (get-text-property + position 'ebox-native-property-template-ids rendered)) + (next + (or (next-single-property-change + position 'ebox-native-property-template-ids rendered limit) + limit))) + (when ids + (unless (and property-templates + (proper-list-p ids) + ids + (cl-every + (lambda (id) + (and (integerp id) (>= id 0) + (< id template-count))) + ids)) + (error "Native reflow tape has invalid property template ids")) + (remove-text-properties + position next '(ebox-native-property-template-ids nil) rendered) + (dolist (id ids) + (ebox-native-reflow--apply-property-template-span + rendered position next (aref property-templates id)))) + (setq position next))) + rendered)) + +(defun ebox-native-reflow--validate-literal-properties + (rendered styles complete-p &optional property-templates) + "Validate all text properties in RENDERED against STYLES and COMPLETE-P." + (let ((style-faces (ebox-native-reflow--tape-style-faces styles)) + (template-symbols + (ebox-native-reflow--property-template-symbols property-templates)) + (position 0) + (limit (length rendered))) + (while (< position limit) + (let ((properties (text-properties-at position rendered))) + (unless (and (proper-list-p properties) + (zerop (% (length properties) 2))) + (error "Native reflow tape has a malformed property list")) + (while properties + (let ((property (pop properties)) + (value (pop properties))) + (pcase property + ((guard (memq property template-symbols)) + nil) + ('face + (unless (ebox-native-reflow--tape-valid-face-p + value style-faces) + (error "Native reflow tape has an invalid face value"))) + ('display + (unless (and (proper-list-p value) + (= (length value) 3) + (eq (nth 0 value) 'space) + (eq (nth 1 value) :width) + (proper-list-p (nth 2 value)) + (= (length (nth 2 value)) 1) + (integerp (car (nth 2 value))) + (> (car (nth 2 value)) 0)) + (error "Native reflow tape has an invalid display value"))) + ('ebox-content-idx + (unless (and complete-p (integerp value) (>= value 0)) + (error "Native reflow tape has an invalid content index"))) + ('ebox-content-owners + (unless (and complete-p (proper-list-p value) value + (cl-every + (lambda (region-id) + (and (integerp region-id) (> region-id 0))) + value)) + (error "Native reflow tape has an invalid owner stack"))) + ((or 'ebox-content 'ebox-content-owner 'ebox-scroll-window + 'ebox-pl 'ebox-pr 'ebox-pt 'ebox-pb + 'ebox-bl 'ebox-br 'ebox-bt 'ebox-bb + 'ebox-ml 'ebox-mr 'ebox-mt 'ebox-mb) + (unless (and complete-p (integerp value) (> value 0)) + (error "Native reflow tape has an invalid region property"))) + (_ + (error "Native reflow tape has an unknown property: %S" + property))))) + (setq position (next-property-change position rendered limit)))))) + +(defun ebox-native-reflow--patch-payload-metadata + (metadata metadata-record-count target-character-count complete-p) + "Validate patch METADATA against its declared record count." + (cond + ((null metadata) + (unless (zerop metadata-record-count) + (error "Native reflow patch metadata count has no payload")) + nil) + ((not complete-p) + (error "Native reflow light tape contains root metadata")) + ((> metadata-record-count most-positive-fixnum) + (error "Native reflow tape has too many metadata records")) + (t + (ebox-native-reflow--validate-root-metadata + metadata metadata-record-count target-character-count)))) + +(defun ebox-native-reflow--patch-payload-replacements (text descriptors) + "Split aggregate patch TEXT into replacement strings for DESCRIPTORS." + (let ((start 0) + replacements) + (dolist (patch descriptors (nreverse replacements)) + (let ((end (+ start (- (plist-get patch :new-end) + (plist-get patch :new-start))))) + (let ((replacement (substring text start end))) + (plist-put patch :replacement replacement) + (push replacement replacements)) + (setq start end))))) + +(defun ebox-native-reflow--tape-read-patch-payload + (cursor descriptors styles complete-p target-character-count + metadata-record-count trusted-native-encoder-p property-templates) + "Read one aggregate patch payload vector from CURSOR." + (let* ((literal (ebox-native-reflow--tape-decode-utf8 + (ebox-native-reflow--tape-read-bytes + cursor (- (aref cursor 2) (aref cursor 1))) + "patch payload literal")) + (read-circle t) + (read-symbol-shorthands nil) + (parsed + (condition-case err + (read-from-string literal) + (error + (error "Native reflow patch payload literal is invalid: %s" + (error-message-string err))))) + (payload (car parsed))) + (unless (= (cdr parsed) (length literal)) + (error "Native reflow patch payload literal has trailing data")) + (unless (and (vectorp payload) (= (length payload) 2) + (stringp (aref payload 0))) + (error "Native reflow patch payload shape mismatch")) + (let* ((text (aref payload 0)) + (expected (cl-loop for patch in descriptors + sum (- (plist-get patch :new-end) + (plist-get patch :new-start))))) + (unless (= (length text) expected) + (error "Native reflow patch replacement length mismatch")) + (unless trusted-native-encoder-p + (ebox-native-reflow--validate-literal-properties + text styles complete-p property-templates)) + (ebox-native-reflow--expand-property-templates text property-templates) + (list + :replacements (ebox-native-reflow--patch-payload-replacements + text descriptors) + :metadata + (ebox-native-reflow--patch-payload-metadata + (aref payload 1) metadata-record-count target-character-count + complete-p))))) + +(defun ebox-native-reflow--root-metadata-point-span-p + (span character-count) + "Return non-nil when SPAN is a valid one-based root point range." + (and (consp span) + (integerp (car span)) (integerp (cdr span)) + (<= 1 (car span)) (< (car span) (cdr span)) + (<= (cdr span) (1+ character-count)))) + +(defun ebox-native-reflow--validate-root-role-template + (table character-count) + "Validate root role TABLE and return its semantic record count." + (unless (and (hash-table-p table) (eq (hash-table-test table) 'equal)) + (error "Native reflow tape has an invalid role metadata table")) + (let ((count 0)) + (maphash + (lambda (key spans) + (unless (and (consp key) (consp (cdr key)) (null (cddr key)) + (integerp (car key)) (> (car key) 0) + (memq (cadr key) + ebox-native-reflow--tape-metadata-role-symbols) + (consp spans)) + (error "Native reflow tape has invalid role metadata")) + (while (consp spans) + (unless (ebox-native-reflow--root-metadata-point-span-p + (car spans) character-count) + (error "Native reflow tape has invalid role metadata")) + (setq count (1+ count) + spans (cdr spans))) + (unless (null spans) + (error "Native reflow tape has invalid role metadata"))) + table) + count)) + +(defun ebox-native-reflow--validate-root-extent-template + (table character-count) + "Validate root extent TABLE and return its semantic record count." + (unless (and (hash-table-p table) (eq (hash-table-test table) 'equal)) + (error "Native reflow tape has an invalid extent metadata table")) + (maphash + (lambda (region-id span) + (unless (and (integerp region-id) (> region-id 0) + (ebox-native-reflow--root-metadata-point-span-p + span character-count)) + (error "Native reflow tape has invalid extent metadata"))) + table) + (hash-table-count table)) + +(defun ebox-native-reflow--validate-root-scroll-template + (table character-count) + "Validate root scroll TABLE and return its semantic record count." + (unless (and (hash-table-p table) (eq (hash-table-test table) 'equal)) + (error "Native reflow tape has an invalid scroll metadata table")) + (let ((count 0)) + (maphash + (lambda (region-id records) + (unless (and (integerp region-id) (> region-id 0) + (consp records)) + (error "Native reflow tape has invalid scroll metadata")) + (while (consp records) + (let ((record (car records))) + (unless (and (consp record) (integerp (car record)) + (>= (car record) 0) (consp (cdr record)) + (integerp (cadr record)) (>= (cadr record) 0) + (integerp (cddr record)) + (< (cadr record) (cddr record)) + (<= (cddr record) character-count)) + (error "Native reflow tape has invalid scroll metadata"))) + (setq count (1+ count) + records (cdr records))) + (unless (null records) + (error "Native reflow tape has invalid scroll metadata"))) + table) + count)) + +(defun ebox-native-reflow--validate-root-metadata + (metadata record-count character-count) + "Validate precompiled METADATA against its declared dimensions." + (pcase metadata + (`(:prepared-p t + :role-span-template ,roles + :box-extent-template ,extents + :scroll-content-span-template ,scroll + :scroll-window-p ,(and window-p (or 'nil 't))) + (let ((actual-count + (+ (ebox-native-reflow--validate-root-role-template + roles character-count) + (ebox-native-reflow--validate-root-extent-template + extents character-count) + (ebox-native-reflow--validate-root-scroll-template + scroll character-count) + (if window-p 1 0)))) + (unless (= actual-count record-count) + (error "Native reflow tape metadata record count mismatch"))) + metadata) + (_ + (error "Native reflow tape has invalid prepared root metadata")))) + +(defun ebox-native-reflow--tape-read-root-metadata + (cursor metadata-byte-count record-count character-count complete-p + &optional _trusted-native-encoder-p) + "Read and validate one precompiled root metadata literal from CURSOR." + (cond + ((zerop metadata-byte-count) + (unless (zerop record-count) + (error "Native reflow tape metadata count has no payload")) + nil) + ((not complete-p) + (error "Native reflow light tape contains root metadata")) + ((> record-count most-positive-fixnum) + (error "Native reflow tape has too many metadata records")) + (t + (let* ((bytes (ebox-native-reflow--tape-read-bytes + cursor metadata-byte-count)) + (literal (ebox-native-reflow--tape-decode-utf8 + bytes "root metadata")) + (read-circle t) + (read-symbol-shorthands nil) + (parsed + (condition-case err + (read-from-string literal) + (error + (error "Native reflow root metadata is invalid: %s" + (error-message-string err))))) + (metadata (car parsed))) + (unless (= (cdr parsed) (length literal)) + (error "Native reflow root metadata has trailing data")) + (ebox-native-reflow--validate-root-metadata + metadata record-count character-count))))) + +(defun ebox-native-reflow--preflight-layout-patch-tape + (cursor header styles trusted-native-encoder-p property-templates) + "Decode one Rust-origin minimal patch body from CURSOR. +HEADER supplies the already validated target identity. STYLES owns the face + registry. TRUSTED-NATIVE-ENCODER-P is retained for full-tape + compatibility; exact native patch literals trust the ABI-matched encoder's + property intervals after reader, type, trailing-data, and length checks." + (ebox-native-reflow--tape-require + cursor ebox-native-reflow--tape-patch-body-preamble-length) + (let* ((base-character-count (ebox-native-reflow--tape-read-u64 cursor)) + (patch-count (ebox-native-reflow--tape-read-u32 cursor)) + (reserved (ebox-native-reflow--tape-read-u32 cursor)) + (metadata-record-count (ebox-native-reflow--tape-read-u64 cursor)) + (payload-byte-length (ebox-native-reflow--tape-read-u64 cursor)) + (target-character-count (plist-get header :character-count)) + (descriptor-bytes + (* patch-count ebox-native-reflow--tape-patch-descriptor-length)) + (required-bytes (+ descriptor-bytes payload-byte-length))) + (unless (zerop reserved) + (error "Native reflow patch reserved field is nonzero")) + (unless (= required-bytes (- (aref cursor 2) (aref cursor 1))) + (error "Native reflow patch body length mismatch")) + (let ((descriptors nil) + (previous-old-end 0) + (previous-new-end 0)) + (dotimes (_ patch-count) + (let ((old-start (ebox-native-reflow--tape-read-u64 cursor)) + (old-end (ebox-native-reflow--tape-read-u64 cursor)) + (new-start (ebox-native-reflow--tape-read-u64 cursor)) + (new-end (ebox-native-reflow--tape-read-u64 cursor))) + (unless (and (<= previous-old-end old-start old-end + base-character-count) + (<= previous-new-end new-start new-end + target-character-count) + (= (- old-start previous-old-end) + (- new-start previous-new-end)) + (or (< old-start old-end) (< new-start new-end))) + (error "Native reflow patch has invalid or unaligned ranges")) + (push (list :old-start old-start :old-end old-end + :new-start new-start :new-end new-end) + descriptors) + (setq previous-old-end old-end + previous-new-end new-end))) + (unless (= (- base-character-count previous-old-end) + (- target-character-count previous-new-end)) + (error "Native reflow patch target length is inconsistent")) + (setq descriptors (nreverse descriptors)) + (let* ((payload-end (+ (aref cursor 1) payload-byte-length)) + (payload-cursor + (vector (aref cursor 0) (aref cursor 1) payload-end)) + (payload + (ebox-native-reflow--tape-read-patch-payload + payload-cursor descriptors styles + (plist-get header :complete) target-character-count + metadata-record-count trusted-native-encoder-p + property-templates))) + (unless (= (aref payload-cursor 1) payload-end) + (error "Native reflow patch body has trailing bytes")) + (aset cursor 1 payload-end) + (unless (= (aref cursor 1) (aref cursor 2)) + (error "Native reflow patch tape has trailing bytes")) + (list :native-patch t + :patch-origin 'rust + :base-character-count base-character-count + :target-character-count target-character-count + :target-line-count (plist-get header :line-count) + :patches descriptors + :root-render-metadata (plist-get payload :metadata) + :metadata-record-count metadata-record-count + :replacement-bytes + (cl-loop for replacement in (plist-get payload :replacements) + sum (string-bytes replacement)) + :full-frame-bytes 0))))) + +(defun ebox-native-reflow--preflight-layout-tape + (payload styles expected &optional trusted-native-encoder-p + property-templates) + "Validate and decode one versioned Rust materialization tape PAYLOAD." + (unless (vectorp styles) + (error "Native reflow styles must be a vector")) + (let* ((cursor (ebox-native-reflow--tape-cursor payload)) + (header (ebox-native-reflow--tape-read-header + cursor styles expected)) + (ok-p (plist-get header :ok))) + (unless ok-p + (let* ((message-length (ebox-native-reflow--tape-read-u32 cursor)) + (message-bytes + (ebox-native-reflow--tape-read-bytes cursor message-length))) + (unless (= (aref cursor 1) (aref cursor 2)) + (error "Native reflow error tape has trailing bytes")) + (error "Native reflow layout failed: %s" + (ebox-native-reflow--tape-decode-utf8 + message-bytes "error message")))) + (if (plist-get header :patch) + (ebox-native-reflow--preflight-layout-patch-tape + cursor header styles trusted-native-encoder-p property-templates) + (ebox-native-reflow--tape-require + cursor ebox-native-reflow--tape-body-preamble-length) + (let* ((literal-byte-length (ebox-native-reflow--tape-read-u64 cursor)) + (line-width-count (ebox-native-reflow--tape-read-u32 cursor)) + (metadata-byte-length (ebox-native-reflow--tape-read-u32 cursor)) + (metadata-record-count (ebox-native-reflow--tape-read-u64 cursor)) + (line-count (plist-get header :line-count)) + (declared-characters (plist-get header :character-count)) + (required-bytes (+ (* line-width-count 8) + literal-byte-length + metadata-byte-length))) + (unless (= line-width-count line-count) + (error "Native reflow tape body metadata mismatch")) + (unless (= required-bytes (- (aref cursor 2) (aref cursor 1))) + (error "Native reflow tape body length mismatch")) + (let ((line-widths (make-vector line-count 0))) + (dotimes (index line-count) + (let ((width (ebox-native-reflow--tape-read-u64 cursor))) + (unless (<= width most-positive-fixnum) + (error "Native reflow tape line width is too large")) + (aset line-widths index width))) + (let* ((literal-bytes + (ebox-native-reflow--tape-read-bytes + cursor literal-byte-length)) + (literal + (ebox-native-reflow--tape-decode-utf8 + literal-bytes "materialized literal")) + (read-circle t) + (read-symbol-shorthands nil) + (parsed + (condition-case err + (read-from-string literal) + (error + (error "Native reflow tape literal is invalid: %s" + (error-message-string err))))) + (rendered (car parsed))) + (unless (and (stringp rendered) (= (cdr parsed) (length literal))) + (error "Native reflow tape literal has trailing or non-string data")) + (unless (= (length rendered) declared-characters) + (error "Native reflow tape character count mismatch")) + (unless (= (cl-count ?\n rendered) (1- line-count)) + (error "Native reflow tape has invalid line break metadata")) + (ebox-native-reflow--expand-property-templates + rendered property-templates) + (let ((metadata + (ebox-native-reflow--tape-read-root-metadata + cursor metadata-byte-length metadata-record-count + declared-characters (plist-get header :complete) + trusted-native-encoder-p))) + (unless (= (aref cursor 1) (aref cursor 2)) + (error "Native reflow tape has trailing bytes")) + (vector header rendered line-widths metadata)))))))) + +(defun ebox-native-reflow--materialize-layout-tape + (payload &optional styles expected trusted-native-encoder-p + property-templates) + "Validate and materialize binary layout tape PAYLOAD. +STYLES supplies Emacs-owned face templates. EXPECTED optionally constrains +the identity header before any result string is allocated. When +TRUSTED-NATIVE-ENCODER-P is non-nil, trust the ABI-matched Rust encoder's +typed line-width and property invariants instead of repeating them in Emacs." + (let* ((styles (or styles [])) + (decoded (ebox-native-reflow--preflight-layout-tape + payload styles expected trusted-native-encoder-p + property-templates))) + (if (plist-get decoded :native-patch) + (let* ((patch-count (length (plist-get decoded :patches))) + (metadata-count + (or (plist-get decoded :metadata-record-count) 0)) + (retained-estimate + (+ (or (plist-get decoded :replacement-bytes) 0) + (* patch-count 128) + (* metadata-count 64)))) + (plist-put decoded :tape-bytes (string-bytes payload)) + (plist-put decoded :artifact-byte-estimate + (max (string-bytes payload) retained-estimate)) + decoded) + (let ((header (aref decoded 0)) + (rendered (aref decoded 1)) + (line-widths (aref decoded 2)) + (metadata (aref decoded 3))) + (unless trusted-native-encoder-p + (ebox-native-reflow--validate-literal-properties + rendered styles (plist-get header :complete) + property-templates) + (let ((lines (ebox-string-lines rendered))) + (unless (= (length lines) (plist-get header :line-count)) + (error "Native reflow materialized line count mismatch")) + (cl-loop for line in lines + for index from 0 + unless (= (ebox-string-pixel-width line) + (aref line-widths index)) + do (error "Native reflow tape line width mismatch")))) + (when (and trusted-native-encoder-p metadata + (fboundp 'ebox--register-rendered-root-metadata)) + (ebox--register-rendered-root-metadata rendered metadata)) + rendered)))) + +(defun ebox-native-reflow-take-layout + (session generation key &optional expected) + "Take and materialize SESSION's semantic layout result. +EXPECTED may add exact tape identity fields beyond GENERATION and KEY." + (when-let ((payload + (ebox-native--module-take + (ebox-native-reflow--live-handle session) generation key))) + (let ((identity (copy-sequence expected))) + (setq identity (plist-put identity :generation generation) + identity (plist-put identity :key key)) + ;; The ABI-matched Rust encoder constructs this literal only from typed, + ;; bounded layout data. Re-scanning every property interval here costs + ;; more than one 50 ms frame on the maintained flex fixture; corruption + ;; tests exercise the same materializer without this trust flag. + (ebox-native-reflow--materialize-layout-tape + payload (ebox-native-reflow-session-styles session) identity t + (plist-get (ebox-native-reflow-session-layout-package session) + :property-templates))))) + +(defun ebox-native-reflow-render-proof-sync + (node frame &optional layout-package) + "Render NODE once through the native synchronous proof path. +FRAME supplies positive viewport geometry and optional `:root-width'. The +result is a fully materialized propertized string; native-owned property +template ids are expanded before returning." + (when (and (ebox-native-reflow-layout-ready-p) + (fboundp 'ebox-native--module-render-proof)) + (let* ((package (or layout-package + (ebox-native-reflow--compile-layout-package node))) + (native-key (or (plist-get frame :key) 1)) + (control-frame (copy-sequence frame))) + (setq control-frame (plist-put control-frame :key native-key)) + (let* ((payload + (ebox-native--module-render-proof + (ebox-native-reflow--layout-control-json + (plist-get package :document) + (list control-frame)))) + (expected + (list :generation 0 + :key native-key + :runtime-revision + (or (plist-get control-frame :runtime-revision) 0) + :context-hash + (or (plist-get control-frame :context-hash) 0) + :viewport-width (plist-get control-frame :viewport-width) + :viewport-height + (plist-get control-frame :viewport-height) + :root-width + (or (plist-get control-frame :root-width) + (plist-get control-frame :viewport-width)) + :complete + (if (plist-member control-frame :complete) + (plist-get control-frame :complete) + t)))) + (ebox-native-reflow--materialize-layout-tape + payload (plist-get package :styles) expected t + (plist-get package :property-templates)))))) + +(defun ebox-native-reflow-render-owner-proof + (node snapshot _dirty context) + "Return a native proof render for NODE and CONTEXT, or nil when unsupported." + (when (and (not (plist-get context :root-owner-p)) + (ebox-native-reflow--native-node-supported-p node) + (ebox-native-reflow-layout-ready-p) + (>= (length (plist-get snapshot :buffer-spans)) 2)) + (let* ((details-function (plist-get context :details-function)) + (details + (if (functionp details-function) + (funcall details-function) + context)) + (stored-viewport-height (plist-get details :viewport-height)) + (viewport-height + (cond + ((and (numberp stored-viewport-height) + (> stored-viewport-height 0)) + (truncate stored-viewport-height)) + ((not (plist-get details :viewport-height-dependent-p)) + (max 1 (length (plist-get snapshot :buffer-spans)))))) + (node-type (and (listp node) (plist-get node :ebox-type))) + (exact-slot-width (plist-get details :parent-content-width)) + (viewport-width-known-p + (and (numberp exact-slot-width) (> exact-slot-width 0))) + (slot-width + (or (and viewport-width-known-p + (truncate exact-slot-width)) + (plist-get details :slot-width-placeholder)))) + (when (and (plist-get details :side-effects-safe-p) + (integerp viewport-height) (> viewport-height 0) + (integerp slot-width) (> slot-width 0)) + (condition-case err + (let ((frame + (list :key 1 + :viewport-width slot-width + :viewport-width-known viewport-width-known-p + :viewport-height viewport-height + :runtime-revision + (or (plist-get details :runtime-revision) 0) + :context-hash 0 + :complete t + :root-metadata nil))) + (when (and viewport-width-known-p (eq node-type 'box)) + (setq frame (plist-put frame :root-width slot-width))) + (setq ebox-native-reflow--last-owner-proof-error nil) + (ebox-native-reflow-render-proof-sync node frame)) + (error + (setq ebox-native-reflow--last-owner-proof-error + (error-message-string err)) + (signal (car err) (cdr err)))))))) + +(defun ebox-native-reflow-submit (session generation frames) + "Submit FRAMES for SESSION GENERATION without waiting for results." + (unless (and (integerp generation) (>= generation 0)) + (error "Native reflow generation must be a nonnegative integer")) + (let ((accepted + (ebox-native--module-submit + (ebox-native-reflow--live-handle session) + generation + (ebox-native-reflow--control-json frames)))) + (setf (ebox-native-reflow-session-generation session) generation) + accepted)) + +(defun ebox-native-reflow-ready-p (session generation key) + "Return non-nil when SESSION has GENERATION KEY ready." + (ebox-native--module-ready-p + (ebox-native-reflow--live-handle session) generation key)) + +(defun ebox-native-reflow-ready-count (session generation keys) + "Return consecutive ready count at the front of KEYS." + (let ((count 0)) + (while (and keys + (ebox-native-reflow-ready-p + session generation (car keys))) + (cl-incf count) + (setq keys (cdr keys))) + count)) + +(defun ebox-native-reflow-take (session generation key) + "Take SESSION's GENERATION KEY result as a UTF-8 string, or nil." + (when-let ((bytes + (ebox-native--module-take + (ebox-native-reflow--live-handle session) generation key))) + (decode-coding-string bytes 'utf-8 t))) + +(defun ebox-native-reflow-confirm-native-frame + (session generation key confirmed-revision) + "Promote SESSION's GENERATION KEY after a successful publication. +CONFIRMED-REVISION must be the buffer runtime revision observed after the +published frame has been installed." + (unless (and (integerp generation) (>= generation 0) + (integerp key) + (integerp confirmed-revision) (>= confirmed-revision 0)) + (error "Native reflow confirmation requires numeric identity fields")) + (unless (ebox-native--module-confirm-frame + (ebox-native-reflow--live-handle session) + generation key confirmed-revision) + (error "Native reflow confirmation has no matching pending frame")) + t) + +(defun ebox-native-reflow-cancel (session generation) + "Cancel SESSION GENERATION and invalidate its queued/results." + (prog1 + (ebox-native--module-cancel + (ebox-native-reflow--live-handle session) generation) + (setf (ebox-native-reflow-session-generation session) + (1+ generation)))) + +(defun ebox-native-reflow-stats (session) + "Return SESSION's bounded runtime statistics as a plist." + (json-parse-string + (ebox-native--module-stats (ebox-native-reflow--live-handle session)) + :object-type 'plist :array-type 'list + :null-object nil :false-object nil)) + +(defun ebox-native-reflow--detach-session-readiness (session) + "Detach and close SESSION's native readiness channel." + (let ((process (ebox-native-reflow-session-readiness-process session))) + (setf (ebox-native-reflow-session-readiness-process session) nil + (ebox-native-reflow-session-readiness-preparation session) nil) + (when process + (process-put process 'ebox-native-reflow-session nil) + (unwind-protect + (when (and (ebox-native-reflow-session-handle session) + (not (ebox-native-reflow-session-released-p session)) + (fboundp + 'ebox-native--module-detach-readiness-channel)) + (unless (ebox-native--module-detach-readiness-channel + (ebox-native-reflow--live-handle session)) + (error "Native reflow readiness channel did not detach"))) + (when (process-live-p process) + (delete-process process)))))) + +(defun ebox-native-reflow-release-session (session) + "Release SESSION idempotently and stop its native workers." + (when (and (ebox-native-reflow-session-p session) + (not (ebox-native-reflow-session-released-p session))) + (let ((handle (ebox-native-reflow-session-handle session))) + (unwind-protect + (ebox-native-reflow--detach-session-readiness session) + (when handle + (ebox-native--module-release handle)) + (setf (ebox-native-reflow-session-handle session) nil + (ebox-native-reflow-session-released-p session) t)))) + nil) + +(defun ebox-native-reflow-preparation-supported-p (buffer) + "Return non-nil when BUFFER can use native root-frame preparation." + (and (buffer-live-p buffer) + (let ((state (ebox--buffer-render-state buffer))) + (and state + (ebox-native-reflow--native-node-supported-p + (plist-get state :root-node)))) + (ebox-native-reflow-layout-ready-p))) + +(defun ebox-native-reflow--preparation-frame + (spec state native-key source-revision context-hash complete-p) + "Return one native control frame for logical SPEC and NATIVE-KEY." + (let ((frame + (list :key native-key + :viewport-width + (or (plist-get spec :viewport-width) + (plist-get state :viewport-width)) + :viewport-height + (or (plist-get spec :viewport-height) + (plist-get state :viewport-height)) + :runtime-revision source-revision + :context-hash context-hash + :complete complete-p))) + (when (plist-member spec :root-width) + (setq frame (plist-put frame :root-width + (plist-get spec :root-width)))) + (when (or (plist-member spec :base-viewport-width) + (plist-member spec :base-root-width)) + (setq frame + (plist-put frame :base-viewport-width + (or (plist-get spec :base-viewport-width) + (plist-get state :viewport-width)))) + (setq frame + (plist-put frame :base-viewport-height + (or (plist-get spec :base-viewport-height) + (plist-get state :viewport-height)))) + (when (plist-member spec :base-root-width) + (setq frame + (plist-put frame :base-root-width + (plist-get spec :base-root-width))))) + frame)) + +(defun ebox-native-reflow--preparation-key + (preparation logical-key full-p) + "Return PREPARATION's native key for LOGICAL-KEY and FULL-P." + (when-let ((keys + (gethash logical-key + (ebox-native-reflow-preparation-key-table + preparation)))) + (if full-p (cdr keys) (car keys)))) + +(defun ebox-native-reflow--preparation-cache-key (logical-key full-p) + "Return the materialized-frame cache key for LOGICAL-KEY and FULL-P." + (list logical-key (and full-p t))) + +(defun ebox-native-reflow-preparation-fully-materialized-p (preparation) + "Return non-nil when PREPARATION has no native tape left to materialize." + (and preparation + (null + (ebox-native-reflow-preparation-materialization-queue preparation)))) + +(defun ebox-native-reflow--stop-preparation-ready-watch (preparation) + "Stop PREPARATION's main-thread readiness timer." + (when-let ((timer + (ebox-native-reflow-preparation-ready-timer preparation))) + (when (timerp timer) + (cancel-timer timer)) + (setf (ebox-native-reflow-preparation-ready-timer preparation) nil))) + +(defun ebox-native-reflow--readiness-channel-live-p (preparation) + "Return non-nil when PREPARATION is routed to its session channel." + (when-let* ((session + (ebox-native-reflow-preparation-native-session preparation)) + (process + (ebox-native-reflow-session-readiness-process session))) + (and (eq preparation + (ebox-native-reflow-session-readiness-preparation session)) + (process-live-p process)))) + +(defun ebox-native-reflow--readiness-filter (process _bytes) + "Handle one native readiness notification delivered through PROCESS." + (when-let* ((session + (process-get process 'ebox-native-reflow-session)) + ((eq process + (ebox-native-reflow-session-readiness-process session))) + (preparation + (ebox-native-reflow-session-readiness-preparation session))) + (when (and (eq process + (ebox-native-reflow-session-readiness-process session)) + (eq session + (ebox-native-reflow-preparation-native-session preparation)) + (not (ebox-native-reflow-preparation-stopped-p preparation))) + (ebox-native-reflow--stop-preparation-ready-watch preparation) + (ebox-native-reflow--watch-preparation-ready preparation)))) + +(defun ebox-native-reflow--readiness-sentinel (process _event) + "Surface an unexpected native readiness channel close for PROCESS." + (when-let ((session + (process-get process 'ebox-native-reflow-session))) + (when (and (eq process + (ebox-native-reflow-session-readiness-process session)) + (not (process-live-p process))) + (let ((preparation + (ebox-native-reflow-session-readiness-preparation session))) + (unwind-protect + (ebox-native-reflow--detach-session-readiness session) + (when (and preparation + (not (ebox-native-reflow-preparation-stopped-p + preparation))) + (ebox-native-reflow--fail-preparation + preparation "Native reflow readiness channel closed"))))))) + +(defun ebox-native-reflow--ensure-session-readiness (session) + "Return SESSION's live readiness process, attaching it once if needed." + (when (and (not (eq system-type 'windows-nt)) + (fboundp 'ebox-native--module-attach-readiness-channel)) + (let ((existing + (ebox-native-reflow-session-readiness-process session))) + (cond + ((and existing (process-live-p existing)) existing) + (existing + (error "Native reflow readiness channel is closed")) + ((ebox-native-reflow-session-readiness-preparation session) + (error "Native reflow readiness route has no channel")) + (t + (let ((process + (make-pipe-process + :name "ebox-native-reflow-readiness" + :buffer nil + :coding 'no-conversion + :noquery t + :filter #'ebox-native-reflow--readiness-filter + :sentinel #'ebox-native-reflow--readiness-sentinel)) + attached-p) + (unwind-protect + (progn + (process-put process 'ebox-native-reflow-session session) + (unless (ebox-native--module-attach-readiness-channel + (ebox-native-reflow--live-handle session) process) + (error "Native reflow readiness channel was rejected")) + (setf (ebox-native-reflow-session-readiness-process session) + process) + (setq attached-p t) + process) + (unless attached-p + (process-put process 'ebox-native-reflow-session nil) + (when (process-live-p process) + (delete-process process)))))))))) + +(defun ebox-native-reflow--route-preparation-readiness (preparation) + "Route PREPARATION through its session-owned readiness channel." + (when-let* ((session + (ebox-native-reflow-preparation-native-session preparation)) + ((ebox-native-reflow--ensure-session-readiness session))) + (when-let ((current + (ebox-native-reflow-session-readiness-preparation session))) + (unless (eq current preparation) + (error "Native reflow session already routes another preparation"))) + (setf (ebox-native-reflow-session-readiness-preparation session) + preparation) + t)) + +(defun ebox-native-reflow--clear-preparation-readiness (preparation) + "Clear PREPARATION's route without closing its session channel." + (when-let ((session + (ebox-native-reflow-preparation-native-session preparation))) + (when (eq preparation + (ebox-native-reflow-session-readiness-preparation session)) + (setf (ebox-native-reflow-session-readiness-preparation session) nil)))) + +(defun ebox-native-reflow--idle-continuation-delay (delay) + "Return a fresh idle threshold DELAY beyond the current idle duration." + (+ (or (when-let ((idle (current-idle-time))) + (float-time idle)) + 0) + (max 0 (or delay 0)))) + +(defun ebox-native-reflow--schedule-preparation-ready-watch + (preparation delay) + "Schedule PREPARATION's next one-shot materialization turn after DELAY." + (unless (or (ebox-native-reflow-preparation-stopped-p preparation) + (ebox-native-reflow-preparation-error preparation)) + (setf (ebox-native-reflow-preparation-ready-timer preparation) + (if (ebox-native-reflow-preparation-idle-watch-p preparation) + (run-with-idle-timer + (ebox-native-reflow--idle-continuation-delay delay) + nil #'ebox-native-reflow--watch-preparation-ready preparation) + (run-at-time delay nil + #'ebox-native-reflow--watch-preparation-ready + preparation))))) + +(defun ebox-native-reflow--next-materialization-ready-p (preparation) + "Return non-nil when PREPARATION's next queued native frame is ready." + (when-let* ((entry + (car (ebox-native-reflow-preparation-materialization-queue + preparation))) + (spec (car entry))) + (ebox-native-reflow-preparation-ready-p + preparation (plist-get spec :key) (cadr entry)))) + +(defun ebox-native-reflow-prioritize-preparation (preparation) + "Promote background PREPARATION to input-driven readiness notification." + (when preparation + (setf (ebox-native-reflow-preparation-idle-watch-p preparation) nil) + (when (ebox-native-reflow-preparation-materialization-queue preparation) + (ebox-native-reflow--stop-preparation-ready-watch preparation) + (let ((channel-p + (or (ebox-native-reflow--readiness-channel-live-p preparation) + (ebox-native-reflow--route-preparation-readiness + preparation)))) + (when (or (not channel-p) + (ebox-native-reflow--next-materialization-ready-p + preparation)) + (ebox-native-reflow--schedule-preparation-ready-watch + preparation 0))))) + preparation) + +(defun ebox-native-reflow--fail-preparation (preparation message) + "Record PREPARATION failure MESSAGE and notify its owner once." + (unless (or (ebox-native-reflow-preparation-stopped-p preparation) + (ebox-native-reflow-preparation-error preparation)) + (setf (ebox-native-reflow-preparation-error preparation) message) + (when-let ((function + (ebox-native-reflow-preparation-error-function preparation))) + (funcall function preparation message)))) + +(defun ebox-native-reflow--watch-preparation-ready (preparation) + "Materialize one PREPARATION frame, then notify its owner." + (setf (ebox-native-reflow-preparation-ready-timer preparation) nil) + (cond + ((or (ebox-native-reflow-preparation-stopped-p preparation) + (ebox-native-reflow-preparation-error preparation)) + (ebox-native-reflow--stop-preparation-ready-watch preparation)) + (t + (condition-case err + ;; Rust jobs may complete in parallel, but Emacs values are created + ;; only on the main thread. Convert one result per event-loop turn + ;; before animation so visible frames never alternate playback and + ;; parsing. + (let ((materialized-p + (ebox-native-reflow--materialize-next-ready-frame + preparation))) + (when-let ((function + (ebox-native-reflow-preparation-ready-function + preparation))) + (funcall function preparation nil)) + (when (ebox-native-reflow-preparation-materialization-queue + preparation) + (cond + ((ebox-native-reflow-preparation-idle-watch-p preparation) + (ebox-native-reflow--schedule-preparation-ready-watch + preparation (if materialized-p 0.001 0.01))) + ((ebox-native-reflow--readiness-channel-live-p preparation) + (when (ebox-native-reflow--next-materialization-ready-p + preparation) + (ebox-native-reflow--schedule-preparation-ready-watch + preparation 0))) + (t + (ebox-native-reflow--schedule-preparation-ready-watch + preparation (if materialized-p 0.001 0.01)))))) + (error + (ebox-native-reflow--fail-preparation + preparation (error-message-string err))))))) + +(defun ebox-native-reflow-start-preparation + (buffer kind region-id specs worker-count _batch-cadence + ready-function error-function &optional idle-watch-p layout-package + native-session) + "Start bounded native root preparation for BUFFER and logical SPECS. +KIND is `viewport' or `root-width'. A spec with `:full-only-p' submits only +its complete result; otherwise each requested frame is submitted with its +light result followed by its optional complete restoration companion. +When IDLE-WATCH-P is non-nil, every main-thread materialization turn waits for +a fresh idle interval so background preparation cannot overtake input. +When NATIVE-SESSION is non-nil, borrow it across generations instead of +creating and releasing a session for this preparation." + (unless (ebox-native-reflow-preparation-supported-p buffer) + (error "Native root reflow preparation is unavailable")) + (unless (memq kind '(viewport root-width)) + (error "Unsupported native root reflow kind %S" kind)) + (unless (and (listp specs) specs) + (error "Native root reflow preparation requires frame specs")) + (let* ((state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (layout-package + (or layout-package + (ebox-native-reflow--compile-layout-package root))) + (source-revision (or (plist-get state :runtime-revision) 0)) + (display-signature + (with-current-buffer buffer (ebox-display-signature))) + (context-hash + (sxhash-equal + (list display-signature kind region-id + (ebox--ensure-node-id root)))) + (key-table (make-hash-table :test 'equal)) + (next-key 0) + materialization-queue + frames) + (dolist (spec specs) + (let* ((logical-key (plist-get spec :key)) + (_key-check + (unless logical-key + (error "Native root reflow spec has no logical key"))) + (full-only-p (plist-get spec :full-only-p)) + (_full-only-check + (when (and full-only-p (not (plist-get spec :full-p))) + (error "Native root reflow full-only spec needs :full-p"))) + (light-key (unless full-only-p (cl-incf next-key))) + (full-key (and (plist-get spec :full-p) + (cl-incf next-key)))) + (when (gethash logical-key key-table) + (error "Duplicate native root reflow logical key %S" logical-key)) + (puthash logical-key (cons light-key full-key) key-table) + (when light-key + (push (ebox-native-reflow--preparation-frame + spec state light-key source-revision context-hash nil) + frames) + (push (list (copy-sequence spec) nil) materialization-queue)) + (when full-key + (push (ebox-native-reflow--preparation-frame + spec state full-key source-revision context-hash t) + frames) + (push (list (copy-sequence spec) t) materialization-queue)))) + (let* ((frames (nreverse frames)) + (frame-count (length frames)) + (_capacity-check + (when (> frame-count + (min ebox-native-reflow-max-jobs + ebox-native-reflow-max-results)) + (error + "Native root reflow needs %d frame slots; configured limit is %d" + frame-count + (min ebox-native-reflow-max-jobs + ebox-native-reflow-max-results)))) + (borrowed-session-p (and native-session t)) + (native-session + (or native-session + (ebox-native-reflow-create-session + :workers worker-count))) + (generation + (1+ (or (ebox-native-reflow-session-generation native-session) + 0))) + (preparation + (ebox-native-reflow--make-preparation + :native-session native-session + :native-session-id + (plist-get (ebox-native-reflow-stats native-session) :session-id) + :generation generation + :buffer buffer + :kind kind + :region-id region-id + :source-root root + :source-revision source-revision + :expected-revision source-revision + :display-signature display-signature + :context-hash context-hash + :layout-package layout-package + :key-table key-table + :frame-cache (make-hash-table :test 'equal) + :materialization-queue (nreverse materialization-queue) + :specs (copy-tree specs) + :ready-function ready-function + :error-function error-function + :idle-watch-p idle-watch-p + :window-start-line + (when-let ((window (get-buffer-window buffer t))) + (with-current-buffer buffer + (save-restriction + (widen) + (max 1 (line-number-at-pos (window-start window)))))) + :release-native-session-p (not borrowed-session-p))) + readiness-channel-p + submitted-p) + (unwind-protect + (progn + (unless idle-watch-p + (setq readiness-channel-p + (ebox-native-reflow--route-preparation-readiness + preparation))) + (unless (= (ebox-native-reflow-submit-layout + native-session generation root frames layout-package) + (length frames)) + (error "Native root reflow accepted an incomplete frame batch")) + (when (or (not readiness-channel-p) + (ebox-native-reflow--next-materialization-ready-p + preparation)) + (ebox-native-reflow--schedule-preparation-ready-watch + preparation 0)) + (setq submitted-p t) + preparation) + (unless submitted-p + (ebox-native-reflow--clear-preparation-readiness preparation) + (if borrowed-session-p + (ignore-errors + (ebox-native-reflow-cancel native-session generation)) + (ebox-native-reflow-release-session native-session))))))) + +(defun ebox-native-reflow-preparation-ready-p + (preparation logical-key &optional full-p) + "Return non-nil when PREPARATION has LOGICAL-KEY ready. +When FULL-P is non-nil, query the complete restoration job." + (when (and preparation + (not (ebox-native-reflow-preparation-stopped-p preparation))) + (let ((cache + (ebox-native-reflow-preparation-frame-cache preparation))) + (or (and cache + (gethash + (ebox-native-reflow--preparation-cache-key + logical-key full-p) + cache)) + (when-let ((native-key + (ebox-native-reflow--preparation-key + preparation logical-key full-p))) + (ebox-native-reflow-ready-p + (ebox-native-reflow-preparation-native-session preparation) + (ebox-native-reflow-preparation-generation preparation) + native-key)))))) + +(defun ebox-native-reflow-preparation-ready-count (preparation logical-keys) + "Return PREPARATION's consecutive ready light-frame count." + (let ((count 0)) + (while (and logical-keys + (ebox-native-reflow-preparation-ready-p + preparation (car logical-keys))) + (cl-incf count) + (setq logical-keys (cdr logical-keys))) + count)) + +(defun ebox-native-reflow--validated-preparation-state (preparation) + "Return PREPARATION's current render state after fail-closed validation." + (let* ((buffer (ebox-native-reflow-preparation-buffer preparation)) + (state (and (buffer-live-p buffer) + (ebox--buffer-render-state buffer))) + (root (plist-get state :root-node))) + (unless (and state + (eq root + (ebox-native-reflow-preparation-source-root preparation)) + (equal + (plist-get state :runtime-revision) + (ebox-native-reflow-preparation-expected-revision preparation)) + (with-current-buffer buffer + (equal + (ebox-display-signature) + (ebox-native-reflow-preparation-display-signature + preparation)))) + (error "Preview runtime changed during native reflow preparation")) + state)) + +(defun ebox-native-reflow--materialize-preparation-frame + (preparation spec full-p state) + "Take and materialize PREPARATION's SPEC result using validated STATE." + (let* ((logical-key (plist-get spec :key)) + (native-key + (ebox-native-reflow--preparation-key + preparation logical-key full-p)) + (viewport-width + (or (plist-get spec :viewport-width) + (plist-get state :viewport-width))) + (viewport-height + (or (plist-get spec :viewport-height) + (plist-get state :viewport-height))) + (root-width (or (plist-get spec :root-width) viewport-width)) + (result + (condition-case err + (ebox-native-reflow-take-layout + (ebox-native-reflow-preparation-native-session preparation) + (ebox-native-reflow-preparation-generation preparation) + native-key + (list + :session-id + (ebox-native-reflow-preparation-native-session-id preparation) + :runtime-revision + (ebox-native-reflow-preparation-source-revision preparation) + :context-hash + (ebox-native-reflow-preparation-context-hash preparation) + :viewport-width viewport-width + :viewport-height viewport-height + :root-width root-width + :complete (and full-p t))) + (error + (ebox-native-reflow--fail-preparation + preparation (error-message-string err)) + (signal (car err) (cdr err)))))) + (unless (or (stringp result) (plist-get result :native-patch)) + (error "Native reflow result disappeared before materialization")) + (when (and (stringp result) + (fboundp 'ebox--rendered-root-metadata)) + (ebox--rendered-root-metadata result t)) + result)) + +(defun ebox-native-reflow--materialize-next-ready-frame (preparation) + "Materialize at most one ready tape into PREPARATION's Emacs frame cache." + (let* ((queue + (ebox-native-reflow-preparation-materialization-queue preparation)) + (entry (car queue)) + (spec (car entry)) + (full-p (cadr entry)) + (logical-key (plist-get spec :key)) + (native-key + (ebox-native-reflow--preparation-key + preparation logical-key full-p))) + (when (and native-key + (ebox-native-reflow-ready-p + (ebox-native-reflow-preparation-native-session preparation) + (ebox-native-reflow-preparation-generation preparation) + native-key)) + (let* ((state + (ebox-native-reflow--validated-preparation-state preparation)) + (result + (ebox-native-reflow--materialize-preparation-frame + preparation spec full-p state))) + (puthash + (ebox-native-reflow--preparation-cache-key logical-key full-p) + result + (ebox-native-reflow-preparation-frame-cache preparation)) + (setf + (ebox-native-reflow-preparation-materialization-queue preparation) + (cdr queue)) + t)))) + +(defun ebox-native-reflow--drop-materialization-entry + (preparation logical-key full-p) + "Remove LOGICAL-KEY's FULL-P entry from PREPARATION's preplay queue." + (setf (ebox-native-reflow-preparation-materialization-queue preparation) + (cl-delete-if + (lambda (entry) + (and (equal (plist-get (car entry) :key) logical-key) + (eq (and (cadr entry) t) (and full-p t)))) + (ebox-native-reflow-preparation-materialization-queue preparation)))) + +(defun ebox-native-reflow--prepared-frame-record + (preparation spec state result full-p) + "Return one validated prepared frame record for RESULT and SPEC." + (let* ((root (plist-get state :root-node)) + (region-id (ebox-native-reflow-preparation-region-id preparation)) + (root-box + (or (and region-id (ebox--root-region-box root region-id)) + (pcase (plist-get root :ebox-type) + ('box root) + ('flex (plist-get root :box))))) + (base-root-width + (if (plist-member spec :base-root-width) + (plist-get spec :base-root-width) + (ebox--literal-root-pixel-width root-box))) + (viewport-width + (or (plist-get spec :viewport-width) + (plist-get state :viewport-width))) + (viewport-height + (or (plist-get spec :viewport-height) + (plist-get state :viewport-height)))) + (append + (if (stringp result) + (list :rendered result) + (list :native-patch result + :publication-owner 'prepared-root-native)) + (list + :render-state state + :node-id (ebox--ensure-node-id root) + :runtime-revision (plist-get state :runtime-revision) + :base-buffer-chars-modified-tick + (with-current-buffer + (ebox-native-reflow-preparation-buffer preparation) + (buffer-chars-modified-tick)) + :display-signature + (ebox-native-reflow-preparation-display-signature preparation) + :kind (ebox-native-reflow-preparation-kind preparation) + :region-id region-id + :base-root-width base-root-width + :base-viewport-width + (if (plist-member spec :base-viewport-width) + (plist-get spec :base-viewport-width) + (plist-get state :viewport-width)) + :base-viewport-height + (if (plist-member spec :base-viewport-height) + (plist-get spec :base-viewport-height) + (plist-get state :viewport-height)) + :root-width + (if (plist-member spec :root-width) + (plist-get spec :root-width) + base-root-width) + :viewport-width viewport-width + :viewport-height viewport-height + :full-p (and full-p t) + :metadata-complete-p + (and full-p + (or (stringp result) + (plist-get result :root-render-metadata)) + t))))) + +(defun ebox-native-reflow-copy-frame (preparation logical-key spec) + "Borrow PREPARATION's complete LOGICAL-KEY output for occurrence SPEC. +The canonical cache entry remains owned by PREPARATION so a return trip can +reuse the same geometry without another native job or Emacs materialization." + (let* ((cache (ebox-native-reflow-preparation-frame-cache preparation)) + (result + (and cache + (gethash + (ebox-native-reflow--preparation-cache-key logical-key t) + cache)))) + (when (or (stringp result) (plist-get result :native-patch)) + (ebox-native-reflow--prepared-frame-record + preparation spec + (ebox-native-reflow--validated-preparation-state preparation) + result t)))) + +(defun ebox-native-reflow-prepare-frame + (preparation spec &optional full-p) + "Return PREPARATION's cached, validated one-shot prepared root for SPEC." + (let* ((buffer (ebox-native-reflow-preparation-buffer preparation)) + (logical-key (plist-get spec :key)) + (native-key + (ebox-native-reflow--preparation-key + preparation logical-key full-p)) + (cache-key + (ebox-native-reflow--preparation-cache-key logical-key full-p)) + (cache (ebox-native-reflow-preparation-frame-cache preparation))) + (when (and native-key + (buffer-live-p buffer) + (ebox-native-reflow-preparation-ready-p + preparation logical-key full-p)) + (let* ((state + (ebox-native-reflow--validated-preparation-state preparation)) + (result + (or (and cache (gethash cache-key cache)) + (ebox-native-reflow--materialize-preparation-frame + preparation spec full-p state)))) + (when cache + (remhash cache-key cache)) + (ebox-native-reflow--drop-materialization-entry + preparation logical-key full-p) + (ebox-native-reflow--prepared-frame-record + preparation spec state result full-p))))) + +(defun ebox-native-reflow-confirm-frame + (preparation &optional spec complete-p native-full-p) + "Advance PREPARATION after successful publication of SPEC. +COMPLETE-P means the published frame has complete root indexes. +NATIVE-FULL-P selects the native key that produced the published frame." + (when-let* ((buffer (ebox-native-reflow-preparation-buffer preparation)) + ((buffer-live-p buffer)) + (state (ebox--buffer-render-state buffer)) + ((eq (plist-get state :root-node) + (ebox-native-reflow-preparation-source-root preparation)))) + (let ((confirmed-revision (or (plist-get state :runtime-revision) 0))) + (when (and complete-p spec) + (when-let ((native-key + (ebox-native-reflow--preparation-key + preparation (plist-get spec :key) native-full-p))) + (ebox-native-reflow-confirm-native-frame + (ebox-native-reflow-preparation-native-session preparation) + (ebox-native-reflow-preparation-generation preparation) + native-key confirmed-revision))) + (setf (ebox-native-reflow-preparation-expected-revision preparation) + confirmed-revision + (ebox-native-reflow-preparation-last-spec preparation) spec + (ebox-native-reflow-preparation-light-p preparation) + (not complete-p))))) + +(defun ebox-native-reflow-preparation-stats (preparation) + "Return PREPARATION's native bounded-runtime statistics." + (or (ebox-native-reflow-preparation-final-stats preparation) + (when-let ((session + (ebox-native-reflow-preparation-native-session + preparation))) + (unless (ebox-native-reflow-session-released-p session) + (ebox-native-reflow-stats session))))) + +(defun ebox-native-reflow-stop-preparation (preparation) + "Cancel and release PREPARATION idempotently without joining workers." + (when (and preparation + (not (ebox-native-reflow-preparation-stopped-p preparation))) + (setf (ebox-native-reflow-preparation-stopped-p preparation) t) + (ebox-native-reflow--stop-preparation-ready-watch preparation) + (ebox-native-reflow--clear-preparation-readiness preparation) + (when-let ((cache + (ebox-native-reflow-preparation-frame-cache preparation))) + (clrhash cache)) + (setf (ebox-native-reflow-preparation-materialization-queue preparation) + nil) + (when-let ((session + (ebox-native-reflow-preparation-native-session preparation))) + (unless (ebox-native-reflow-session-released-p session) + (ignore-errors + (ebox-native-reflow-cancel + session (ebox-native-reflow-preparation-generation preparation))) + (setf (ebox-native-reflow-preparation-final-stats preparation) + (ignore-errors (ebox-native-reflow-stats session))) + (when (ebox-native-reflow-preparation-release-native-session-p + preparation) + (unwind-protect + (ebox-native-reflow-release-session session) + (when-let ((stats + (ebox-native-reflow-preparation-final-stats + preparation))) + (plist-put stats :alive nil))))))) + nil) + +(provide 'ebox-native-reflow) +;;; ebox-native-reflow.el ends here diff --git a/ebox-render-context.el b/ebox-render-context.el new file mode 100644 index 0000000..724f90f --- /dev/null +++ b/ebox-render-context.el @@ -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 diff --git a/ebox-selector.el b/ebox-selector.el new file mode 100644 index 0000000..5ccb142 --- /dev/null +++ b/ebox-selector.el @@ -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 diff --git a/ebox-style.el b/ebox-style.el new file mode 100644 index 0000000..cf1c03f --- /dev/null +++ b/ebox-style.el @@ -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 diff --git a/ebox-tree.el b/ebox-tree.el new file mode 100644 index 0000000..b8a6394 --- /dev/null +++ b/ebox-tree.el @@ -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 diff --git a/ebox.el b/ebox.el new file mode 100644 index 0000000..84c5571 --- /dev/null +++ b/ebox.el @@ -0,0 +1,7911 @@ +;;; ebox.el --- Pixel-perfect box model renderer -*- lexical-binding: t -*- + +;; Author: ebox contributors +;; Version: 1.0.1 +;; Package-Requires: ((emacs "29.1")) +;; Keywords: ui, graphics, convenience +;; URL: https://github.com/ginqi7/ebox + +;;; Commentary: + +;; Ebox provides a high-performance, CSS-like box model implementation for Emacs. +;; It renders text content with padding, borders, margins, and background colors +;; using pixel-precise alignment. +;; +;; This file is the public facade and current implementation host. Internal +;; model modules are loaded here first; behavior moves into them through staged +;; extraction commits while this feature name remains the user-facing entry. + +;;; Code: + +(defconst ebox--directory + (file-name-directory (or load-file-name buffer-file-name)) + "Directory containing the active Ebox Lisp sources.") + +(defconst 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") + "Active Ebox Lisp sources compiled by `ebox-byte-compile'.") + +(add-to-list 'load-path ebox--directory) + +(require 'cl-lib) +(require 'mwheel) +(require 'seq) +(require 'subr-x) +(require 'ebox-cache) +(require 'ebox-style) +(require 'ebox-tree) +(require 'ebox-measure) +(require 'ebox-fragment) +(require 'ebox-render-context) +(require 'ebox-layout) +(require 'ebox-flex) +(require 'ebox-grid) +(require 'ebox-buffer-backend) +(require 'ebox-incremental) +(require 'ebox-dsl) +(require 'ebox-selector) + +(declare-function ebox--buffer-update-report "ebox-incremental" (buffer)) +(declare-function ebox--set-buffer-update-report + "ebox-incremental" (buffer report)) + +(unless (fboundp 'ebox-native-build) + (autoload 'ebox-native-build "ebox-native-reflow" nil t)) +(unless (fboundp 'ebox-native-status) + (autoload 'ebox-native-status "ebox-native-reflow" nil t)) +(autoload 'ebox-native-reflow-render-owner-proof "ebox-native-reflow") +(setq ebox-buffer-owner-proof-renderer + #'ebox-native-reflow-render-owner-proof) + +(defalias 'ebox-select-all #'ebox-selector-query-buffer) +(defalias 'ebox-update-selector #'ebox-selector-update-buffer) + +(defgroup ebox nil + "Pixel-perfect box model renderer for Emacs buffers." + :group 'applications + :prefix "ebox-") + +;;;###autoload +(defun ebox-byte-compile () + "Recompile every active Ebox Lisp source into a neighboring `.elc' file. +The command is safe to run repeatedly after updating Ebox source. Exact +compiler output is written to `*Ebox Byte Compile*'. Restart Emacs after a +successful build so the current process loads the new bytecode. This command +does not compile the optional Rust module." + (interactive) + (require 'bytecomp) + (let* ((sources + (mapcar (lambda (file) (expand-file-name file ebox--directory)) + ebox--compile-sources)) + (missing (cl-remove-if #'file-readable-p sources)) + (byte-compile-log-buffer "*Ebox Byte Compile*") + (log (get-buffer-create byte-compile-log-buffer)) + (load-prefer-newer t) + failures) + (when missing + (user-error "Ebox byte compilation blocked; missing source: %s" + (mapconcat #'file-name-nondirectory missing ", "))) + (unless (file-writable-p ebox--directory) + (user-error "Ebox byte compilation blocked; directory is not writable: %s" + ebox--directory)) + (with-current-buffer log + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (format "Ebox byte compilation\n\nSource: %s\nFiles: %d\n\n" + ebox--directory (length sources))))) + (dolist (source sources) + (message "Ebox byte-compiling %s..." (file-name-nondirectory source)) + (condition-case err + (unless (byte-compile-file source) + (push (file-name-nondirectory source) failures)) + (error + (push (file-name-nondirectory source) failures) + (with-current-buffer log + (let ((inhibit-read-only t)) + (goto-char (point-max)) + (insert (format "\n[%s] %s\n" + (file-name-nondirectory source) + (error-message-string err)))))))) + (if failures + (progn + (display-buffer log) + (user-error "Ebox byte compilation failed for: %s" + (mapconcat #'identity (nreverse failures) ", "))) + (message "Ebox byte compilation succeeded for %d files; restart Emacs to use the new .elc files" + (length sources)) + t))) + +(defcustom ebox-render-cache-max-entries 2048 + "Maximum persistent rendered-body cache entries retained per buffer. +Continuous viewport and ordinary box resizing can create a distinct rendered +value for every width. Ebox keeps the newest entries in insertion order and +evicts older entries above this soft bound. The larger default retains a full +responsive flex resize working set; `ebox-render-cache-max-bytes' remains the +harder memory guard for property-rich natural-height renders." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-render-cache-max-bytes (* 32 1024 1024) + "Approximate persistent rendered-body cache byte budget per buffer. +Ebox strings store pixel geometry and ownership mainly in text properties, so +an entry-count limit alone cannot bound memory for natural-height documents. +Values above this insertion-order budget evict older entries; one value larger +than the complete budget is rendered normally but is not retained." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-render-root-cache-max-entries 16 + "Maximum complete root-width outputs retained per buffer. +The default retains one maintained manual-resize round trip, including its +predicted widths. The separate byte budget remains the hard guard for these +large, property-rich strings, so they cannot crowd reusable subtree and flex +measurements or grow with an unbounded resize history." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-render-root-cache-max-bytes (* 8 1024 1024) + "Approximate byte budget for complete root-width outputs per buffer. +This budget complements `ebox-render-root-cache-max-entries'; oversized root +outputs remain correct but are not retained after rendering." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-scroll-step 1 + "Default number of lines moved by ebox scroll commands." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-wheel-scroll-step 16 + "Default number of lines moved by one vertical mouse wheel event. +This is separate from `ebox-scroll-step' so keyboard fine scrolling can stay +precise while real mouse and trackpad scrolling feels responsive." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-wheel-smooth-scroll t + "When non-nil, animate mouse wheel scrolling over several short steps." + :type 'boolean + :group 'ebox) + +(defcustom ebox-wheel-smooth-scroll-interval 0.016 + "Seconds between animated mouse wheel scroll steps." + :type 'number + :group 'ebox) + +(defcustom ebox-wheel-smooth-scroll-lines-per-tick 4 + "Number of content lines moved by each animated mouse wheel tick." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-wheel-smooth-scroll-target-ticks 8 + "Approximate maximum number of timer ticks used to drain pending wheel input." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-scroll-lazy-prefix-lookahead-lines 8 + "Extra lines rendered ahead when extending lazy scroll prefixes. +This keeps repeated fine scroll steps on the marker-window patch path without +materializing the whole scroll source." + :type 'natnum + :group 'ebox) + +(defcustom ebox-scroll-lazy-idle-prefetch-lines 128 + "Extra lazy scroll lines rendered while Emacs is idle. +This warms the same scroll content cache used by wheel/key scrolling, so deep +scrolling can replace already-rendered line strings instead of producing +hidden rows inside a wheel tick. Set to 0 to disable idle prefetch." + :type 'natnum + :group 'ebox) + +(defcustom ebox-scroll-lazy-idle-prefetch-slice-lines 16 + "Maximum lazy scroll lines rendered by one idle prefetch callback. +Idle prefetch may need a larger total lookahead, but each callback stays +bounded so resize-time warming does not monopolize the GUI event loop. Set to +0 to allow one callback to fill the full lookahead target." + :type 'natnum + :group 'ebox) + +(defcustom ebox-scroll-lazy-idle-prefetch-delay 0.15 + "Idle seconds to wait before warming lazy scroll prefixes." + :type 'number + :group 'ebox) + +(defcustom ebox-runtime-idle-prewarm t + "When non-nil, prepare shared incremental-update state while Emacs is idle. +Prewarming is buffer-runtime scoped and never materializes complete lazy scroll +content. It prepares indexes only for scroll lines that are already cached." + :type 'boolean + :group 'ebox) + +(defcustom ebox-runtime-idle-prewarm-delay 0.1 + "Idle seconds to wait before preparing shared incremental-update state." + :type 'number + :group 'ebox) + +(defcustom ebox-runtime-idle-prewarm-prefix-resume-delay 2.0 + "Idle seconds reserved for interaction before lazy prefix prefetch resumes. +This delay is used only when runtime prewarming has already made prefix +prefetch yield; ordinary prefix scheduling keeps its own configured delay." + :type 'number + :group 'ebox) + +(defcustom ebox-runtime-idle-prewarm-slice-size 32 + "Maximum nodes or cached scroll lines processed by one prewarm callback." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-runtime-idle-reflow-cache-prewarm t + "When non-nil, predict and cache the next steady viewport reflow. +Each completed nonzero viewport-width delta predicts one following width. The +predicted render runs after an input-free idle interval and publishes no +buffer or runtime state; it populates the same persistent render cache used by +viewport and ordinary root-width owner rerenders. Nonpositive predictions +are skipped." + :type 'boolean + :group 'ebox) + +(defcustom ebox-runtime-idle-reflow-cache-prewarm-delay 0.15 + "Idle seconds before a predicted viewport reflow warms render caches. +User input resets the idle period, so wheel scrolling and editing cannot be +overtaken by a full invisible layout. Large Playground resize animations use +their bounded native preparation pool instead of this main-thread timer." + :type 'number + :group 'ebox) + +(defcustom ebox-reflow-cache-prewarm-gc-cons-threshold 'auto + "Allocation budget used while an idle reflow is being prepared. +The default `auto' learns from measured allocation per predicted layout and +its GC-free duration. It aims to keep collections uncommon during expensive +resize sequences while retaining smaller heaps for inexpensive pages. This +is a pressure threshold, not a retained RSS cap. Set it to a byte count for a +fixed budget, or nil to inherit the surrounding `gc-cons-threshold'." + :type '(choice (const :tag "Adapt to measured layout cost" auto) + (const :tag "Inherit surrounding value" nil) + positive-integer) + :group 'ebox) + +(defcustom ebox-reflow-cache-prewarm-gc-auto-frame-budget 0.2 + "Target seconds available for one predicted layout and optional GC. +Automatic reflow GC tuning compares measured GC-free layout time with this +budget. It never delays or drops a render; it only selects the allocation +threshold used by later invisible prewarms." + :type 'number + :group 'ebox) + +(defcustom ebox-reflow-cache-prewarm-gc-auto-min-threshold (* 64 1024 1024) + "Minimum allocation threshold selected for expensive predicted layouts." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-reflow-cache-prewarm-gc-auto-max-threshold (* 1024 1024 1024) + "Maximum allocation threshold selected for predicted layouts." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-reflow-cache-prewarm-gc-auto-initial-threshold + (* 512 1024 1024) + "Initial allocation threshold before a predicted layout has been measured." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-reflow-cache-prewarm-gc-auto-target-layouts 24 + "Target expensive predicted layouts between automatic collections. +Automatic tuning multiplies this horizon by measured allocation per layout. +Faster layouts use a shorter horizon because their collections consume less +of the resize cadence." + :type 'positive-integer + :group 'ebox) + +(defcustom ebox-reflow-cache-prewarm-gc-cons-percentage 0.1 + "Heap-relative allocation budget used during idle reflow preparation. +Emacs combines this with `ebox-reflow-cache-prewarm-gc-cons-threshold', so the +effective budget grows with the live working set while garbage collection is +kept inside invisible preparation rather than visible buffer publication. +Set it to nil to inherit the surrounding `gc-cons-percentage'." + :type '(choice (const :tag "Inherit surrounding value" nil) number) + :group 'ebox) + +;;; ============================================================ +;;; Core: Data Structures & String Utils +;;; ============================================================ + +(defvar ebox--region-id-counter 0 + "Global counter for generating unique box region IDs.") + +(defvar ebox--runtime-node-id-counter 0 + "Global counter for internal runtime node IDs.") + +(defvar ebox--render-region-id nil + "Dynamically bound region ID reused during whole-box rerender.") + +(defvar ebox-viewport-width nil + "Dynamically bound horizontal viewport width in pixels. +DSL width values may use `(viewport)' to resolve to this value while a +caller, such as the Playground preview, renders against a concrete window.") + +(defvar ebox-viewport-height nil + "Dynamically bound vertical viewport height in lines. +DSL height values may use `(viewport-height)' to resolve to this value while a +caller, such as the Playground preview, renders against a concrete window.") + +(defvar ebox--region-box-table) +(defvar ebox-region-types) +(defvar ebox--scroll-global-state) +(defvar ebox--smooth-scroll-state-table) +(defvar ebox--render-cache-scroll-state-region-ids nil + "Dynamically bound set of scroll regions touched by one cached render.") +(defvar ebox--scroll-idle-prefetch-timers (make-hash-table :test 'equal) + "Region-keyed idle timers that warm lazy scroll prefix caches.") +(defvar ebox--scroll-idle-prefetch-inhibited-buffers + (make-hash-table :test 'eq) + "Buffers whose active foreground animation owns idle render time.") +(defvar ebox--scroll-allow-noninteractive-prefetch nil + "Non-nil lets tests schedule lazy scroll prefetch timers in batch Emacs.") +(defvar ebox--runtime-prewarm-allow-noninteractive nil + "Non-nil lets tests run buffer runtime prewarming in batch Emacs.") +(defvar ebox--runtime-prewarm-jobs (make-hash-table :test 'eq) + "Buffer-keyed incremental-update prewarm jobs.") +(defvar ebox--runtime-prewarm-timers (make-hash-table :test 'eq) + "Buffer-keyed idle timers for incremental-update prewarming.") +(defvar ebox--reflow-cache-prewarm-timers (make-hash-table :test 'eq) + "Buffer-keyed idle timers for predicted reflow-cache warming.") +(defvar ebox--scroll-prefetch-delay-override nil + "Dynamically bound delay for scroll prefix prefetch scheduling.") +(defvar ebox--region-update-buffer-hint nil + "Optional buffer hint for internal selector-driven region updates.") + +(defsubst ebox--next-region-id () + "Generate next unique region ID." + (cl-incf ebox--region-id-counter)) + +(defconst ebox--longhand + '( :content "" + :box-sizing border-box ;; content-box or border-box + + ;; 所有宽度的单位是像素,所有高度的单位是行数 + :width nil :min-width 0 :max-width nil + :height nil :min-height 0 :max-height nil + + :padding-left-pixel 0 :padding-right-pixel 0 + :padding-top-height 0 :padding-bottom-height 0 + :margin-left-pixel 0 :margin-right-pixel 0 + :margin-top-height 0 :margin-bottom-height 0 + + ;; 左右边框支持设置宽度 + :border-left-pixel 0 :border-left-color nil + :border-right-pixel 0 :border-right-color nil + ;; 上下边框由于 Emacs 限制,用 overline/underline 实现,不支持设置宽度 + :border-top-p nil :border-top-color nil + :border-bottom-p nil :border-bottom-color nil + + :color nil + :bgcolor nil + :text-align left :vertical-align top + ;; supports: 'visible / 'hidden / 'scroll + :overflow scroll + ;; supports: 'visible / 'hidden — hidden suppresses ink while the + ;; layout footprint, buffer text, and metadata stay identical + :visibility visible + ;; supports: 'word / 'char / 'kp + :wrap-mode word + :scroll-offset 0 ;; 当前滚动偏移(行数) + ) + "Default plist for a box structure (longhand properties only).") + +(defun ebox--longhand-create (&optional plist) + "Create a shorthand box structure. +PLIST is a property list that overrides the defaults." + (let ((box (copy-sequence ebox--longhand))) + (while plist + (let ((key (pop plist)) + (value (pop plist))) + (plist-put box key value))) + box)) + +(defsubst ebox-get (box property) + "Retrieve PROPERTY from BOX." + (plist-get box property)) + +(defsubst ebox-put (box property value) + "Set PROPERTY to VALUE in BOX and return the modified box." + (plist-put box property value)) + +;;;###autoload +(defun ebox-buffer-update-report (buffer-or-name) + "Return BUFFER-OR-NAME's stored update report. +The returned plist is a defensive copy of the report owned by the live rendered +buffer. Signal `user-error' when the target is missing, dead, or has no Ebox +runtime. A freshly rendered buffer returns nil until its first update +publication." + (let ((buffer (and buffer-or-name (get-buffer buffer-or-name)))) + (unless (buffer-live-p buffer) + (user-error "Ebox report target is not a live buffer: %S" + buffer-or-name)) + (unless (ebox--buffer-render-state buffer) + (user-error "Ebox report target has no rendered runtime: %S" + buffer-or-name)) + (copy-tree (ebox--buffer-update-report buffer)))) + +(defun ebox--ensure-region-id (box) + "Return BOX's stable region id, creating one if needed." + (or (ebox-get box :region-id) + (let ((id (ebox--next-region-id))) + (ebox-put box :region-id id) + id))) + +(defun ebox--next-runtime-node-id () + "Return the next internal runtime node id." + (cl-incf ebox--runtime-node-id-counter)) + +(defun ebox--ensure-node-id (node) + "Return NODE's stable internal node id, assigning one when needed." + (unless (and (listp node) (plist-get node :node-id)) + (plist-put node :node-id (ebox--next-runtime-node-id))) + (plist-get node :node-id)) + +(defsubst ebox-string-lines (string) + "Split STRING into a list of lines, preserving text properties." + (when string + (let ((start 0) + (lines nil)) + (while (string-match "\n" string start) + (push (substring string start (match-beginning 0)) lines) + (setq start (match-end 0))) + (push (substring string start) lines) + (nreverse lines)))) + +(defsubst ebox-string-height (string) + "Return the number of lines in STRING. +Optimized: Counts newlines directly to avoid list allocation." + (1+ (cl-count ?\n string))) + +(defsubst ebox-lines-join (lines) + "Join a list of LINES into a single string." + (string-join lines "\n")) + +(defun ebox--string-repeat-lines (string count) + "Repeat a single line STRING for COUNT times." + (when (and string (> count 0)) + (ebox-lines-join (make-list count string)))) + +(defun ebox--maplines (function string) + "Apply FUNCTION to each line of STRING and return the new string." + (ebox-lines-join (mapcar function (ebox-string-lines string)))) + +(defun ebox--apply-hidden-visibility (string) + "Return STRING with all ink suppressed and the footprint intact. +Runs that already render as display specs keep their spec and lose +face color. Glyph runs are measured with their faces still applied +and then covered by one pixel-exact display space, so line widths are +identical in fixed and variable pitch while nothing is drawn. Buffer +text and every other text property (region, content, and owner +metadata, event surfaces) stay untouched, which keeps spans, markers, +and incremental diffs byte-compatible with the visible rendering." + (ebox--maplines + (lambda (line) + (if (string-empty-p line) + line + ;; Measure the complete visible line first (faces still applied, + ;; display specs included), then cover every character with its + ;; own blank display space. Per-character specs follow the + ;; engine's measurement convention, and the integer widths sum + ;; exactly to the visible line's width by construction. + (let* ((width (ebox--string-pixel-width line)) + (line (copy-sequence line)) + (length (length line)) + (base (/ width length)) + (remainder (- width (* base length)))) + (remove-text-properties + 0 length '(face nil mouse-face nil) line) + (dotimes (index length) + (put-text-property + index (1+ index) 'display + `(space :width (,(if (< index remainder) (1+ base) base))) + line)) + line))) + string)) + +(defun ebox--maplines-list (function lines) + "Apply FUNCTION to each line in the list LINES and return a new list." + (mapcar function lines)) + +(defun ebox--line-has-non-content-properties-p (line) + "Return non-nil when LINE carries properties other than content markers." + (catch 'found + (let ((pos 0) + (len (length line))) + (while (< pos len) + (let ((props (text-properties-at pos line))) + (while props + (let ((prop (pop props))) + (pop props) + (unless (memq prop '(display + ebox-content ebox-content-idx + ebox-content-owner + ebox-content-owners)) + (throw 'found t))))) + (setq pos (or (next-property-change pos line) len)))) + nil)) + +(defun ebox--lines-pad-vertical (string height &optional offset padding-string) + "Extend STRING to HEIGHT lines. +OFFSET controls content shift: positive (top), negative (bottom). +PADDING-STRING is used to fill empty lines, defaults to empty string." + (let* ((lines (ebox-string-lines string)) + (line-count (length lines)) + (padding-string (or padding-string + (ebox--pixel-blank + (ebox--string-pixel-width string) 1)))) + (when (> height line-count) + (let* ((rest (- height line-count)) + (offset (or offset 0)) + (top-pad (cond ((>= offset 0) (min offset rest)) + (t (max 0 (+ offset rest))))) + (bottom-pad (- rest top-pad)) + (pad-lines (make-list top-pad padding-string)) + (bottom-lines (make-list bottom-pad padding-string))) + (setq lines (append pad-lines lines bottom-lines)))) + (ebox-lines-join (seq-take lines height)))) + +(defun ebox--lines-align-vertical (string height align) + "Align STRING to HEIGHT lines. +ALIGN can be `top', `center', or `bottom'." + (let* ((line-count (ebox-string-height string)) + (offset (pcase align + ('top 0) + ('bottom (- height line-count)) + ('center (/ (- height line-count) 2)) + (_ 0)))) + (ebox--lines-pad-vertical string height offset))) + +(defun ebox--string-lines-pad-bottom (string height) + "Return STRING lines padded at the bottom to HEIGHT." + (let* ((lines (ebox-string-lines string)) + (line-count (length lines)) + (extra (- height line-count))) + (if (<= extra 0) + lines + (let* ((blank-width (ebox--string-pixel-width string)) + (blank (or (ebox--pixel-blank blank-width 1) ""))) + (append lines (make-list extra blank)))))) + +(defun ebox--lines-concat-horizontal (&rest strings) + "Concatenate STRINGS horizontally, aligning to the tallest string." + (setq strings (delq nil strings)) + (when strings + (let* ((heights (mapcar #'ebox-string-height strings)) + (max-height (apply #'max heights)) + (line-lists + (mapcar (lambda (string) + (ebox--string-lines-pad-bottom string max-height)) + strings))) + (ebox-lines-join + (apply #'cl-mapcar #'concat line-lists))))) + +(defun ebox--lines-stack-vertical (&rest strings) + "Stack STRINGS vertically." + (setq strings (delq nil strings)) + (ebox-lines-join strings)) + +(defsubst ebox-default-foreground () + "Return the default face foreground color." + (face-attribute 'default :foreground nil t)) + +(defsubst ebox-default-background () + "Return the default face background color." + (face-attribute 'default :background nil t)) + + +;;; ============================================================ +;;; Tree Identity And DSL Helpers +;;; ============================================================ +(defun ebox--extract-region-id (string) + "Extract region-id from rendered box STRING by finding first ebox-content." + (let ((pos 0) + (len (length string))) + (catch 'found + (while (< pos len) + (when-let ((id (get-text-property pos 'ebox-content string))) + (throw 'found id)) + (setq pos (or (next-single-property-change + pos 'ebox-content string) + len)))))) + +(defun ebox--string-region-ids (string) + "Return all distinct region-ids found in STRING, preserving first-seen order." + (let ((pos 0) + (len (length string)) + ids) + (while (< pos len) + (when-let ((id (get-text-property pos 'ebox-content string))) + (unless (memq id ids) + (setq ids (append ids (list id))))) + (setq pos (1+ pos))) + ids)) + +;;;###autoload +(defun ebox-region-ids (node) + "Return NODE's region ids in document order. +NODE can be an unrendered layout tree or an already-rendered string. +For layout trees, missing ids are assigned to box plists and remain stable +for later renders and dynamic updates. + +Use this to capture ids *before* inserting into a buffer: + + (let* ((layout (ebox-stack info-box (ebox-concat left right))) + (ids (ebox-region-ids layout)) + (info-id (nth 0 ids)) + (left-id (nth 1 ids)) + (right-id (nth 2 ids))) + (ebox-render-to-buffer \"*my-ui*\" layout) + ;; now use info-id / left-id / right-id for dynamic updates + )" + (cond + ((stringp node) + (ebox--string-region-ids node)) + ((not (listp node)) nil) + (t + (let ((type (plist-get node :ebox-type))) + (cond + ((eq type 'box) + (cons (ebox--ensure-region-id node) + (ebox-region-ids (plist-get node :ebox-content-node)))) + ((eq type 'concat) + (apply #'append + (mapcar #'ebox-region-ids + (ebox--layout-children node)))) + ((eq type 'stack) + (apply #'append + (mapcar #'ebox-region-ids + (ebox--layout-children node)))) + ((eq type 'flex) + (if-let ((box (plist-get node :box))) + (list (ebox--ensure-region-id box)) + (apply #'append + (mapcar #'ebox-region-ids (plist-get node :children))))) + ((eq type 'grid) + (if-let ((box (plist-get node :box))) + (list (ebox--ensure-region-id box)) + (apply #'append + (mapcar #'ebox-region-ids (plist-get node :children))))) + ((eq type 'flex-item) + (ebox-region-ids (plist-get node :node))) + (t nil)))))) + +(defun ebox--runtime-node-ids (node) + "Return internal runtime node ids for NODE and its children." + (cond + ((or (stringp node) (not (listp node))) nil) + (t + (cons (ebox--ensure-node-id node) + (apply #'append + (mapcar #'ebox--runtime-node-ids + (ebox--node-children node))))))) + +(defun ebox--runtime-tree (node) + "Return NODE after assigning runtime ids to every renderable node." + (ebox--runtime-node-ids node) + node) + +(defun ebox--runtime-node-key (node) + "Return NODE's explicit runtime key, or nil." + (when (and (listp node) + (plist-member node :key)) + (plist-get node :key))) + +(defun ebox--runtime-same-type-p (old new) + "Return non-nil when OLD and NEW have the same runtime node type." + (and (listp old) + (listp new) + (eq (plist-get old :ebox-type) + (plist-get new :ebox-type)))) + +(defun ebox--runtime-node-id-by-key (node key) + "Return the first runtime node id under NODE whose :key equals KEY." + (when (and key + (listp node) + (not (stringp node))) + (or (when (equal (ebox--runtime-node-key node) key) + (ebox--ensure-node-id node)) + (cl-some (lambda (child) + (ebox--runtime-node-id-by-key child key)) + (ebox--node-children node))))) + +(defun ebox--keyed-runtime-children (children) + "Return a KEY -> child hash table for explicitly keyed CHILDREN." + (let ((table (make-hash-table :test 'equal))) + (dolist (child children) + (when-let ((key (ebox--runtime-node-key child))) + (puthash key child table))) + table)) + +(defun ebox--runtime-positional-match (old-child new-child) + "Return OLD-CHILD when NEW-CHILD can reuse its positional identity." + (when (and (not (ebox--runtime-node-key old-child)) + (not (ebox--runtime-node-key new-child)) + (ebox--runtime-same-type-p old-child new-child)) + old-child)) + +(defun ebox--runtime-keyed-match (old-keyed new-child) + "Return the keyed old child that should match NEW-CHILD." + (when-let ((key (ebox--runtime-node-key new-child))) + (let ((old-child (gethash key old-keyed))) + (when (ebox--runtime-same-type-p old-child new-child) + old-child)))) + +(defun ebox--assign-runtime-node-id-from-match (old new) + "Assign NEW retained runtime identity from OLD when possible." + (ebox-tree-transfer-runtime-identity old new)) + +(defun ebox--reconcile-runtime-tree (old new) + "Transfer stable runtime identities from OLD to matching nodes in NEW. + +Matching rules are deliberately small: a child with an explicit :key only +matches an old sibling with the same key and node type; unkeyed children may +reuse same-position ids when their node type is unchanged. Retained boxes +also keep their region ids so buffer properties remain aligned with runtime +indexes. This prepares component identity without exposing a component API." + (ebox-tree-reconcile-runtime old new)) + +;;;###autoload +(defun ebox-build (dsl) + "Build an Ebox node from an ETML-style list DSL." + (ebox-dsl-build dsl)) + +(defmacro ebox--to-buffer (buffer-or-name switch-fn &rest body) + "Render into BUFFER-OR-NAME using SWITCH-FN, executing BODY." + (declare (indent 2)) + `(let ((buffer (get-buffer-create ,buffer-or-name))) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + ,@body) + (local-set-key "q" (lambda () (interactive) + (local-unset-key "q") + (quit-window))) + (read-only-mode 1) + (funcall ,switch-fn buffer)))) + +(defmacro ebox-pop-to-buffer (buffer-or-name &rest body) + (declare (indent defun)) + `(ebox--to-buffer ,buffer-or-name #'pop-to-buffer ,@body)) + +(defmacro ebox-switch-to-buffer (buffer-or-name &rest body) + (declare (indent defun)) + `(ebox--to-buffer ,buffer-or-name #'switch-to-buffer ,@body)) + +;;; ============================================================ +;;; Property Rules: 配置驱动的 Shorthand/Longhand 映射 +;;; ============================================================ +;; +;; 规则格式自描述,无需 type 标记: +;; - 值是 list → shorthand,展开到列表中的属性 +;; - 值是 (keyword . converter) → longhand,转换后写入目标 +;; - 值是 keyword → 直通,属性名不变 + +(defconst ebox-property-rules ebox-style-ebox-property-rules + "Compatibility alias for Ebox public property expansion rules.") + +;;; --- 值转换器 --- + +(defun ebox--convert (value converter) + "Convert VALUE using CONVERTER." + (ebox-style--convert value converter)) + +;;; --- 值拆分器 --- + +(defun ebox--split-trbl (value) + "Split VALUE using CSS TRBL rules → (top right bottom left)." + (ebox-style--split-trbl value)) + +(defun ebox--split-pair (value) + "Split VALUE into pair → (start end)." + (ebox-style--split-pair value)) + +(defconst ebox--border-styles + '(none hidden dotted dashed solid double groove ridge inset outset) + "Valid CSS border-style keywords.") + +(defun ebox--wsc-classify (item) + "Classify ITEM as width, style, or color. Returns (type . value)." + (ebox-style--wsc-classify item)) + +(defun ebox--split-wsc (value) + "Split VALUE into (width style color) using CSS type-based parsing. +Values can appear in any order; each is identified by type." + (ebox-style--split-wsc value)) + +(defun ebox--split-value (value mode n) + "Split VALUE according to MODE for N targets." + (ebox-style--split-value value mode n)) + +;;; --- 核心展开函数 --- + +(defun ebox--get-rule (property) + "Get rule for PROPERTY from `ebox-property-rules'." + (ebox-style--get-ebox-rule property)) + +(defun ebox--expand-property (property value) + "Expand PROPERTY with VALUE according to rules. Returns a plist." + (ebox-style-expand-ebox-property property value)) + +;;; --- 辅助函数 --- + +(defun ebox--atom-consp (list) + "Check if LIST is a strict dotted pair (atom . atom)." + (ebox-style--atom-consp list)) + +(defun ebox--parse-color (color) + "Parse COLOR to a real color string. +Supports: t (default fg), nil, string, (light . dark) cons." + (ebox-style--parse-color color)) + +(defun ebox--expand-plist (plist) + "Expand PLIST using `ebox-property-rules'." + (ebox-style-expand-ebox-plist plist)) + +;;; --- 核心 API --- + +;;;###autoload +(defun ebox-create (&rest plist) + "Create a box from PLIST. Supports all shorthand and longhand properties. +Properties are expanded recursively according to `ebox-property-rules'." + (let ((expanded (ebox--expand-plist plist)) + (controlled-scroll-offset-p (plist-member plist :scroll-offset))) + (let ((box (ebox--longhand-create expanded))) + (plist-put box :ebox-type 'box) + (plist-put box :display '(block flow)) + (when controlled-scroll-offset-p + (plist-put box :ebox-scroll-offset-controlled-p t)) + box))) + +;;; ============================================================ +;;; Buffer Runtime State & Scroll State +;;; ============================================================ + +;; Incremental runtime model boundaries: +;; - Source Tree Model: user/DSL plists with stable :region-id and :node-id. +;; - Render Context Model: dynamic bindings such as `ebox-viewport-width'. +;; - Buffer Runtime Model: per-buffer root node, viewport, and node indexes. +;; - Layout Snapshot Model: derived render facts used for diffing. +;; - Dirty/Patch Model: executable update choices for buffer edits. +;; +;; Dynamic render-local, flex-local, and snapshot-detail-local caches are +;; optimizations. Do not make them public API or component-owned state. + +(defvar ebox--region-box-table (make-hash-table :test 'equal) + "Maps region-id to its box definition for dynamic content updates.") + +(defvar ebox--box-extents (make-hash-table :test 'equal) + "Maps region-id to (start-marker . end-marker) for contiguous box roots. +Only boxes occupying one contiguous buffer slice are stored here.") + +(defvar-local ebox--box-extent-template nil + "Prepared numeric box extents for the current root publication.") + +(defvar ebox--defer-scroll-content-index nil + "Non-nil means full scroll content indexes are built lazily.") + +(defun ebox--scroll-get-state (region-id) + "Get scroll state for REGION-ID." + (gethash region-id ebox--scroll-global-state)) + +(defun ebox--scroll-cancel-idle-prefetch (region-id) + "Cancel any pending idle prefix prefetch for REGION-ID." + (when-let ((timer (gethash region-id ebox--scroll-idle-prefetch-timers))) + (when (timerp timer) + (cancel-timer timer)) + (remhash region-id ebox--scroll-idle-prefetch-timers))) + +(defun ebox--scroll-inhibit-idle-prefetch-for-buffer (buffer) + "Pause lazy scroll prefix work while BUFFER has a foreground animation." + (when (buffer-live-p buffer) + (puthash buffer t ebox--scroll-idle-prefetch-inhibited-buffers) + (maphash + (lambda (region-id state) + (when (eq buffer (ebox--scroll-state-buffer state)) + (ebox--scroll-cancel-idle-prefetch region-id))) + ebox--scroll-global-state) + t)) + +(defun ebox--scroll-resume-idle-prefetch-for-buffer (buffer) + "Resume useful lazy scroll prefix work after BUFFER's animation." + (when buffer + (remhash buffer ebox--scroll-idle-prefetch-inhibited-buffers) + (let (region-ids) + (maphash + (lambda (region-id state) + (when (and (eq buffer (ebox--scroll-state-buffer state)) + (ebox--scroll-idle-prefetch-needed-p state)) + (push region-id region-ids))) + ebox--scroll-global-state) + (dolist (region-id region-ids) + (ebox--scroll-schedule-idle-prefetch region-id))))) + +(defun ebox--scroll-idle-prefetch-needed-p (state) + "Return non-nil when STATE can benefit from idle prefix prefetch." + (and (> (or ebox-scroll-lazy-idle-prefetch-lines 0) 0) + (plist-get state :render-content-prefix) + (not (plist-get state :content-lines-complete-p)))) + +(defun ebox--idle-continuation-delay (delay) + "Return an idle-timer threshold one fresh DELAY beyond current idle time." + (+ (or (when-let ((idle (current-idle-time))) + (float-time idle)) + 0) + (max 0 (or delay 0)))) + +(defun ebox--scroll-schedule-idle-prefetch (region-id &optional delay) + "Schedule input-yielding lazy prefix prefetch for REGION-ID when useful." + (ebox--scroll-cancel-idle-prefetch region-id) + (when-let ((state (and (or (not noninteractive) + ebox--scroll-allow-noninteractive-prefetch) + (ebox--scroll-get-state region-id)))) + (when (and (ebox--scroll-idle-prefetch-needed-p state) + (let ((buffer (ebox--scroll-state-buffer state))) + (not (and buffer + (gethash + buffer + ebox--scroll-idle-prefetch-inhibited-buffers))))) + (puthash + region-id + (run-with-idle-timer + (max 0 (or delay ebox-scroll-lazy-idle-prefetch-delay 0)) + nil #'ebox--scroll-idle-prefetch region-id) + ebox--scroll-idle-prefetch-timers)))) + +(defun ebox--scroll-prefetch-slice-lines () + "Return a bounded urgent lazy prefetch slice size in lines." + (max 1 + (or ebox-scroll-step 1) + (or ebox-wheel-smooth-scroll-lines-per-tick 1) + (or ebox-wheel-scroll-step 1))) + +(defun ebox--scroll-sync-prefix-render-p () + "Return non-nil when scroll input may synchronously render lazy prefixes." + (or noninteractive + (not (display-graphic-p)))) + +(defun ebox--scroll-schedule-cache-miss-prefetch (region-id state) + "Schedule a bounded urgent prefetch for lazy scroll STATE." + (if (ebox--scroll-idle-prefetch-needed-p state) + (let* ((current-lines (length (plist-get state :content-lines))) + (target-lines + (+ current-lines (ebox--scroll-prefetch-slice-lines)))) + (setq state + (plist-put state :cache-miss-prefetch-target-lines + target-lines)) + (puthash region-id state ebox--scroll-global-state) + (ebox--scroll-schedule-idle-prefetch region-id 0) + state) + state)) + +(defun ebox--scroll-idle-prefetch-slice-lines (&optional state) + "Return STATE's bounded idle prefetch slice, or nil for unbounded. +Composite rows can hide expensive flex work. Limit latency-sensitive buffers +to one newly requested line per idle turn so one generic slice cannot cross +several costly rows before yielding back to input." + (let* ((slice (max 0 (or ebox-scroll-lazy-idle-prefetch-slice-lines 0))) + (buffer (and state (ebox--scroll-state-buffer state)))) + (when (and (> slice 0) + buffer + (ebox--buffer-latency-sensitive-scroll-prefix-p buffer)) + (setq slice 1)) + (and (> slice 0) slice))) + +(defun ebox--scroll-ensure-bounded-prefix-for-offset + (region-id state desired-offset &optional budget-lines) + "Extend lazy scroll STATE just enough to approach DESIRED-OFFSET. + +This is the synchronous edge of the lazy scroll model: visible scrolling can +only replace cached line strings after those strings exist. When the user +reaches the current lazy prefix boundary, render at most one configured +interactive slice instead of pausing for an idle timer or materializing the +entire source. BUDGET-LINES caps how many new source lines this call may +request; callers that represent GUI animation ticks pass their tick size." + (let* ((content-lines (plist-get state :content-lines)) + (content-height (or (plist-get state :content-height) 0)) + (current-lines (length content-lines)) + (max-offset (max 0 (- current-lines content-height)))) + (if (or (<= desired-offset max-offset) + (not (plist-get state :render-content-prefix)) + (plist-get state :content-lines-complete-p)) + state + (let* ((visible-end (+ (max 0 desired-offset) content-height)) + (slice-lines (max 1 (or budget-lines ebox-scroll-step 1))) + (slice-end (+ current-lines slice-lines)) + (required-lines (max current-lines + (min visible-end slice-end))) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (ebox--scroll-state-ensure-prefix-lines + region-id state required-lines t))))) + +(defun ebox--smooth-scroll-active-p (region-id) + "Return non-nil when REGION-ID has an active smooth scroll timer." + (when-let ((entry (gethash region-id ebox--smooth-scroll-state-table))) + (and (timerp (plist-get entry :timer)) + (not (plist-get entry :waiting-prefetch))))) + +(defun ebox--smooth-scroll-resume-after-prefetch (region-id) + "Resume REGION-ID smooth scrolling after a paused lazy prefetch." + (when-let ((entry (gethash region-id ebox--smooth-scroll-state-table))) + (when (plist-get entry :waiting-prefetch) + (plist-put entry :waiting-prefetch nil) + (unless (timerp (plist-get entry :timer)) + (plist-put + entry :timer + (run-at-time ebox-wheel-smooth-scroll-interval + ebox-wheel-smooth-scroll-interval + #'ebox--smooth-scroll-tick region-id))) + (puthash region-id entry ebox--smooth-scroll-state-table)))) + +(defun ebox--scroll-idle-prefetch (region-id) + "Warm REGION-ID's lazy scroll prefix cache while Emacs is idle." + (remhash region-id ebox--scroll-idle-prefetch-timers) + (if (ebox--smooth-scroll-active-p region-id) + (ebox--scroll-schedule-idle-prefetch + region-id + (ebox--idle-continuation-delay + ebox-scroll-lazy-idle-prefetch-delay)) + (when-let ((state (ebox--scroll-get-state region-id))) + (let ((buffer (ebox--scroll-state-buffer state)) + (cache-miss-target + (plist-get state :cache-miss-prefetch-target-lines))) + (if (and buffer + (not cache-miss-target) + (gethash buffer ebox--runtime-prewarm-jobs)) + (ebox--scroll-schedule-idle-prefetch + region-id + (ebox--idle-continuation-delay + ebox-runtime-idle-prewarm-prefix-resume-delay)) + (when (ebox--scroll-idle-prefetch-needed-p state) + (let* ((defer-gc + (and buffer + (cl-some + (lambda (window) + (display-graphic-p (window-frame window))) + (get-buffer-window-list buffer nil t)))) + (current-lines (length (plist-get state :content-lines))) + (prefetch-lines + (max 0 ebox-scroll-lazy-idle-prefetch-lines)) + (visible-target-lines + (+ (or (plist-get state :scroll-offset) 0) + (or (plist-get state :content-height) 0) + prefetch-lines)) + (target-lines + (if cache-miss-target + (max current-lines cache-miss-target) + (max (+ current-lines prefetch-lines) + visible-target-lines))) + (slice-lines (ebox--scroll-idle-prefetch-slice-lines state)) + (bounded-target-lines + (if slice-lines + (min target-lines (+ current-lines slice-lines)) + target-lines))) + (when defer-gc + (ebox--deferred-render-gc-enter)) + (unwind-protect + (progn + ;; A configured slice is the complete hidden-work budget + ;; for this idle turn. The generic prefix helper normally + ;; adds a completion sentinel and smooth-scroll lookahead; + ;; exact mode defers both so a one-line slice cannot turn + ;; back into a multi-row render and reintroduce input + ;; stalls. + (let ((ebox-scroll-lazy-prefix-lookahead-lines + (if slice-lines + 0 + ebox-scroll-lazy-prefix-lookahead-lines)) + (ebox--scroll-window-yielding-prewarm-p + (and slice-lines (= slice-lines 1)))) + (setq state + (ebox--scroll-state-ensure-prefix-lines + region-id state bounded-target-lines + (and slice-lines t)))) + (when (and cache-miss-target + (>= (length (plist-get state :content-lines)) + cache-miss-target)) + (setq state (ebox--plist-remove + state :cache-miss-prefetch-target-lines)) + (puthash region-id state ebox--scroll-global-state)) + (let ((prefix-still-needed-p + (ebox--scroll-idle-prefetch-needed-p state))) + (when prefix-still-needed-p + (ebox--scroll-schedule-idle-prefetch + region-id + (ebox--idle-continuation-delay + ebox-scroll-lazy-idle-prefetch-delay))) + ;; Restart the shared tree/index prewarm once after the + ;; lazy prefix settles. Restarting it after every + ;; one-line slice makes each following slice yield for + ;; the full priority delay and can starve idle prefix + ;; progress indefinitely. + (when (and buffer (not prefix-still-needed-p)) + (ebox--schedule-buffer-runtime-prewarm buffer))) + (ebox--smooth-scroll-resume-after-prefetch region-id)) + (when defer-gc + (ebox--deferred-render-gc-schedule-restore)))))))))) + +(defun ebox--scroll-content-marker-spans (markers) + "Return MARKERS as a plain list of marker span conses." + (cond + ((vectorp markers) (append markers nil)) + ((listp markers) markers) + (t nil))) + +(defun ebox--scroll-line-spans-in-window (start line-count) + "Return LINE-COUNT line spans starting at START in one forward scan." + (save-excursion + (goto-char start) + (cl-loop repeat line-count + collect (cons (point) (line-end-position)) + do (forward-line 1)))) + +(defun ebox--scroll-clear-content-markers (state) + "Detach content span markers stored in scroll STATE." + (dolist (span (ebox--scroll-content-marker-spans + (plist-get state :content-span-markers))) + (when (consp span) + (when (markerp (car span)) + (set-marker (car span) nil)) + (when (markerp (cdr span)) + (set-marker (cdr span) nil))))) + +(defun ebox--scroll-clear-rendered-window-markers (state) + "Detach rendered scroll window boundary markers stored in STATE." + (when-let ((start (plist-get state :rendered-window-start-marker))) + (when (markerp start) + (set-marker start nil))) + (when-let ((end (plist-get state :rendered-window-end-marker))) + (when (markerp end) + (set-marker end nil)))) + +(defun ebox--scroll-store-rendered-window-extents + (buffer region-id state start end line-count) + "Store lightweight rendered window extents for REGION-ID. +Unlike content span markers, these two markers are cheap enough for the scroll +hot path and are sufficient to anchor the next viewport replacement." + (when (and (buffer-live-p buffer) + (integer-or-marker-p start) + (integer-or-marker-p end) + (integerp line-count) + (> line-count 0)) + (with-current-buffer buffer + (let ((start-pos (if (markerp start) (marker-position start) start)) + (end-pos (if (markerp end) (marker-position end) end))) + (when (and start-pos end-pos (< start-pos end-pos)) + (let ((start-marker + (or (and (markerp (plist-get + state :rendered-window-start-marker)) + (plist-get state :rendered-window-start-marker)) + (make-marker))) + (end-marker + (or (and (markerp (plist-get + state :rendered-window-end-marker)) + (plist-get state :rendered-window-end-marker)) + (make-marker)))) + (set-marker-insertion-type start-marker nil) + (set-marker-insertion-type end-marker t) + (set-marker start-marker start-pos) + (set-marker end-marker end-pos) + (setq state + (plist-put state :rendered-window-start-marker + start-marker)) + (setq state + (plist-put state :rendered-window-end-marker + end-marker))) + (setq state + (plist-put state :rendered-window-line-count line-count)) + (setq state (plist-put state :buffer buffer)) + (puthash region-id state ebox--scroll-global-state) + state))))) + +(defun ebox--scroll-window-region-id-set (buffer ranges) + "Return all ebox region ids inside BUFFER RANGES as a hash set." + (let ((set (make-hash-table :test 'equal))) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (dolist (range ranges) + (let ((pos (car range)) + (end (cdr range))) + (while (< pos end) + (dolist (region-id (ebox--pos-region-ids pos)) + (puthash region-id t set)) + (setq pos (or (next-property-change pos nil end) + end))))))) + set)) + +(defun ebox--scroll-state-put-window-region-id-set (state value) + "Return STATE with cached visible region id set stored at the front." + (cons :content-window-region-id-set + (cons value + (ebox--plist-remove state :content-window-region-id-set)))) + +(defun ebox--scroll-state-store-window-region-id-set + (region-id state buffer ranges) + "Store visible region ids for REGION-ID/STATE from BUFFER RANGES." + (setq state + (ebox--scroll-state-put-window-region-id-set + state (ebox--scroll-window-region-id-set buffer ranges))) + (setq state (plist-put state :buffer buffer)) + (puthash region-id state ebox--scroll-global-state) + state) + +(defun ebox--scroll-state-clear-window-region-id-set + (region-id state &optional buffer) + "Clear the cached visible region id set for REGION-ID/STATE." + (when (or (plist-get state :content-window-region-id-set) + (and buffer (not (eq (plist-get state :buffer) buffer)))) + (setq state (plist-put state :content-window-region-id-set nil)) + (when buffer + (setq state (plist-put state :buffer buffer))) + (puthash region-id state ebox--scroll-global-state)) + state) + +(defun ebox--scroll-state-region-visible-in-window-p (state region-ids) + "Return non-nil when REGION-IDS appear in STATE's marker window." + (if-let ((region-set (plist-get state :content-window-region-id-set))) + (cl-some (lambda (region-id) + (gethash region-id region-set)) + region-ids) + (when-let ((markers (plist-get state :content-span-markers))) + (let ((region-set (ebox--region-id-set region-ids))) + (catch 'visible + (dolist (span (ebox--scroll-content-marker-spans markers)) + (when-let* ((start-marker (car-safe span)) + (end-marker (cdr-safe span)) + ((markerp start-marker)) + ((markerp end-marker)) + (buffer (marker-buffer start-marker)) + ((eq buffer (marker-buffer end-marker))) + (start (marker-position start-marker)) + (end (marker-position end-marker)) + ((< start end))) + (with-current-buffer buffer + (let ((pos start)) + (while (< pos end) + (when (cl-some (lambda (region-id) + (gethash region-id region-set)) + (ebox--pos-region-ids pos)) + (throw 'visible t)) + (setq pos (or (next-property-change pos nil end) + end))))))) + nil))))) + +(defun ebox--scroll-state-region-visible-in-content-range-p + (state region-ids) + "Return non-nil when REGION-IDS have spans in STATE's visible line range." + (when-let ((spans + (ebox--scroll-hot-content-line-spans + state region-ids))) + (ebox--scroll-state-spans-visible-p state spans))) + +(defun ebox--scroll-state-buffer (state) + "Return the buffer associated with scroll STATE, or nil." + (or (plist-get state :buffer) + (catch 'buffer + (dolist (span (ebox--scroll-content-marker-spans + (plist-get state :content-span-markers))) + (when-let ((buffer (and (consp span) + (markerp (car span)) + (marker-buffer (car span))))) + (throw 'buffer buffer))) + nil))) + +;;;###autoload +(defun ebox-scroll-state (region-id) + "Return the scroll state plist for REGION-ID, or nil. +The plist is read-only API for consumers that need to map visible scroll +content back to their own source model." + (ebox--scroll-get-state region-id)) + +(defconst ebox--scroll-cache-transient-state-keys + '(:buffer + :content-span-markers + :content-window-region-id-set + :rendered-window-start-marker + :rendered-window-end-marker + :rendered-window-line-count + :content-region-id-set + :region-line-bounds-index + :region-line-span-index + :region-line-span-index-deferred + :rendered-region-line-span-index + :rendered-region-line-span-index-deferred + :region-line-span-hints + :lazy-scroll-prefix-dirty + :lazy-scroll-window-refresh-required + :native-reflow-target-prefix-p + :native-reflow-prefix-reset-p + :native-reflow-visible-offset + :native-reflow-visible-lines) + "Scroll state fields rebuilt when cached rendered output is published.") + +(defvar ebox--render-cache-scroll-state-retained-cost-cache nil) + +(defun ebox--scroll-cache-state-template (state) + "Return a markerless shallow template for cached scroll STATE. +Content line conses and the prefix renderer remain shared because that closure +owns the exact continuation cache for the captured viewport context." + (let ((plist state) + template) + (while plist + (let ((key (pop plist)) + (value (pop plist))) + (unless (memq key ebox--scroll-cache-transient-state-keys) + (push key template) + (push value template)))) + (nreverse template))) + +(defun ebox--scroll-cache-line-list-retained-cost (lines) + "Return a cheap retained-byte estimate for cached scroll LINES." + (let ((total 0)) + (dolist (line lines total) + (cl-incf total 16) + (when (stringp line) + (cl-incf total (+ 64 (string-bytes line) + (* 16 (length line)))))))) + +(defun ebox--scroll-cache-state-template-retained-cost (template) + "Return retained bytes charged to cached scroll state TEMPLATE." + (+ (* 16 (length template)) + (ebox--scroll-cache-line-list-retained-cost + (plist-get template :content-lines)) + (ebox--scroll-cache-line-list-retained-cost + (plist-get template :rendered-content-lines)) + (if (plist-get template :render-content-prefix) 512 0) + (if (plist-get template :materialize-content-lines) 256 0))) + +(defun ebox--scroll-cache-side-effects-for-region-ids (region-id-set) + "Return cached scroll actions and their retained cost for REGION-ID-SET." + (let ((retained-cost 64) + actions) + (when (hash-table-p region-id-set) + (maphash + (lambda (region-id _) + (cl-incf retained-cost 48) + (if-let ((state (ebox--scroll-get-state region-id))) + (let ((template (ebox--scroll-cache-state-template state))) + (cl-incf + retained-cost + (or (and (hash-table-p + ebox--render-cache-scroll-state-retained-cost-cache) + (gethash + state + ebox--render-cache-scroll-state-retained-cost-cache)) + (let ((cost + (ebox--scroll-cache-state-template-retained-cost + template))) + (when (hash-table-p + ebox--render-cache-scroll-state-retained-cost-cache) + (puthash + state cost + ebox--render-cache-scroll-state-retained-cost-cache)) + cost))) + (push (list 'set region-id template) actions)) + (push (list 'clear region-id) actions))) + region-id-set)) + (list :scroll-actions actions :retained-cost retained-cost))) + +(defun ebox--record-render-cache-scroll-region (region-id) + "Record REGION-ID in the active rendered-node side-effect collector." + (when (hash-table-p ebox--render-cache-scroll-state-region-ids) + (puthash region-id t ebox--render-cache-scroll-state-region-ids))) + +(defun ebox--scroll-cache-actions-portable-p (actions) + "Return non-nil when ACTIONS can move from an isolated to a live tree. +Complete scroll templates contain no continuation closures and can therefore +be rebound by node id. Lazy prefix closures retain their source tree and must +remain transaction-local." + (cl-every + (lambda (action) + (pcase action + (`(set ,_region-id ,template) + (and (null (plist-get template :render-content-prefix)) + (null (plist-get template :materialize-content-lines)))) + (`(clear ,_region-id) t) + (_ nil))) + actions)) + +(defun ebox--scroll-cache-live-box (region-id template) + "Return REGION-ID's live box corresponding to cached TEMPLATE." + (let* ((template-box (plist-get template :box)) + (node-id (and template-box (ebox--ensure-node-id template-box))) + (node-table (ebox--buffer-node-table (current-buffer)))) + (or (and node-table node-id (gethash node-id node-table)) + (plist-get (ebox--scroll-get-state region-id) :box) + (gethash region-id ebox--region-box-table)))) + +(defun ebox--scroll-cache-actions-replayable-p (actions &optional portable) + "Return non-nil when cached scroll ACTIONS still match the live runtime." + (let ((node-table (ebox--buffer-node-table (current-buffer)))) + (cl-every + (lambda (action) + (pcase action + (`(set ,region-id ,template) + (let* ((box (plist-get template :box)) + (node-id (and box (ebox--ensure-node-id box))) + (live-state (ebox--scroll-get-state region-id)) + (effective-box + (if portable + (ebox--scroll-cache-live-box region-id template) + box))) + (and effective-box + (equal (or (plist-get template :scroll-offset) 0) + (or (ebox-get effective-box :scroll-offset) 0)) + (or portable + (null node-table) + (eq box (gethash node-id node-table))) + (or (null live-state) + (eq effective-box (plist-get live-state :box))) + (not (plist-get live-state :lazy-scroll-prefix-dirty)) + (not (plist-get + live-state :lazy-scroll-window-refresh-required))))) + (`(clear ,_region-id) t) + (_ nil))) + actions))) + +(defun ebox--replay-scroll-cache-actions (actions &optional portable) + "Replay cached scroll ACTIONS into the current render runtime." + (dolist (action actions) + (pcase action + (`(set ,region-id ,template) + (let ((state (copy-sequence template))) + (when portable + (plist-put state :box + (ebox--scroll-cache-live-box region-id template))) + (ebox--scroll-set-state region-id state))) + (`(clear ,region-id) + (ebox--scroll-clear-state region-id)) + (_ + (error "Invalid cached scroll action: %S" action)))) + t) + +(defun ebox--scroll-set-state (region-id state) + "Set scroll STATE for REGION-ID." + (when (bound-and-true-p + ebox--collect-rebuilt-scroll-state-region-ids) + (cl-pushnew region-id ebox--rebuilt-scroll-state-region-ids + :test #'equal)) + (when-let ((old-state (ebox--scroll-get-state region-id))) + (ebox--scroll-clear-content-markers old-state) + (ebox--scroll-clear-rendered-window-markers old-state)) + (when-let ((content-lines (plist-get state :content-lines))) + (let ((ebox--defer-scroll-content-index + (or ebox--defer-scroll-content-index + (plist-get state :render-content-prefix) + (plist-get state :materialize-content-lines)))) + (setq state + (ebox--scroll-state-set-lines + state content-lines + (plist-get state :rendered-content-lines))))) + (puthash region-id state ebox--scroll-global-state) + (ebox--record-render-cache-scroll-region region-id) + (ebox--scroll-schedule-idle-prefetch + region-id ebox--scroll-prefetch-delay-override)) + +(defun ebox--scroll-clear-state (region-id) + "Remove scroll state for REGION-ID." + (ebox--scroll-cancel-idle-prefetch region-id) + (when-let ((old-state (ebox--scroll-get-state region-id))) + (ebox--scroll-clear-content-markers old-state) + (ebox--scroll-clear-rendered-window-markers old-state)) + (remhash region-id ebox--scroll-global-state) + (ebox--record-render-cache-scroll-region region-id)) + +(defun ebox--scroll-state-rendered-visible-window (state) + "Return STATE's current rendered visible slice, including text properties." + (let* ((content-height (plist-get state :content-height)) + (offset (or (plist-get state :scroll-offset) 0)) + (native-offset (plist-get state :native-reflow-visible-offset)) + (native-lines (plist-get state :native-reflow-visible-lines))) + (cond + ((and (integerp content-height) + (>= content-height 0) + (equal native-offset offset) + (= (length native-lines) content-height)) + (list offset native-lines)) + ((and (integerp content-height) (>= content-height 0)) + (when-let ((lines (plist-get state :rendered-content-lines))) + (let* ((max-offset (max 0 (- (length lines) content-height))) + (offset (max 0 (min max-offset offset))) + (end (min (+ offset content-height) (length lines)))) + (list offset (seq-subseq lines offset end)))))))) + +(defun ebox--scroll-rendered-visible-windows-equal-p (left right) + "Return non-nil when LEFT and RIGHT have identical text and properties." + (and left right + (= (car left) (car right)) + (= (length (cadr left)) (length (cadr right))) + (cl-every #'equal-including-properties (cadr left) (cadr right)))) + +(defun ebox--scroll-state-set-visible-refresh-requirement + (state old-visible old-refresh-required) + "Return STATE with an exact visible-window refresh requirement. +OLD-VISIBLE is the rendered window before rebuilding cached lines. Preserve an +existing OLD-REFRESH-REQUIRED request, otherwise require a refresh only when +the currently displayed rendered slice actually changed." + (let ((new-visible (ebox--scroll-state-rendered-visible-window state))) + (if (or old-refresh-required + (not (ebox--scroll-rendered-visible-windows-equal-p + old-visible new-visible))) + (plist-put state :lazy-scroll-window-refresh-required t) + (ebox--plist-remove state :lazy-scroll-window-refresh-required)))) + +(defun ebox--scroll-state-materialize-lines (region-id state) + "Return STATE after materializing lazy scroll content for REGION-ID." + (if-let ((materialize (plist-get state :materialize-content-lines))) + (let* ((old-visible + (ebox--scroll-state-rendered-visible-window state)) + (old-refresh-required + (plist-get state :lazy-scroll-window-refresh-required)) + (buffer (ebox--scroll-state-buffer state)) + (materialized + (if (and buffer (ebox--buffer-render-state buffer)) + (ebox--with-buffer-render-context buffer + (ebox--with-validated-display-cache + (funcall materialize state region-id))) + (ebox--with-validated-display-cache + (funcall materialize state region-id)))) + (content-lines (plist-get materialized :content-lines)) + (rendered-lines (plist-get materialized :rendered-content-lines))) + (if (not content-lines) + state + (let ((ebox--defer-scroll-content-index t)) + (setq state + (ebox--scroll-state-set-lines + state content-lines rendered-lines))) + (setq state (plist-put state :content-height + (plist-get materialized :content-height))) + (setq state (plist-put state :box + (or (plist-get materialized :box) + (plist-get state :box)))) + (setq state (plist-put state :content-lines-complete-p t)) + (setq state (ebox--plist-remove state :materialize-content-lines)) + (setq state (ebox--plist-remove state + :native-reflow-materialize-p)) + (dolist (key '(:native-reflow-target-prefix-p + :native-reflow-prefix-reset-p + :native-reflow-visible-offset + :native-reflow-visible-lines)) + (setq state (ebox--plist-remove state key))) + (setq state (ebox--plist-remove state :render-content-prefix)) + (setq state (ebox--plist-remove state :lazy-scroll-prefix-dirty)) + (setq state + (ebox--scroll-state-set-visible-refresh-requirement + state old-visible old-refresh-required)) + (ebox--scroll-cancel-idle-prefetch region-id) + (puthash region-id state ebox--scroll-global-state) + state)) + state)) + +(defun ebox--scroll-state-ensure-prefix-lines + (region-id state required-lines &optional exact-prefix) + "Return STATE after rendering lazy scroll content through REQUIRED-LINES. +This preserves the lazy scroll contract: scroll input should extend only the +prefix needed by the target visible window, not materialize the whole source. +When EXACT-PREFIX is non-nil, do not render the extra completion sentinel; +interactive callers can publish an already-cached visible prefix without +probing source completion, while idle prefetch retains the sentinel." + (let* ((required-lines (max 0 (or required-lines 0))) + (old-visible + (ebox--scroll-state-rendered-visible-window state)) + (old-refresh-required + (plist-get state :lazy-scroll-window-refresh-required)) + (native-target-p + (plist-get state :native-reflow-target-prefix-p)) + (native-reset-p + (plist-get state :native-reflow-prefix-reset-p)) + (content-lines (plist-get state :content-lines)) + (current-lines (length content-lines)) + (prefix-dirty + (or (plist-get state :lazy-scroll-prefix-dirty) + native-target-p)) + (render-prefix (plist-get state :render-content-prefix)) + (extension-lines (max 0 (- required-lines current-lines))) + (interactive-step + (max 1 + (or ebox-scroll-step 1) + (or ebox-wheel-smooth-scroll-lines-per-tick 1))) + (lookahead-lines + (if (<= extension-lines + interactive-step) + (max 0 ebox-scroll-lazy-prefix-lookahead-lines) + 0)) + (sentinel-lines + (if (and (> extension-lines 0) (not exact-prefix)) 1 0)) + (render-target-lines + (+ required-lines sentinel-lines lookahead-lines))) + (if (or (and (not prefix-dirty) + (<= required-lines current-lines)) + (plist-get state :content-lines-complete-p) + (not render-prefix)) + state + (let* ((buffer (ebox--scroll-state-buffer state)) + (render-state + (if native-reset-p + (plist-put (copy-sequence state) + :lazy-scroll-prefix-dirty t) + state)) + (rendered + (if (and buffer (ebox--buffer-render-state buffer)) + (ebox--with-buffer-render-context buffer + (ebox--with-validated-display-cache + (funcall render-prefix render-state region-id + render-target-lines))) + (ebox--with-validated-display-cache + (funcall render-prefix render-state region-id + render-target-lines)))) + (prefix-lines (plist-get rendered :content-lines)) + (rendered-lines (plist-get rendered :rendered-content-lines))) + (if (not prefix-lines) + state + (let ((ebox--defer-scroll-content-index t)) + (setq state + (ebox--scroll-state-set-lines + state prefix-lines rendered-lines))) + (setq state + (plist-put state :content-height + (or (plist-get rendered :content-height) + (plist-get state :content-height)))) + (setq state (plist-put state :box + (or (plist-get rendered :box) + (plist-get state :box)))) + (setq state + (plist-put state :content-lines-complete-p + (plist-get rendered :complete))) + (setq state + (ebox--plist-remove state :native-reflow-prefix-reset-p)) + (setq state (ebox--plist-remove + state :lazy-scroll-prefix-dirty)) + (when (and native-target-p + (>= (length prefix-lines) + (+ (or (plist-get state :scroll-offset) 0) + (or (plist-get state :content-height) 0)))) + (setq state + (ebox--plist-remove + state :native-reflow-target-prefix-p)) + (setq state + (ebox--plist-remove + state :native-reflow-visible-offset)) + (setq state + (ebox--plist-remove + state :native-reflow-visible-lines))) + ;; A clean extension normally appends only offscreen lines. Avoid a + ;; needless stop-and-refresh when the exact visible rendered slice is + ;; unchanged, but retain the atomic refresh gate when rebuilt text or + ;; redisplay properties differ. + (setq state + (ebox--scroll-state-set-visible-refresh-requirement + state old-visible old-refresh-required)) + ;; Native publication already exposed this exact visible window. + ;; Property-list order can differ between the live patch and the + ;; freshly rendered prefix while their text, faces and display + ;; geometry are identical. Keep the bounded line-slide path when + ;; the live marker window proves that visual identity. + (when (and native-target-p + (not (plist-get state + :native-reflow-target-prefix-p)) + buffer + (ebox--scroll-state-window-markers-healthy-p + buffer region-id state)) + (setq state + (ebox--plist-remove + state :lazy-scroll-window-refresh-required))) + (when (plist-get rendered :complete) + (setq state (ebox--plist-remove + state :materialize-content-lines)) + (setq state (ebox--plist-remove + state :render-content-prefix)) + (ebox--scroll-cancel-idle-prefetch region-id)) + (puthash region-id state ebox--scroll-global-state) + state))))) + +(defun ebox--next-content-span-change (pos limit) + "Return the next visible content span property change after POS." + (let ((next limit)) + (dolist (prop '(ebox-content ebox-content-idx)) + (let ((change (next-single-property-change pos prop nil limit))) + (when (and change (< change next)) + (setq next change)))) + next)) + +(defun ebox--scroll-state-with-content-marker-spans + (buffer state entries &optional defer-window-region-set) + "Return STATE with marker-backed content ENTRIES in BUFFER." + (let* ((max-idx (cl-loop for entry in entries + maximize (or (car entry) 0))) + (markers (make-vector (1+ (or max-idx 0)) nil)) + ranges) + (with-current-buffer buffer + (dolist (entry entries) + (pcase-let ((`(,idx ,start . ,end) entry)) + (when (and (integerp idx) start end) + (let ((start-marker (copy-marker start nil)) + (end-marker (copy-marker end t))) + (aset markers idx (cons start-marker end-marker)) + (push (cons start end) ranges)))))) + (setq state (plist-put state :content-span-markers markers)) + (if defer-window-region-set + (progn + (setq state (plist-put state :content-window-region-id-set nil)) + (plist-put state :buffer buffer)) + (ebox--scroll-state-put-window-region-id-set + state (ebox--scroll-window-region-id-set buffer ranges))))) + +(defun ebox--scroll-store-content-marker-spans + (buffer region-id state entries &optional defer-window-region-set) + "Store marker-backed visible content ENTRIES for REGION-ID in BUFFER." + (let ((old-state state)) + (setq state + (ebox--scroll-state-with-content-marker-spans + buffer (copy-sequence state) entries + defer-window-region-set)) + (puthash region-id state ebox--scroll-global-state) + (ebox--scroll-clear-content-markers old-state) + (ebox--scroll-state-window-extents region-id state))) + +(defun ebox--scroll-window-region-id-set-in-spans (spans) + "Return scroll viewport region ids marked inside buffer SPANS." + (let ((region-set (make-hash-table :test 'equal))) + (dolist (span spans) + (let ((pos (car span)) + (end (cdr span))) + (while (< pos end) + (when-let ((region-id + (get-text-property pos 'ebox-scroll-window))) + (puthash region-id t region-set)) + (setq pos + (or (next-single-property-change + pos 'ebox-scroll-window nil end) + end))))) + region-set)) + +(defun ebox--refresh-root-owner-scroll-windows (buffer results) + "Refresh lightweight scroll window markers after one root owner replacement. +Return non-nil when RESULTS cover BUFFER's root and every scroll viewport can +use the rendered-window fast path." + (when (and (= (length results) 1) + (equal (plist-get (car results) :owner-id) + (ebox--buffer-root-node-id buffer))) + (let ((region-set (ebox--buffer-region-id-set buffer)) + (rendered-scroll-region-set + (with-current-buffer buffer + (ebox--scroll-window-region-id-set-in-spans + (plist-get (car results) :new-buffer-spans)))) + (replacement-spans (plist-get (car results) :new-buffer-spans)) + region-ids + windows + compatible) + (maphash + (lambda (region-id state) + (when (and (gethash region-id region-set) + (gethash region-id rendered-scroll-region-set) + (plist-get state :box)) + (push region-id region-ids))) + ebox--scroll-global-state) + (setq region-ids (nreverse region-ids)) + (if (null region-ids) + t + (with-current-buffer buffer + (if (ebox-buffer--region-role-span-table) + (ebox--refresh-box-extents-from-role-spans + region-ids replacement-spans) + (ebox--refresh-scroll-window-extents-from-render-markers + region-ids replacement-spans)) + (setq compatible t) + (dolist (region-id region-ids) + (let* ((state (ebox--scroll-get-state region-id)) + (box (plist-get state :box)) + (height (plist-get state :content-height)) + (extents (ebox--live-box-extents region-id)) + (start (and extents + (marker-position (car extents)))) + (end (and extents + (marker-position (cdr extents))))) + (if (and (ebox--scroll-chrome-free-rendered-window-p box) + (integerp height) + (> height 0) + start end (< start end) + (= (count-lines start end) height)) + (push (list region-id state start end height) windows) + (setq compatible nil)))) + (when compatible + (dolist (window windows) + (pcase-let ((`(,region-id ,state ,start ,end ,height) window)) + (ebox--scroll-clear-content-markers state) + (setq state (plist-put state :content-span-markers nil)) + (setq state + (plist-put state :content-window-region-id-set nil)) + (ebox--scroll-store-rendered-window-extents + buffer region-id state start end height))) + (when-let ((render-state (ebox--buffer-render-state buffer))) + (plist-put render-state :scroll-region-ids region-ids)) + t)))))) + +(defun ebox--face-visual-attributes (face) + "Return FACE's effective visual attributes under Emacs merge order. +Earlier entries of an anonymous face list take precedence, so for +every attribute keyword the first provider wins and the result is +independent of how contributors were merged or split. Named faces +cannot be attribute-merged without display context, so any face +containing a named entry compares by its raw representation instead." + (let ((entries (if (and (listp face) (not (keywordp (car-safe face)))) + face + (list face))) + attributes named) + (dolist (entry entries) + (cond + ((null entry) nil) + ((and (listp entry) (keywordp (car-safe entry))) + (cl-loop for (key value) on entry by #'cddr + unless (assq key attributes) + do (push (cons key value) attributes))) + (t (setq named t)))) + (if named + (list 'raw face) + (list 'visual + (sort attributes + (lambda (a b) + (string< (symbol-name (car a)) + (symbol-name (car b))))))))) + +(defun ebox--scroll-string-visual-signature (string) + "Return STRING's visual signature as pixel-extent glyph runs. +Each run is (START-PIXEL END-PIXEL TEXT DISPLAY VISUAL): equal runs +carry the same characters, the same display spec, the same effective +visual attributes, and the same pixel extent, which fully determines +their rendering. Faces compare by `ebox--face-visual-attributes' +rather than raw face shape: independent patch paths legitimately +produce different face representations for the same visible result, +and this signature's contract is visual equality. Pixel widths are +measured once per property run, never per character." + (let ((pixel 0) + (length (length string)) + (position 0) + runs) + (cl-flet + ((record (start end text display visual) + ;; TEXT is nil for visually blank segments; blank extents + ;; merge across property boundaries so equality does not + ;; depend on how invisible properties segment identical + ;; lines. Contiguous word segments with equal display and + ;; visual attributes merge the same way. + (let ((run (car runs))) + (cond + ((and run (null text) (null (nth 2 run)) + (equal (nth 4 run) visual)) + (setcar (cdr run) end)) + ((and run text (nth 2 run) + (equal (nth 3 run) display) + (equal (nth 4 run) visual)) + (setcar (cdr run) end) + (setcar (cddr run) (concat (nth 2 run) text))) + (t (push (list start end text display visual) runs)))))) + (while (< position length) + (let* ((next (or (next-property-change position string) length)) + (face (get-text-property position 'face string)) + (display (get-text-property position 'display string)) + (visual (ebox--face-visual-attributes face)) + (space-display-p (eq (car-safe display) 'space))) + (cond + (space-display-p + (let ((width (ebox--substring-pixel-width string position next))) + (record pixel (+ pixel width) nil nil visual) + (setq pixel (+ pixel width)))) + (display + (let ((width (ebox--substring-pixel-width string position next))) + (record pixel (+ pixel width) + (substring-no-properties string position next) + display visual) + (setq pixel (+ pixel width)))) + (t + (let ((cursor position)) + (while (< cursor next) + (let ((blank (eq (aref string cursor) ?\s)) + (segment-end cursor)) + (while (and (< segment-end next) + (eq (eq (aref string segment-end) ?\s) + blank)) + (setq segment-end (1+ segment-end))) + (let ((width (ebox--substring-pixel-width + string cursor segment-end))) + (record pixel (+ pixel width) + (unless blank + (substring-no-properties + string cursor segment-end)) + nil visual) + (setq pixel (+ pixel width))) + (setq cursor segment-end)))))) + (setq position next)))) + (list pixel (nreverse runs)))) + +(defun ebox--scroll-lines-visually-equal-p (actual expected) + "Return non-nil when ACTUAL and EXPECTED line lists render identically. +Raw string/property equality is a sufficient fast path; only lines +whose representations diverge pay for pixel-measured signatures." + (and (= (length actual) (length expected)) + (cl-every + (lambda (actual-line expected-line) + (or (equal-including-properties actual-line expected-line) + (equal (ebox--scroll-string-visual-signature actual-line) + (ebox--scroll-string-visual-signature expected-line)))) + actual expected))) + +(defun ebox--scroll-state-window-markers-healthy-p + (buffer region-id state) + "Return non-nil when STATE's live BUFFER window equals its cached slice." + (when-let* (((buffer-live-p buffer)) + (box (plist-get state :box)) + (height (plist-get state :content-height)) + ((integerp height)) + ((> height 0)) + ((eq (ebox--scroll-state-buffer state) buffer))) + (let* ((offset (or (plist-get state :scroll-offset) 0)) + (native-offset (plist-get state :native-reflow-visible-offset)) + (native-lines (plist-get state :native-reflow-visible-lines)) + (chrome-free-p + (ebox--scroll-chrome-free-rendered-window-p box)) + (marker-spans + (delq nil + (ebox--scroll-content-marker-spans + (plist-get state :content-span-markers)))) + (contiguous-marker-window-p + (and chrome-free-p + (= (length marker-spans) height) + (ebox--scroll-content-window + (plist-put (copy-sequence state) :region-id region-id) + height))) + (lines + (if chrome-free-p + (or (plist-get state :rendered-content-lines) + (plist-get state :content-lines)) + (plist-get state :content-lines))) + (visible + (cond + ((and (equal native-offset offset) + (= (length native-lines) height)) + (list offset native-lines)) + (lines + (let ((end (min (length lines) (+ offset height)))) + (and (<= (+ offset height) (length lines)) + (list offset (seq-subseq lines offset end))))))) + (expected (and visible + (= (length (cadr visible)) height) + (cadr visible))) + (expected + (if (and expected + (not chrome-free-p)) + (let ((decorated + (ebox--scroll-decorate-rendered-window-lines + expected box region-id))) + (if (= (length decorated) height) + decorated + expected)) + expected))) + (and expected + (if (and chrome-free-p + (or (null marker-spans) contiguous-marker-window-p)) + (when-let* ((extents (ebox--scroll-state-window-extents + region-id state)) + (start (car extents)) + (end (cdr extents)) + ((markerp start)) + ((markerp end)) + ((eq (marker-buffer start) buffer)) + ((eq (marker-buffer end) buffer)) + (start-pos (marker-position start)) + (end-pos (marker-position end)) + ((< start-pos end-pos)) + (expected + (with-current-buffer buffer + (ebox--scroll-lines-with-live-wrapper-context + expected start-pos region-id)))) + (and (= (with-current-buffer buffer + (count-lines start-pos end-pos)) + height) + (ebox--scroll-lines-visually-equal-p + (with-current-buffer buffer + (ebox-string-lines + (buffer-substring start-pos end-pos))) + expected))) + (let ((spans marker-spans)) + (and (= (length spans) height) + (cl-every + (lambda (span) + (when-let* (((consp span)) + (start (car span)) + (end (cdr span)) + ((markerp start)) + ((markerp end)) + ((eq (marker-buffer start) buffer)) + ((eq (marker-buffer end) buffer)) + (start-pos (marker-position start)) + (end-pos (marker-position end))) + (< start-pos end-pos))) + spans) + (ebox--scroll-lines-visually-equal-p + (with-current-buffer buffer + (mapcar + (lambda (span) + (buffer-substring + (marker-position (car span)) + (marker-position (cdr span)))) + spans)) + expected)))))))) + +(defun ebox--publish-scoped-owner-scroll-windows + (buffer results source-node-ids + &optional pre-containing-region-ids rebuilt-region-ids + final-spans-by-result) + "Atomically publish scroll windows rebuilt by owner rerenders in BUFFER. +RESULTS are already-applied backend patch results and SOURCE-NODE-IDS are the +runtime nodes whose changes produced them. One buffer-wide marker rebuild is +performed only after every replacement is complete, so nested scroll windows +cannot invalidate markers installed for one another. `:covered-p' is non-nil +only when every source-containing state and every scroll state rebuilt inside +RESULTS has a verified live window from this same buffer generation." + (when (and (buffer-live-p buffer) results source-node-ids) + (let ((buffer-region-set (ebox--buffer-region-id-set buffer)) + (rendered-region-set (make-hash-table :test 'equal)) + rendered-region-ids + candidate-region-ids + published-region-ids + (affected-region-ids (copy-sequence pre-containing-region-ids))) + (with-current-buffer buffer + (dolist (result results) + (when (eq (plist-get result :op) 'owner-rerender) + (let ((spans + (or (and final-spans-by-result + (gethash result final-spans-by-result)) + (plist-get result :new-buffer-spans)))) + (maphash + (lambda (region-id _present) + (when (gethash region-id buffer-region-set) + (puthash region-id t rendered-region-set))) + (ebox--scroll-window-region-id-set-in-spans spans))))) + (maphash (lambda (region-id _present) + (push region-id rendered-region-ids)) + rendered-region-set)) + (dolist (region-id rendered-region-ids) + (cl-pushnew region-id affected-region-ids :test #'equal)) + (dolist (region-id rebuilt-region-ids) + (when-let ((state (and (gethash region-id buffer-region-set) + (ebox--scroll-get-state region-id)))) + (when (eq (ebox--scroll-state-buffer state) buffer) + (cl-pushnew region-id affected-region-ids :test #'equal) + (cl-pushnew region-id candidate-region-ids :test #'equal)))) + (dolist (region-id rendered-region-ids) + (cl-pushnew region-id candidate-region-ids :test #'equal)) + (when candidate-region-ids + ;; Install every content marker generation together. This handles + ;; chromed, chrome-free, root and nested scroll owners without a + ;; per-state zero-delta rewrite. + (ebox--refresh-buffer-scroll-content-markers buffer) + (dolist (region-id candidate-region-ids) + (when-let ((state (ebox--scroll-get-state region-id))) + (when (ebox--scroll-state-window-markers-healthy-p + buffer region-id state) + (setq state + (ebox--plist-remove + state :lazy-scroll-window-refresh-required)) + (puthash region-id state ebox--scroll-global-state) + (push region-id published-region-ids)))) + (list :covered-p + (cl-every + (lambda (region-id) + (member region-id published-region-ids)) + affected-region-ids) + :covered-region-ids (nreverse affected-region-ids) + :published-region-ids (nreverse published-region-ids)))))) + +(defun ebox--refresh-scroll-window-extents-from-render-markers + (region-ids spans) + "Refresh scroll REGION-IDS in SPANS from rendered window markers. +Root reflow invalidates the broad role index. Chrome-free scroll boxes mark +their exact visible window on the rendered string, so recovering those ranges +requires only one dedicated property scan rather than generic role inference." + (ebox--clear-box-extents-for-region-ids region-ids) + (when spans + (let ((region-set (ebox--region-id-set region-ids)) + (ranges (make-hash-table :test 'equal))) + (dolist (span spans) + (let ((pos (car span)) + (end (cdr span))) + (while (< pos end) + (let* ((region-id + (get-text-property pos 'ebox-scroll-window)) + (next + (or (next-single-property-change + pos 'ebox-scroll-window nil end) + end))) + (when (and region-id (gethash region-id region-set)) + (if-let ((range (gethash region-id ranges))) + (progn + (setcar range (min (car range) pos)) + (setcdr range (max (cdr range) next))) + (puthash region-id (cons pos next) ranges))) + (setq pos (max next (1+ pos))))))) + (maphash + (lambda (region-id range) + (when (< (car range) (cdr range)) + (ebox--set-box-extents region-id (car range) (cdr range)))) + ranges)))) + +(defun ebox--refresh-buffer-scroll-content-markers (buffer) + "Refresh marker-backed scroll content spans for scroll regions in BUFFER." + (when (and (buffer-live-p buffer) + (> (hash-table-count ebox--scroll-global-state) 0)) + (let ((spans-by-region (make-hash-table :test 'equal)) + (owner-line-indexes (make-hash-table :test 'equal)) + (seen (make-hash-table :test 'equal)) + scroll-region-ids) + (cl-labels + ((record (region-id idx start end) + (when (and region-id + (integerp idx) + (ebox--scroll-get-state region-id) + (< start end)) + (puthash region-id t seen) + (puthash region-id + (cons (cons idx (cons start end)) + (gethash region-id spans-by-region)) + spans-by-region))) + (owner-ids-at (pos) + (let (ids) + (dolist (region-id (get-text-property + pos 'ebox-content-owners)) + (cl-pushnew region-id ids :test #'equal)) + (when-let ((region-id (get-text-property + pos 'ebox-content-owner))) + (cl-pushnew region-id ids :test #'equal)) + ids)) + (line-owner-spans (start end) + (let ((pos start) + spans) + (while (< pos end) + (let ((next (or (next-property-change pos nil end) end))) + (dolist (region-id (owner-ids-at pos)) + (when (ebox--scroll-get-state region-id) + (let ((span (alist-get region-id spans nil nil + #'equal))) + (if span + (setcdr span next) + (push (cons region-id (cons pos next)) + spans))))) + (setq pos (max next (1+ pos))))) + spans))) + (with-current-buffer buffer + (save-excursion + (goto-char (point-min)) + (while (< (point) (point-max)) + (let* ((region-id (get-text-property (point) 'ebox-content)) + (idx (get-text-property (point) 'ebox-content-idx)) + (next (ebox--next-content-span-change + (point) (point-max)))) + (record region-id idx (point) next) + (goto-char (max next (1+ (point))))))) + (save-excursion + (goto-char (point-min)) + (while (< (point) (point-max)) + (let* ((line-start (point)) + (line-end (line-end-position))) + (when (< line-start line-end) + (dolist (entry (line-owner-spans line-start line-end)) + (let* ((region-id (car entry)) + (span (cdr entry)) + (idx (gethash region-id owner-line-indexes 0))) + (record region-id idx (car span) (cdr span)) + (puthash region-id (1+ idx) owner-line-indexes)))) + (forward-line 1)))))) + (maphash + (lambda (region-id entries) + (when-let ((state (ebox--scroll-get-state region-id))) + (ebox--scroll-store-content-marker-spans + buffer region-id state (nreverse entries)))) + spans-by-region) + (maphash + (lambda (region-id state) + (when (eq (ebox--scroll-state-buffer state) buffer) + (push region-id scroll-region-ids) + (unless (gethash region-id seen) + (ebox--scroll-clear-content-markers state) + (setq state (plist-put state :content-span-markers nil)) + (setq state (plist-put state :content-window-region-id-set nil)) + (setq state (plist-put state :buffer nil)) + (puthash region-id state ebox--scroll-global-state)))) + ebox--scroll-global-state) + (when-let ((render-state (ebox--buffer-render-state buffer))) + (plist-put render-state :scroll-region-ids + (nreverse scroll-region-ids)))))) + +(defun ebox--span-touches-range-p (span range) + "Return non-nil when SPAN overlaps RANGE or contains its empty insertion." + (if (= (car range) (cdr range)) + (<= (car span) (car range) (cdr span)) + (and (< (car span) (cdr range)) + (> (cdr span) (car range))))) + +(defun ebox--scroll-content-entries-in-range (region-id start end) + "Return visible content marker entries for REGION-ID inside START..END." + (let ((position start) + direct + owned + (owner-index 0)) + (while (< position end) + (let* ((content (get-text-property position 'ebox-content)) + (index (get-text-property position 'ebox-content-idx)) + (next (ebox--next-content-span-change position end))) + (when (and (equal content region-id) (integerp index)) + (push (cons index (cons position next)) direct)) + (setq position (max next (1+ position))))) + (save-excursion + (goto-char start) + (while (< (point) end) + (let* ((line-start (point)) + (line-end (min end (line-end-position))) + (position line-start) + owner-start owner-end) + (while (< position line-end) + (let* ((owners + (append + (get-text-property position 'ebox-content-owners) + (when-let ((owner + (get-text-property + position 'ebox-content-owner))) + (list owner)))) + (next (or (next-property-change position nil line-end) + line-end))) + (when (member region-id owners) + (setq owner-start (or owner-start position) + owner-end next)) + (setq position (max next (1+ position))))) + (when (and owner-start owner-end (< owner-start owner-end)) + (push (cons owner-index (cons owner-start owner-end)) owned) + (cl-incf owner-index)) + (forward-line 1)))) + (nreverse (or direct owned)))) + +(defconst ebox--scroll-content-template-absent + 'ebox--scroll-content-template-absent + "Sentinel returned when a scroll content template has no REGION-ID entry.") + +(defun ebox--scroll-content-template-entries (template region-id start) + "Return REGION-ID scroll content entries from TEMPLATE at buffer START." + (when (and (hash-table-p template) (integerp start)) + (let ((entries + (gethash region-id template + ebox--scroll-content-template-absent))) + (if (eq entries ebox--scroll-content-template-absent) + (list ebox--scroll-content-template-absent) + (mapcar + (lambda (entry) + (cons (car entry) + (cons (+ start (cadr entry)) + (+ start (cddr entry))))) + entries))))) + +(defun ebox--preflight-scroll-content-span-template + (buffer template start target-length) + "Validate TEMPLATE for BUFFER before prepared-root publication. +Return translated target entries keyed by scroll region id. Missing template +entries, malformed target offsets, and nonempty states with empty entries are +rejected before the visible buffer text changes." + (unless (and (buffer-live-p buffer) + (hash-table-p template) + (integerp start) + (integerp target-length) + (>= target-length 0)) + (error "Invalid prepared scroll template")) + (let ((translated (make-hash-table :test 'equal)) + (scroll-region-ids + (delete-dups + (append + (copy-sequence + (plist-get (ebox--buffer-render-state buffer) + :scroll-region-ids)) + (cl-loop for region-id being the hash-keys of ebox--scroll-global-state + using (hash-values state) + when (eq (ebox--scroll-state-buffer state) buffer) + collect region-id))))) + (dolist (region-id scroll-region-ids) + (let ((entries + (gethash region-id template + ebox--scroll-content-template-absent)) + (state (ebox--scroll-get-state region-id))) + (when (eq entries ebox--scroll-content-template-absent) + (error "Prepared scroll template missing region %S" region-id)) + (unless state + (error "Prepared scroll template has no live state for %S" + region-id)) + (when (and (null entries) + (> (or (plist-get state :content-height) 0) 0)) + (error "Prepared scroll template is empty for nonempty region %S" + region-id)) + (puthash + region-id + (mapcar + (lambda (entry) + (let ((index (car entry)) + (entry-start (cadr entry)) + (entry-end (cddr entry))) + (unless (and (integerp index) + (>= index 0) + (integerp entry-start) + (integerp entry-end) + (<= 0 entry-start entry-end target-length) + (< entry-start entry-end)) + (error "Prepared scroll template has invalid target offsets")) + (cons index + (cons (+ start entry-start) + (+ start entry-end))))) + entries) + translated))) + (list :entries-by-region translated + :scroll-region-ids scroll-region-ids))) + +(defun ebox--refresh-patched-scroll-markers + (buffer affected-region-ids published-spans + &optional scroll-content-template root-start) + "Refresh only scroll windows touched by an exact root patch in BUFFER. +Return a plist whose `:complete-p' value proves every affected live scroll +state received one current marker generation. +SCROLL-CONTENT-TEMPLATE contains target-side scroll spans relative to +ROOT-START, and avoids live extent scans for prepared root publications." + (when (buffer-live-p buffer) + (let ((candidate-set (make-hash-table :test 'equal)) + candidates staged staged-publications published + (complete-p t)) + (dolist (region-id affected-region-ids) + (when (ebox--scroll-get-state region-id) + (puthash region-id t candidate-set))) + (dolist (region-id + (plist-get (ebox--buffer-render-state buffer) + :scroll-region-ids)) + (let* ((template-entries + (ebox--scroll-content-template-entries + scroll-content-template region-id root-start)) + (template-present-p + (not (eq (car template-entries) + ebox--scroll-content-template-absent))) + extents start end) + (unless template-present-p + (setq extents + (with-current-buffer buffer + (ebox--live-box-extents region-id)) + start (and extents (marker-position (car extents))) + end (and extents (marker-position (cdr extents))))) + ;; Exact root patches already bound the publication work. If their + ;; role delta is empty, retain correctness for scroll roots whose + ;; extents do not expose the changed interior by refreshing the + ;; small render-state scroll set, never by scanning the page. + (when (or (null affected-region-ids) + template-present-p + (and start end (< start end) + (cl-some + (lambda (span) + (ebox--span-touches-range-p + (cons start end) span)) + published-spans))) + (puthash region-id t candidate-set)))) + (maphash (lambda (region-id _present) + (push region-id candidates)) + candidate-set) + (with-current-buffer buffer + (dolist (region-id candidates) + (let* ((state (ebox--scroll-get-state region-id)) + (template-entries + (ebox--scroll-content-template-entries + scroll-content-template region-id root-start)) + (template-present-p + (not (eq (car template-entries) + ebox--scroll-content-template-absent))) + extents start end entries) + (if (not state) + (setq complete-p nil) + (unless template-present-p + (setq extents (ebox--live-box-extents region-id) + start (and extents (marker-position (car extents))) + end (and extents (marker-position (cdr extents))))) + (setq entries + (if template-present-p + template-entries + (and start end (< start end) + (ebox--scroll-content-entries-in-range + region-id start end)))) + (if (or entries + (and template-present-p + (= (or (plist-get state :content-height) 0) + 0))) + (push (list region-id state entries) staged) + (setq complete-p nil))))) + (when complete-p + (dolist (prepared (nreverse staged)) + (pcase-let ((`(,region-id ,state ,entries) prepared)) + (let* ((render-state (ebox--buffer-render-state buffer)) + (old-state state) + (state (copy-sequence state)) + (box (plist-get state :box)) + (viewport-width + (plist-get render-state :viewport-width)) + (viewport-height + (plist-get render-state :viewport-height)) + (prefix-viewport-width + (and box viewport-width + (let ((ebox-viewport-width viewport-width)) + (or (ebox--wrapper-content-viewport-pixel box) + viewport-width)))) + (content-height (plist-get state :content-height)) + (scroll-offset + (or (plist-get state :scroll-offset) 0)) + (source + (and box + (ebox--scroll-window-stack-source + (ebox--box-content-node box)))) + (visible-lines + (and (integerp content-height) + (= (length entries) content-height) + (cl-loop + for entry in (sort (copy-sequence entries) + (lambda (left right) + (< (car left) + (car right)))) + for expected-index from 0 + when (= (car entry) expected-index) + collect + (buffer-substring + (cadr entry) (cddr entry)))))) + ;; The patch contains the complete visible target window but + ;; no hidden target page. Drop the old-width line cache, + ;; retain only this exact visible proof, and rebuild the new + ;; prefix incrementally. This keeps publication proportional + ;; to the patch and prevents first scroll input from paying a + ;; full-page materialization. + (when (and box source prefix-viewport-width viewport-height + (= (length visible-lines) content-height)) + (setq state (plist-put state :content-lines nil)) + (setq state + (ebox--plist-remove state + :rendered-content-lines)) + (dolist (key '(:content-region-id-set + :region-line-bounds-index + :region-line-span-index + :region-line-span-index-deferred + :rendered-region-line-span-index + :rendered-region-line-span-index-deferred + :region-line-span-hints)) + (setq state (ebox--plist-remove state key))) + (setq state + (plist-put + state :render-content-prefix + (ebox--scroll-window-prefix-renderer + box source prefix-viewport-width + viewport-height))) + (setq state + (plist-put + state :scroll-window-producer-spec + (ebox--scroll-window-producer-spec + prefix-viewport-width viewport-width + viewport-height))) + (setq state + (plist-put + state :materialize-content-lines + (ebox--scroll-window-materializer + box viewport-width viewport-height))) + (setq state + (plist-put state :content-lines-complete-p nil)) + (setq state + (plist-put state + :native-reflow-target-prefix-p t)) + (setq state + (plist-put state + :native-reflow-prefix-reset-p t)) + (setq state + (plist-put state :native-reflow-visible-offset + scroll-offset)) + (setq state + (plist-put state :native-reflow-visible-lines + visible-lines)) + (setq state + (ebox--plist-remove + state :native-reflow-materialize-p))) + (push + (list region-id old-state + (ebox--scroll-state-with-content-marker-spans + buffer state entries)) + staged-publications) + (push region-id published))))) + (let (swapped) + (condition-case err + (progn + (dolist (publication (nreverse staged-publications)) + (pcase-let ((`(,region-id ,old-state ,new-state) + publication)) + (puthash region-id new-state ebox--scroll-global-state) + (push (list region-id old-state new-state) swapped))) + (dolist (publication swapped) + (pcase-let ((`(,region-id ,_old-state ,_new-state) + publication)) + (ebox--scroll-schedule-idle-prefetch region-id))) + (dolist (publication swapped) + (pcase-let ((`(,_region-id ,old-state ,_new-state) + publication)) + (ebox--scroll-clear-content-markers old-state)))) + (error + (dolist (publication swapped) + (pcase-let ((`(,region-id ,old-state ,new-state) + publication)) + (puthash region-id old-state ebox--scroll-global-state) + (ebox--scroll-clear-content-markers new-state))) + (signal (car err) (cdr err)))))) + (list :complete-p complete-p + :affected-region-ids (nreverse candidates) + :published-region-ids (nreverse published))))) + +(defun ebox--next-string-content-span-change (position string limit) + "Return next content property change after POSITION in STRING." + (let ((next limit)) + (dolist (property '(ebox-content ebox-content-idx)) + (let ((change (next-single-property-change + position property string limit))) + (when (and change (< change next)) + (setq next change)))) + next)) + +(defun ebox--next-string-content-owner-change (position string limit) + "Return next wrapper-owner property change after POSITION in STRING." + (let ((next limit)) + (dolist (property '(ebox-content-owner ebox-content-owners)) + (let ((change (next-single-property-change + position property string limit))) + (when (and change (< change next)) + (setq next change)))) + next)) + +(defun ebox--build-scroll-content-span-template (string) + "Build scroll content marker offsets from rendered STRING." + (let ((table (make-hash-table :test 'equal)) + (owner-line-indexes (make-hash-table :test 'equal)) + (limit (length string))) + (cl-labels + ((record (region-id index start end) + (when (and region-id + (gethash region-id ebox--scroll-global-state) + (integerp index) + (< start end)) + (puthash region-id + (cons (cons index (cons start end)) + (gethash region-id table)) + table))) + (owner-ids-at (position) + (let (ids) + (dolist (region-id + (get-text-property position 'ebox-content-owners string)) + (when (gethash region-id ebox--scroll-global-state) + (cl-pushnew region-id ids :test #'equal))) + (when-let ((region-id + (get-text-property + position 'ebox-content-owner string))) + (when (gethash region-id ebox--scroll-global-state) + (cl-pushnew region-id ids :test #'equal))) + ids)) + (line-owner-spans (start end) + (let ((position start) + spans) + (while (< position end) + (let ((next (ebox--next-string-content-owner-change + position string end))) + (dolist (region-id (owner-ids-at position)) + (let ((span (alist-get region-id spans nil nil #'equal))) + (if span + (setcdr span next) + (push (cons region-id (cons position next)) spans)))) + (setq position (max next (1+ position))))) + spans))) + (let ((position 0)) + (while (< position limit) + (let* ((region-id + (get-text-property position 'ebox-content string)) + (index + (get-text-property position 'ebox-content-idx string)) + (next + (ebox--next-string-content-span-change + position string limit))) + (record region-id index position next) + (setq position (max next (1+ position)))))) + (let ((line-start 0)) + (while (< line-start limit) + (let ((line-end (or (string-search "\n" string line-start) limit))) + (when (< line-start line-end) + (dolist (entry (line-owner-spans line-start line-end)) + (let* ((region-id (car entry)) + (span (cdr entry)) + (index (gethash region-id owner-line-indexes 0))) + (record region-id index (car span) (cdr span)) + (puthash region-id (1+ index) owner-line-indexes)))) + (setq line-start (if (< line-end limit) (1+ line-end) limit)))))) + (maphash (lambda (region-id entries) + (puthash region-id (nreverse entries) table)) + table) + table)) + +(defun ebox--install-buffer-scroll-content-span-template + (buffer template start) + "Install scroll marker TEMPLATE in BUFFER relative to START." + (when (and (buffer-live-p buffer) (hash-table-p template)) + (let ((region-set (ebox--buffer-region-id-set buffer)) + (seen (make-hash-table :test 'equal)) + scroll-region-ids) + (maphash + (lambda (region-id entries) + (when-let ((state (and (gethash region-id region-set) + (ebox--scroll-get-state region-id)))) + (puthash region-id t seen) + (ebox--scroll-store-content-marker-spans + buffer region-id state + (mapcar (lambda (entry) + (cons (car entry) + (cons (+ start (cadr entry)) + (+ start (cddr entry))))) + entries) + t))) + template) + (maphash + (lambda (region-id state) + (when (eq (ebox--scroll-state-buffer state) buffer) + (push region-id scroll-region-ids) + (unless (gethash region-id seen) + (ebox--scroll-clear-content-markers state) + (setq state (plist-put state :content-span-markers nil)) + (setq state (plist-put state :content-window-region-id-set nil)) + (setq state (plist-put state :buffer nil)) + (puthash region-id state ebox--scroll-global-state)))) + ebox--scroll-global-state) + (when-let ((render-state (ebox--buffer-render-state buffer))) + (plist-put render-state :scroll-region-ids + (nreverse scroll-region-ids))) + t))) + +(defvar ebox--rendered-root-metadata-table + (make-hash-table :test 'eq :weakness 'key) + "Weak rendered-string map for root replacement index templates.") + +(defun ebox--string-foreign-region-in-range-p + (rendered start end region-id) + "Return non-nil when RENDERED START..END crosses REGION-ID ownership." + (let ((position start) + found) + (while (and (< position end) (not found)) + (unless (eq (aref rendered position) ?\n) + (let ((region-ids (ebox--string-region-ids-at rendered position)) + (owner-stack + (get-text-property + position 'ebox-content-owners rendered))) + (when (cl-some + (lambda (owner) + (and (not (equal owner region-id)) + (not (member region-id owner-stack)))) + region-ids) + (setq found t)))) + (setq position (1+ position))) + found)) + +(defun ebox--build-box-extent-template (rendered) + "Build safe point-min-based numeric box extents from RENDERED once." + (let ((table (make-hash-table :test 'equal)) + (interleaved (make-hash-table :test 'equal)) + (position 0) + (limit (length rendered))) + (while (< position limit) + (let* ((next (or (next-property-change position rendered limit) limit)) + (start (1+ position)) + (end (1+ next)) + (region-ids + (delete-dups + (append + (get-text-property position 'ebox-content-owners rendered) + (when-let ((owner (get-text-property + position 'ebox-content-owner rendered))) + (list owner)) + (when-let ((content (get-text-property + position 'ebox-content rendered))) + (list content)) + (delq nil + (mapcar + (lambda (entry) + (get-text-property position (cdr entry) rendered)) + ebox-region-types)))))) + (dolist (region-id region-ids) + (if-let ((span (gethash region-id table))) + (progn + (when (and (> start (cdr span)) + (ebox--string-foreign-region-in-range-p + rendered (1- (cdr span)) position region-id)) + (puthash region-id t interleaved)) + (setcar span (min (car span) start)) + (setcdr span (max (cdr span) end))) + (puthash region-id (cons start end) table))) + (setq position (max next (1+ position))))) + (maphash (lambda (region-id _) + (remhash region-id table)) + interleaved) + table)) + +(defun ebox--register-rendered-root-metadata (rendered prepared) + "Register Rust PREPARED root indexes for RENDERED. +The native tape uses ordinary hash tables. Add the private template marker +only after the tape has been validated, then update any stable lightweight +descriptor already retained by a prepared patch result." + (unless (and (stringp rendered) + (plist-get prepared :prepared-p) + (hash-table-p (plist-get prepared :role-span-template)) + (hash-table-p (plist-get prepared :box-extent-template)) + (hash-table-p + (plist-get prepared :scroll-content-span-template))) + (error "Invalid prepared rendered-root metadata")) + (let* ((role-template (plist-get prepared :role-span-template)) + (metadata + (or (gethash rendered ebox--rendered-root-metadata-table) + (list :prepared-p nil + :role-span-template nil + :box-extent-template nil + :scroll-content-span-template nil + :scroll-window-p nil)))) + (puthash ebox-buffer--role-span-table-template-key t role-template) + (plist-put metadata :role-span-template role-template) + (plist-put metadata :box-extent-template + (plist-get prepared :box-extent-template)) + (plist-put metadata :scroll-content-span-template + (plist-get prepared :scroll-content-span-template)) + (plist-put metadata :scroll-window-p + (and (plist-get prepared :scroll-window-p) t)) + (plist-put metadata :prepared-p t) + (puthash rendered metadata ebox--rendered-root-metadata-table) + metadata)) + +(defun ebox--rendered-root-metadata (rendered &optional lightweight-only) + "Return reusable root replacement metadata for RENDERED output. +When LIGHTWEIGHT-ONLY is non-nil, record only whether RENDERED contains a +scroll window. A later offscreen prewarm or an incompatible visible scroll +root may upgrade the same metadata object with complete role and scroll span +templates. This keeps ordinary cold-width publication off the full-output +property-scan path while preserving prepared indexes where they are needed." + (let ((metadata + (gethash rendered ebox--rendered-root-metadata-table))) + (unless metadata + (setq metadata + (list :prepared-p nil + :role-span-template nil + :box-extent-template nil + :scroll-content-span-template nil + :scroll-window-p + (not + (null + (text-property-not-all + 0 (length rendered) + 'ebox-scroll-window nil rendered))))) + (puthash rendered metadata ebox--rendered-root-metadata-table)) + (unless (or lightweight-only + (plist-get metadata :prepared-p)) + ;; Keep the plist object stable: patch results holding the lightweight + ;; descriptor observe this in-place upgrade without retaining RENDERED. + (plist-put metadata :role-span-template + (ebox-buffer--build-region-role-span-template rendered)) + (plist-put metadata :box-extent-template + (ebox--build-box-extent-template rendered)) + (plist-put metadata :scroll-content-span-template + (ebox--build-scroll-content-span-template rendered)) + (plist-put metadata :prepared-p t)) + metadata)) + +(defconst ebox--scroll-state-span-patch-keys + '(:content :text-align :vertical-align :wrap-mode :scroll-offset) + "Local keys that can patch cached scroll content lines directly.") + +(defun ebox--scroll-state-span-patch-keys-p (changed-keys) + "Return non-nil when CHANGED-KEYS are safe for scroll-state line patching." + (and changed-keys + (cl-every + (lambda (key) + (or (memq key ebox--scroll-state-span-patch-keys) + (and (memq :content changed-keys) + (memq key ebox--paint-style-signature-keys)))) + changed-keys))) + +(defun ebox--scroll-state-owned-by-buffer-p (buffer state) + "Return non-nil when scroll STATE belongs to BUFFER's runtime tree. +An explicit buffer association is authoritative. A render-only pass can +temporarily replace a live state with an ownerless state for the exact same +runtime box, so accept that state only when BUFFER indexes the identical box +object. Unrelated ownerless and foreign-buffer states remain isolated." + (let ((state-buffer (ebox--scroll-state-buffer state))) + (or (eq state-buffer buffer) + (and (null state-buffer) + (when-let* ((box (plist-get state :box)) + (box-id (ebox--ensure-node-id box))) + (eq (ebox--buffer-runtime-node buffer box-id) box)))))) + +(defun ebox--scroll-state-covers-node-p (buffer state node) + "Return non-nil when lazy scroll STATE owns NODE in BUFFER." + (and (ebox--scroll-state-owned-by-buffer-p buffer state) + (when-let* ((box (plist-get state :box)) + (box-id (ebox--ensure-node-id box)) + (node-id (plist-get node :node-id))) + (or (equal box-id node-id) + (ebox--runtime-ancestor-id-p buffer box-id node-id))))) + +(defun ebox--scroll-state-region-ids-containing-node-ids + (buffer node-ids) + "Return BUFFER scroll region ids containing any runtime NODE-IDS." + (let ((buffer-region-ids + (plist-get (ebox--buffer-render-state buffer) :scroll-region-ids)) + region-ids) + (dolist (node-id node-ids) + (when-let ((node (ebox--buffer-runtime-node buffer node-id))) + (dolist (region-id buffer-region-ids) + (when-let ((state (ebox--scroll-get-state region-id))) + (when (ebox--scroll-state-covers-node-p buffer state node) + (cl-pushnew region-id region-ids :test #'equal)))))) + (nreverse region-ids))) + +(defun ebox--region-ids-visible-via-role-table-p (region-ids) + "Return non-nil when REGION-IDS have any visible role span in current buffer." + (when-let ((table (ebox-buffer--region-role-span-table))) + (catch 'visible + (dolist (region-id region-ids) + (dolist (entry ebox-region-types) + (when (cl-some + (lambda (span) + (ebox-buffer--current-region-role-span-p + span region-id (car entry))) + (gethash (ebox-buffer--region-role-key + region-id (car entry)) + table)) + (throw 'visible t)))) + nil))) + +(defun ebox--region-ids-role-table-status (table region-ids) + "Return `current', `stale', or nil for REGION-IDS entries in TABLE." + (let (indexed) + (catch 'status + (dolist (region-id region-ids) + (dolist (entry ebox-region-types) + (dolist (span (gethash (ebox-buffer--region-role-key + region-id (car entry)) + table)) + (setq indexed t) + (unless (ebox-buffer--current-region-role-span-p + span region-id (car entry)) + (throw 'status 'stale))))) + (and indexed 'current)))) + +(defun ebox--region-ids-visible-by-property-scan-p (region-ids) + "Return non-nil when REGION-IDS appear in current buffer text properties." + (catch 'visible + (dolist (region-id region-ids) + (dolist (entry ebox-region-types) + (goto-char (point-min)) + (when (text-property-search-forward (cdr entry) region-id t) + (throw 'visible t)))) + nil)) + +(defun ebox--region-ids-visible-in-buffer-p (buffer region-ids) + "Return non-nil when REGION-IDS currently have visible spans in BUFFER." + (and buffer + region-ids + (buffer-live-p buffer) + (with-current-buffer buffer + (or (ebox--region-ids-visible-via-role-table-p region-ids) + (unless (ebox-buffer--region-role-span-table) + (save-excursion + (ebox--region-ids-visible-by-property-scan-p + region-ids))))))) + +(defun ebox--runtime-node-own-region-ids (node) + "Return NODE's own wrapper region ids without descending into children." + (when (listp node) + (pcase (plist-get node :ebox-type) + ('box + (when-let ((region-id (ebox-get node :region-id))) + (list region-id))) + ('flex + (when-let* ((box (plist-get node :box)) + (region-id (ebox-get box :region-id))) + (list region-id)))))) + +(defun ebox--scroll-state-node-visible-p (buffer state node) + "Return non-nil when NODE occupies STATE's current visible window. +When an empty fixed box has no own text-property span, walk only through its +local wrapper ancestors. Stop before the scroll owner's direct content +wrapper so one visible row cannot make every hidden descendant look visible." + (when-let* ((scroll-box (plist-get state :box)) + (scroll-node-id (ebox--ensure-node-id scroll-box)) + (node-id (plist-get node :node-id))) + (let ((candidate-id node-id) + (first t)) + (catch 'visible + (while candidate-id + (let* ((candidate + (ebox--buffer-runtime-node buffer candidate-id)) + (parent-id + (ebox--runtime-parent-id buffer candidate-id))) + (when (or first (not (equal parent-id scroll-node-id))) + (when-let ((region-ids + (ebox--runtime-node-own-region-ids candidate))) + (when (or + (ebox--region-ids-visible-in-buffer-p + buffer region-ids) + (ebox--scroll-state-region-visible-in-window-p + state region-ids) + (ebox--scroll-state-region-visible-in-content-range-p + state region-ids)) + (throw 'visible t)))) + (when (or (equal candidate-id scroll-node-id) + (equal parent-id scroll-node-id)) + (setq candidate-id nil)) + (when candidate-id + (setq candidate-id parent-id + first nil)))) + nil)))) + +(defun ebox--node-visible-in-buffer-p (buffer node) + "Return non-nil when NODE is represented in BUFFER's visible scroll windows." + (let ((region-ids (and node (ebox--node-all-region-ids node))) + containing-states) + (or (ebox--region-ids-visible-in-buffer-p buffer region-ids) + (progn + (maphash + (lambda (_scroll-region-id state) + (when (ebox--scroll-state-covers-node-p buffer state node) + (push state containing-states))) + ebox--scroll-global-state) + (and containing-states + (cl-every + (lambda (state) + (ebox--scroll-state-node-visible-p buffer state node)) + containing-states)))))) + +(defun ebox--ensure-visible-node-runtime-indexes + (buffer node &optional primary-region-id) + "Refresh missing or stale visible role spans for NODE in BUFFER. +A missing table uses a targeted property search before rebuilding the small +rendered viewport. A present table is authoritative unless requested entries +exist but no longer validate, in which case rebuild it without a property scan." + (when (and (buffer-live-p buffer) node) + (let ((region-ids (if primary-region-id + (list primary-region-id) + (ebox--node-all-region-ids node)))) + (with-current-buffer buffer + (let ((table (ebox-buffer--region-role-span-table))) + (if table + (when (eq (ebox--region-ids-role-table-status + table region-ids) + 'stale) + (ebox-buffer-refresh-region-role-spans buffer) + t) + (when (save-excursion + (ebox--region-ids-visible-by-property-scan-p region-ids)) + (ebox-buffer-refresh-region-role-spans buffer) + t))))))) + +(defun ebox--mark-lazy-scroll-covered-hidden-states + (buffer node region-ids) + "Mark lazy scroll states covering hidden NODE/REGION-IDS as dirty. +Return the number of marked states. This preserves deferred hidden-update +performance while preventing later prefix extension from replaying stale cached +lines." + (let ((count 0)) + (unless (ebox--node-visible-in-buffer-p buffer node) + (maphash + (lambda (scroll-region-id state) + (when (and (plist-get state :materialize-content-lines) + (ebox--scroll-state-covers-node-p buffer state node)) + (let* ((content-lines (plist-get state :content-lines)) + (cached-spans + (and content-lines + (ebox--scroll-hot-content-line-spans + state region-ids)))) + (when (and (not cached-spans) + (not (ebox--scroll-state-region-visible-in-window-p + state region-ids))) + (setq state (plist-put state :lazy-scroll-prefix-dirty t)) + (puthash scroll-region-id state ebox--scroll-global-state) + (cl-incf count))))) + ebox--scroll-global-state)) + count)) + +(defun ebox--try-lazy-scroll-deferred-patch + (buffer region-id node changed-keys) + "Record a hidden lazy scroll update without materializing its scroll source." + (when (and buffer node changed-keys) + (let ((region-ids (ebox--node-all-region-ids node))) + (when (> (ebox--mark-lazy-scroll-covered-hidden-states + buffer node region-ids) + 0) + (ebox--update-report + region-id 'span-patch + :dirty-count 1 + :patch-count 0 + :patch-ops nil + :owner-id (plist-get node :node-id) + :owner-ids (list (plist-get node :node-id)) + :owner-type (plist-get node :ebox-type) + :span-count 0 + :scroll-state-patch t + :lazy-scroll-deferred-patch t + :dirty-kinds '(geometry) + :dirty-keys changed-keys))))) + +(defun ebox--string-region-role-ids-at (string pos) + "Return ebox role/id pairs at POS in STRING." + (let (role-ids) + (dolist (entry ebox-region-types) + (when-let ((region-id (get-text-property pos (cdr entry) string))) + (push (cons (car entry) region-id) role-ids))) + role-ids)) + +(defun ebox--string-region-ids-at (string pos) + "Return all ebox region ids present at POS in STRING." + (let (ids) + (dolist (region-id (get-text-property pos 'ebox-content-owners string)) + (cl-pushnew region-id ids :test #'equal)) + (dolist (entry ebox-region-types) + (when-let ((region-id (get-text-property pos (cdr entry) string))) + (cl-pushnew region-id ids :test #'equal))) + ids)) + +(defun ebox--string-next-region-property-change (string pos limit) + "Return next ebox region property change in STRING after POS, capped at LIMIT." + (let ((next limit)) + (dolist (entry ebox-region-types) + (let ((change (next-single-property-change + pos (cdr entry) string limit))) + (when (and change (< change next)) + (setq next change)))) + next)) + +(defun ebox--string-region-role-ids-compatible-p (role-ids region-set) + "Return non-nil when ROLE-IDS do not cross foreign direct content owners." + (cl-every (lambda (role-id) + (or (not (eq (car role-id) 'content)) + (gethash (cdr role-id) region-set))) + role-ids)) + +(defun ebox--scroll-line-region-span (line region-set) + "Return LINE span owned by REGION-SET, or nil when absent/incompatible." + (let ((pos 0) + (limit (length line)) + span-start span-end patchable seen) + (setq patchable t) + (while (and patchable (< pos limit)) + (let* ((next (ebox--string-next-region-property-change line pos limit)) + (ids (ebox--string-region-ids-at line pos))) + (when (cl-some (lambda (region-id) (gethash region-id region-set)) + ids) + (setq seen t) + (unless span-start + (setq span-start pos)) + (setq span-end next) + (unless (ebox--string-region-role-ids-compatible-p + (ebox--string-region-role-ids-at line pos) + region-set) + (setq patchable nil))) + (setq pos (max next (1+ pos))))) + (and seen patchable span-start span-end (cons span-start span-end)))) + +(defun ebox--scroll-line-region-span-for-ids (line region-ids) + "Return a conservative owned span for REGION-IDS in LINE. +This lookup searches each role directly in the string property intervals. It +is optimized for validating a few known rendered-line hints and returns nil +when foreign direct content makes the combined span ambiguous." + (let ((limit (length line)) + (region-set (ebox--region-id-set region-ids)) + span-start span-end) + (dolist (region-id region-ids) + (dolist (entry ebox-region-types) + (let ((property (cdr entry)) + (pos 0) + found) + (while (and (< pos limit) + (setq found + (text-property-any + pos limit property region-id line))) + (let ((next (or (next-single-property-change + found property line limit) + limit))) + (setq span-start (if span-start + (min span-start found) + found) + span-end (if span-end + (max span-end next) + next) + pos (max next (1+ found)))))))) + (let ((pos 0)) + (while (< pos limit) + (let* ((owners (get-text-property + pos 'ebox-content-owners line)) + (next (or (next-single-property-change + pos 'ebox-content-owners line limit) + limit))) + (when (cl-some (lambda (region-id) + (member region-id owners)) + region-ids) + (setq span-start (if span-start (min span-start pos) pos) + span-end (if span-end (max span-end next) next))) + (setq pos (max next (1+ pos)))))) + (when (and span-start span-end) + (let ((pos span-start) + (compatible t)) + (while (and compatible (< pos span-end)) + (let* ((content-id + (get-text-property pos 'ebox-content line)) + (next (or (next-single-property-change + pos 'ebox-content line span-end) + span-end))) + (when (and content-id + (not (gethash content-id region-set))) + (setq compatible nil)) + (setq pos (max next (1+ pos))))) + (and compatible (cons span-start span-end)))))) + +(defun ebox--scroll-line-region-present-p (line region-ids) + "Return non-nil when LINE has any direct role for REGION-IDS." + (let ((limit (length line))) + (and (> limit 0) + (cl-some + (lambda (region-id) + (cl-some + (lambda (entry) + (text-property-any 0 limit (cdr entry) region-id line)) + ebox-region-types)) + region-ids)))) + +(defun ebox--scroll-state-region-line-spans (lines region-ids) + "Return cached line spans in LINES owned by REGION-IDS." + (let ((region-set (ebox--region-id-set region-ids)) + spans) + (cl-loop for line in lines + for idx from 0 + do (when (ebox--scroll-line-region-present-p line region-ids) + (when-let ((span (ebox--scroll-line-region-span + line region-set))) + (push (cons idx span) spans)))) + (nreverse spans))) + +(defun ebox--scroll-line-region-ids (line) + "Return all ebox region ids present in LINE." + (let ((pos 0) + (limit (length line)) + ids) + (while (< pos limit) + (dolist (region-id (ebox--string-region-ids-at line pos)) + (cl-pushnew region-id ids :test #'equal)) + (setq pos (max (ebox--string-next-region-property-change line pos limit) + (1+ pos)))) + (nreverse ids))) + +(defun ebox--scroll-line-content-region-ids (line) + "Return LINE's content-topology region ids through the cheap owner fields. +Decoration-only legacy lines fall back to the complete role scanner." + (let ((pos 0) + (limit (length line)) + ids + topology-seen) + (while (< pos limit) + (dolist (region-id (get-text-property pos 'ebox-content-owners line)) + (setq topology-seen t) + (cl-pushnew region-id ids :test #'equal)) + (dolist (property '(ebox-content ebox-content-owner)) + (when-let ((region-id (get-text-property pos property line))) + (setq topology-seen t) + (cl-pushnew region-id ids :test #'equal))) + (setq pos + (max + (1+ pos) + (min + (or (next-single-property-change + pos 'ebox-content-owners line limit) + limit) + (or (next-single-property-change + pos 'ebox-content line limit) + limit) + (or (next-single-property-change + pos 'ebox-content-owner line limit) + limit))))) + (if topology-seen + (nreverse ids) + (ebox--scroll-line-region-ids line)))) + +(defun ebox--scroll-line-direct-content-region-ids (line) + "Return direct `ebox-content' region ids in LINE. +Ancestor content-owner ids are intentionally excluded: adapter containers such +as flex wrappers do not own a concrete line span, and selector planning already +uses their rendered descendants." + (let ((pos 0) + (limit (length line)) + ids) + (while (< pos limit) + (when-let ((region-id (get-text-property pos 'ebox-content line))) + (cl-pushnew region-id ids :test #'equal)) + (setq pos + (max (1+ pos) + (or (next-single-property-change + pos 'ebox-content line limit) + limit)))) + (nreverse ids))) + +(defun ebox--scroll-line-bounds-index-add (index line-index line) + "Add LINE at LINE-INDEX to lightweight region bounds INDEX." + (dolist (region-id (ebox--scroll-line-direct-content-region-ids line)) + (if-let ((bounds (gethash region-id index))) + (setcdr bounds line-index) + (puthash region-id (cons line-index line-index) index))) + index) + +(defun ebox--scroll-build-region-line-bounds-index (lines) + "Return a lightweight region id to inclusive line bounds index for LINES." + (let ((index (make-hash-table :test 'equal))) + (cl-loop for line in lines + for line-index from 0 + do (ebox--scroll-line-bounds-index-add + index line-index line)) + index)) + +(defun ebox--scroll-lines-prefix-equal-p (prefix lines) + "Return non-nil when PREFIX is an exact property-preserving prefix of LINES." + (and (<= (length prefix) (length lines)) + (cl-loop for old-line in prefix + for new-line in lines + always (equal-including-properties old-line new-line)))) + +(defun ebox--scroll-update-region-line-bounds-index + (state old-lines new-lines) + "Return STATE's lightweight bounds index updated for NEW-LINES." + (let ((index (plist-get state :region-line-bounds-index))) + (if (and index + old-lines + (ebox--scroll-lines-prefix-equal-p old-lines new-lines)) + (progn + (cl-loop for line in (nthcdr (length old-lines) new-lines) + for line-index from (length old-lines) + do (ebox--scroll-line-bounds-index-add + index line-index line)) + index) + (ebox--scroll-build-region-line-bounds-index new-lines)))) + +(defun ebox--scroll-build-region-line-span-index (lines) + "Return an index mapping region ids to cached line spans in LINES." + (let ((index (make-hash-table :test 'equal))) + (cl-loop for line in lines + for line-index from 0 + do + (dolist (region-id (ebox--scroll-line-region-ids line)) + (when-let ((span (ebox--scroll-line-region-span + line (ebox--region-id-set + (list region-id))))) + (puthash region-id + (cons (cons line-index span) + (gethash region-id index)) + index)))) + (maphash (lambda (region-id spans) + (puthash region-id (nreverse spans) index)) + index) + index)) + +(defun ebox--scroll-build-content-region-id-set (lines) + "Return a set of every content-topology region id represented in LINES. +Normal Ebox lines expose each box through `ebox-content', +`ebox-content-owner', or `ebox-content-owners'. Decoration-only legacy lines +fall back to the complete role scanner." + (let ((set (make-hash-table :test 'equal))) + (dolist (line lines) + (dolist (region-id (ebox--scroll-line-content-region-ids line)) + (puthash region-id t set))) + set)) + +(defun ebox--scroll-state-ensure-content-region-id-set (state) + "Return STATE's cached content region-id membership set." + (or (plist-get state :content-region-id-set) + (let ((set (ebox--scroll-build-content-region-id-set + (plist-get state :content-lines)))) + (plist-put state :content-region-id-set set) + set))) + +(defun ebox--scroll-region-id-set-intersects-p (set region-ids) + "Return non-nil when SET contains at least one of REGION-IDS." + (catch 'present + (dolist (region-id region-ids) + (when (gethash region-id set) + (throw 'present t))) + nil)) + +(defun ebox--scroll-index-line-spans (index region-ids) + "Return merged line spans for REGION-IDS from INDEX." + (let ((by-line (make-hash-table :test 'eql)) + spans) + (dolist (region-id region-ids) + (dolist (entry (gethash region-id index)) + (let* ((line-index (car entry)) + (span (cdr entry)) + (existing (gethash line-index by-line))) + (if existing + (progn + (setcar existing (min (car existing) (car span))) + (setcdr existing (max (cdr existing) (cdr span)))) + (puthash line-index (copy-tree entry) by-line))))) + (maphash (lambda (_line-index entry) + (push entry spans)) + by-line) + (sort spans (lambda (left right) (< (car left) (car right)))))) + +(defun ebox--scroll-index-add-line (index line-index line) + "Add LINE at LINE-INDEX to INDEX." + (dolist (region-id (ebox--scroll-line-region-ids line)) + (when-let ((span (ebox--scroll-line-region-span + line (ebox--region-id-set (list region-id))))) + (puthash region-id + (cons (cons line-index span) + (gethash region-id index)) + index)))) + +(defun ebox--scroll-sort-line-index (index) + "Sort INDEX entries by line number." + (maphash + (lambda (region-id entries) + (puthash region-id + (sort entries + (lambda (left right) + (< (car left) (car right)))) + index)) + index) + index) + +(defun ebox--scroll-state-set-lines + (state content-lines &optional rendered-lines) + "Return STATE with CONTENT-LINES and derived span indexes installed." + (let ((old-lines (plist-get state :content-lines))) + (setq state + (plist-put + state :region-line-bounds-index + (ebox--scroll-update-region-line-bounds-index + state old-lines content-lines)))) + (setq state (plist-put state :content-lines content-lines)) + (setq state (ebox--plist-remove state :content-region-id-set)) + (setq state (ebox--plist-remove state :region-line-span-hints)) + (if ebox--defer-scroll-content-index + (progn + (setq state (plist-put state :region-line-span-index nil)) + (setq state (plist-put state :region-line-span-index-deferred t))) + (setq state + (plist-put state :region-line-span-index + (ebox--scroll-build-region-line-span-index + content-lines))) + (setq state (plist-put state :region-line-span-index-deferred nil))) + (if rendered-lines + (progn + (setq state (plist-put state :rendered-content-lines rendered-lines)) + (if ebox--defer-scroll-content-index + (progn + (setq state + (plist-put state :rendered-region-line-span-index nil)) + (setq state + (plist-put + state :rendered-region-line-span-index-deferred t))) + (setq state + (plist-put state :rendered-region-line-span-index + (ebox--scroll-build-region-line-span-index + rendered-lines))) + (setq state + (plist-put + state :rendered-region-line-span-index-deferred nil)))) + (setq state (ebox--plist-remove state :rendered-content-lines)) + (setq state (ebox--plist-remove state :rendered-region-line-span-index)) + (setq state + (ebox--plist-remove state + :rendered-region-line-span-index-deferred))) + state) + +(defun ebox--scroll-state-ensure-region-line-span-index (state) + "Return STATE's content line index, building deferred indexes on demand." + (or (plist-get state :region-line-span-index) + (when (plist-get state :region-line-span-index-deferred) + (let ((index (ebox--scroll-build-region-line-span-index + (plist-get state :content-lines)))) + (plist-put state :region-line-span-index index) + (plist-put state :region-line-span-index-deferred nil) + index)))) + +(defun ebox--scroll-state-ensure-rendered-line-span-index (state) + "Return STATE's rendered line index, building deferred indexes on demand." + (or (plist-get state :rendered-region-line-span-index) + (when (plist-get state :rendered-region-line-span-index-deferred) + (let ((index (ebox--scroll-build-region-line-span-index + (plist-get state :rendered-content-lines)))) + (plist-put state :rendered-region-line-span-index index) + (plist-put state :rendered-region-line-span-index-deferred nil) + index)))) + +(defun ebox--scroll-state-patch-lines + (state content-lines content-spans + &optional rendered-lines _rendered-spans region-ids) + "Return STATE with patched lines and deferred derived span indexes. +CONTENT-SPANS and REGION-IDS retain bounded line hints for follow-up updates. +An existing full index is downgraded to line-number hints: its character spans +are never trusted after the patch, but every indexed region remains locatable." + (let ((line-hints + (when-let ((existing + (or (plist-get state :region-line-span-index) + (plist-get state :region-line-span-hints)))) + (copy-hash-table existing))) + (content-region-id-set (plist-get state :content-region-id-set)) + (ebox--defer-scroll-content-index t)) + (setq state + (ebox--scroll-state-set-lines state content-lines rendered-lines)) + (when (and content-spans region-ids) + (unless line-hints + (setq line-hints (make-hash-table :test 'equal))) + (dolist (region-id region-ids) + (puthash region-id (copy-tree content-spans) line-hints))) + (when line-hints + (setq state + (plist-put state :region-line-span-hints line-hints))) + ;; Runtime updates preserve region topology, so an equal-layout line patch + ;; cannot add or remove ids from the cached prefix. + (when content-region-id-set + (setq state + (plist-put state :content-region-id-set + content-region-id-set)))) + state) + +(defun ebox--scroll-state-line-spans-for-region-ids (state region-ids) + "Return cached content line spans for REGION-IDS in scroll STATE." + (if-let ((index (ebox--scroll-state-ensure-region-line-span-index state))) + (ebox--scroll-index-line-spans index region-ids) + (ebox--scroll-state-region-line-spans + (plist-get state :content-lines) region-ids))) + +(defun ebox--scroll-rendered-line-spans-for-region-ids (state region-ids) + "Return cached rendered-content line spans for REGION-IDS in scroll STATE." + (if-let ((index (ebox--scroll-state-ensure-rendered-line-span-index state))) + (ebox--scroll-index-line-spans index region-ids) + (when-let ((lines (plist-get state :rendered-content-lines))) + (ebox--scroll-state-region-line-spans lines region-ids)))) + +(defun ebox--scroll-hot-content-line-spans (state region-ids) + "Return content spans for REGION-IDS without building a deferred full index. +Dynamic updates reuse an already prepared index. When no ready index exists, +scan for only REGION-IDS; building the all-region index synchronously is much +more expensive than the targeted fallback on a long lazy prefix." + (if-let ((index (plist-get state :region-line-span-index))) + ;; A ready exact index already proves both membership and spans. Building + ;; the prefix-wide membership set first adds an O(prefix) tax to visible + ;; updates after scrolling. + (ebox--scroll-index-line-spans index region-ids) + (let ((content-region-id-set + (ebox--scroll-state-ensure-content-region-id-set state))) + (when (ebox--scroll-region-id-set-intersects-p + content-region-id-set region-ids) + (let ((hints (plist-get state :region-line-span-hints))) + (if-let ((hint-spans + (and hints + (cl-loop for region-id in region-ids + for spans = (gethash region-id hints) + unless spans return nil + append spans)))) + (ebox--scroll-line-spans-at-index-hints + (plist-get state :content-lines) + region-ids + hint-spans) + (ebox--scroll-state-region-line-spans + (plist-get state :content-lines) region-ids))))))) + +(defun ebox--scroll-line-spans-at-index-hints + (lines region-ids index-hints) + "Return REGION-IDS spans in LINES limited to INDEX-HINTS line numbers. +INDEX-HINTS use the same `(LINE-INDEX . SPAN)' shape as the scroll indexes. +Content and rendered scroll lines share line numbering, so a content hit can +bound rendered-line inspection without scanning or indexing the whole prefix." + (let ((region-set (ebox--region-id-set region-ids)) + (indices (delete-dups (mapcar #'car index-hints))) + spans) + (setq indices (sort indices #'<)) + (cl-loop for line in lines + for line-index from 0 + while indices + do + (cond + ((< line-index (car indices))) + ((= line-index (car indices)) + (when (ebox--scroll-line-region-present-p line region-ids) + (when-let ((span (ebox--scroll-line-region-span + line region-set))) + (push (cons line-index span) spans))) + (setq indices (cdr indices))))) + (nreverse spans))) + +(defun ebox--scroll-hot-rendered-line-spans + (state region-ids content-spans) + "Return rendered spans for REGION-IDS without building a deferred index. +Reuse a ready rendered index; otherwise inspect only lines named by +CONTENT-SPANS, whose line numbers are aligned with rendered scroll lines." + (if-let ((index (plist-get state :rendered-region-line-span-index))) + (ebox--scroll-index-line-spans index region-ids) + (when-let ((lines (plist-get state :rendered-content-lines))) + (if (cl-every + (lambda (entry) + (equal (cdr entry) + (ebox--scroll-line-region-span-for-ids + (nth (car entry) lines) region-ids))) + content-spans) + ;; The rendered copy can add outer wrapper ownership while retaining + ;; the exact target span. Reuse only after direct-role validation. + content-spans + (ebox--scroll-line-spans-at-index-hints + lines region-ids content-spans))))) + +(defun ebox--cancel-buffer-reflow-cache-prewarm (buffer) + "Cancel pending predicted reflow-cache warming for BUFFER." + (when-let ((timer (gethash buffer ebox--reflow-cache-prewarm-timers))) + (when (timerp timer) + (cancel-timer timer))) + (remhash buffer ebox--reflow-cache-prewarm-timers)) + +(defun ebox--cancel-buffer-runtime-prewarm (buffer) + "Cancel and discard pending runtime prewarming for BUFFER." + (ebox--cancel-buffer-reflow-cache-prewarm buffer) + (when-let ((timer (gethash buffer ebox--runtime-prewarm-timers))) + (when (timerp timer) + (cancel-timer timer))) + (remhash buffer ebox--runtime-prewarm-timers) + (remhash buffer ebox--runtime-prewarm-jobs)) + +(defun ebox--runtime-prewarm-enabled-p () + "Return non-nil when runtime prewarming may run in this Emacs." + (and ebox-runtime-idle-prewarm + (or (not noninteractive) + ebox--runtime-prewarm-allow-noninteractive))) + +(defun ebox--reflow-cache-prewarm-enabled-p () + "Return non-nil when predicted reflow-cache warming may run." + (and ebox-runtime-idle-reflow-cache-prewarm + (or (not noninteractive) + ebox--runtime-prewarm-allow-noninteractive))) + +(defun ebox--reflow-cache-prewarm-auto-maximum-threshold () + "Return the memory-aware maximum automatic prewarm allocation threshold." + (let* ((minimum (max 1 ebox-reflow-cache-prewarm-gc-auto-min-threshold)) + (maximum + (max minimum ebox-reflow-cache-prewarm-gc-auto-max-threshold)) + (physical-memory (ebox--physical-memory-bytes)) + (memory-limit + (and physical-memory (floor (/ physical-memory 16.0))))) + (max minimum (min maximum (or memory-limit maximum))))) + +(defun ebox--reflow-cache-prewarm-auto-layout-horizon (seconds) + "Return the target collection-free layout count for SECONDS." + (let* ((target (max 1 ebox-reflow-cache-prewarm-gc-auto-target-layouts)) + (budget (max 0.001 ebox-reflow-cache-prewarm-gc-auto-frame-budget))) + (cond + ((or (not (numberp seconds)) (>= seconds (* budget 0.65))) target) + ((>= seconds (* budget 0.30)) + (max 1 (ceiling (/ (* target 2.0) 3.0)))) + (t (max 1 (ceiling (/ target 3.0))))))) + +(defun ebox--reflow-cache-prewarm-auto-threshold-for-allocation + (allocated-bytes seconds) + "Return an automatic threshold for ALLOCATED-BYTES and layout SECONDS." + (let* ((minimum (max 1 ebox-reflow-cache-prewarm-gc-auto-min-threshold)) + (maximum (ebox--reflow-cache-prewarm-auto-maximum-threshold)) + (horizon (ebox--reflow-cache-prewarm-auto-layout-horizon seconds)) + (quantum (* 16 1024 1024)) + (raw (* (max 1 allocated-bytes) horizon)) + (rounded (* quantum (ceiling (/ raw (float quantum)))))) + (max + minimum + (min maximum rounded)))) + +(defun ebox--memory-use-count-delta (before after) + "Return one wrapping `memory-use-counts' delta from BEFORE to AFTER." + (if (>= after before) + (- after before) + (+ (- most-positive-fixnum before) after 1))) + +(defun ebox--memory-use-counts-estimated-bytes (before after) + "Estimate allocation bytes between memory count lists BEFORE and AFTER." + (let ((deltas + (cl-mapcar #'ebox--memory-use-count-delta before after))) + ;; Emacs reports object counts rather than bytes. These 64-bit object + ;; sizes deliberately include headers for the allocation-pressure signal; + ;; the 16 MiB quantization absorbs allocator and platform variation. + (+ (* (or (nth 0 deltas) 0) 16) ; conses + (* (or (nth 1 deltas) 0) 16) ; floats + (* (or (nth 2 deltas) 0) 8) ; vector cells + (* (or (nth 3 deltas) 0) 48) ; symbols + (or (nth 4 deltas) 0) ; string characters + (* (or (nth 5 deltas) 0) 40) ; intervals + (* (or (nth 6 deltas) 0) 32)))) ; string headers + +(defun ebox--reflow-cache-prewarm-effective-threshold (state) + "Return the configured allocation threshold for predicted render STATE." + (pcase ebox-reflow-cache-prewarm-gc-cons-threshold + ('auto + (or (and state (plist-get state :reflow-prewarm-gc-threshold)) + (min (ebox--reflow-cache-prewarm-auto-maximum-threshold) + (max ebox-reflow-cache-prewarm-gc-auto-min-threshold + ebox-reflow-cache-prewarm-gc-auto-initial-threshold)))) + ((pred numberp) ebox-reflow-cache-prewarm-gc-cons-threshold) + (_ nil))) + +(defun ebox--record-reflow-cache-prewarm-cost + (state elapsed-seconds gc-count threshold allocated-bytes) + "Record predicted layout cost in STATE after ELAPSED-SECONDS and GC-COUNT. +THRESHOLD is the allocation budget used for the completed prewarm, and +ALLOCATED-BYTES is its estimated allocation pressure." + (when state + (plist-put state :reflow-prewarm-last-seconds elapsed-seconds) + (plist-put state :reflow-prewarm-last-gc-count gc-count) + (plist-put state :reflow-prewarm-last-gc-threshold threshold) + (plist-put state :reflow-prewarm-last-allocation-bytes allocated-bytes) + ;; A GC-contaminated sample measures heap scanning, not layout complexity. + ;; Keep the previous learned value until one clean prewarm is available. + (when (= gc-count 0) + (let* ((previous + (plist-get state :reflow-prewarm-layout-seconds)) + (smoothed + (if (numberp previous) + (+ (* previous 0.75) (* elapsed-seconds 0.25)) + elapsed-seconds))) + (plist-put state :reflow-prewarm-layout-seconds smoothed))) + ;; Allocation counters are monotonic across collection, so every completed + ;; prewarm can improve the pressure estimate even when its time was noisy. + (when (and (numberp allocated-bytes) (> allocated-bytes 0)) + (let* ((previous + (plist-get state :reflow-prewarm-allocation-bytes)) + (smoothed + (if (numberp previous) + (floor (+ (* previous 0.75) (* allocated-bytes 0.25))) + allocated-bytes))) + (plist-put state :reflow-prewarm-allocation-bytes smoothed) + (plist-put + state :reflow-prewarm-gc-threshold + (ebox--reflow-cache-prewarm-auto-threshold-for-allocation + smoothed (plist-get state :reflow-prewarm-layout-seconds))))))) + +(defun ebox--call-with-reflow-cache-prewarm-gc + (state function &rest arguments) + "Call FUNCTION with ARGUMENTS under idle reflow GC pressure settings. +Automatic collection may run while the predicted render is invisible. The +surrounding interactive burst settings are restored before the cached result +can be published by a visible resize frame. Timer errors remain isolated from +ordinary editing; `quit' still propagates so C-g stays responsive." + (let* ((threshold (ebox--reflow-cache-prewarm-effective-threshold state)) + (percentage ebox-reflow-cache-prewarm-gc-cons-percentage) + (gc-cons-threshold + (if (numberp threshold) threshold gc-cons-threshold)) + (gc-cons-percentage + (if (numberp percentage) percentage gc-cons-percentage)) + ;; Predicted rendering enters `ebox--with-render-gc' internally. Use + ;; the same pressure values there instead of raising them back to the + ;; visible-transaction defaults. + (ebox-render-gc-cons-threshold threshold) + (ebox-render-gc-cons-percentage percentage) + (gcs-before gcs-done) + (counts-before (memory-use-counts)) + (started (float-time)) + result) + (setq result + (condition-case nil + (apply function arguments) + (error nil))) + (when result + (ebox--record-reflow-cache-prewarm-cost + state (- (float-time) started) (- gcs-done gcs-before) threshold + (ebox--memory-use-counts-estimated-bytes + counts-before (memory-use-counts)))) + result)) + +(defun ebox--make-reflow-prewarm-scratch (state revision) + "Create one isolated live-tree copy for render STATE at REVISION." + (let ((root (plist-get state :root-node))) + (when root + (list :source-root root + :tree (ebox-tree-copy-node-structure root) + :live-revision revision + :predicted-region-id nil + :predicted-width nil + :predicted-revision nil + :render-signature-cache (make-hash-table :test 'eq) + :viewport-dependent-node-ids-cache + (make-hash-table :test 'eq) + :viewport-dependent-subtree-cache + (make-hash-table :test 'eq) + :viewport-height-dependent-subtree-cache + (make-hash-table :test 'eq))))) + +(defun ebox--live-reflow-prewarm-scratch (state revision) + "Return STATE's isolated tree at live REVISION, recreating it if needed. +An unconfirmed ordinary-width prediction is never treated as the live tree." + (let* ((root (plist-get state :root-node)) + (scratch (plist-get state :reflow-prewarm-scratch))) + (unless (and scratch + (eq (plist-get scratch :source-root) root) + (equal (plist-get scratch :live-revision) revision) + (null (plist-get scratch :predicted-revision))) + (setq scratch (ebox--make-reflow-prewarm-scratch state revision)) + (plist-put state :reflow-prewarm-scratch scratch)) + scratch)) + +(defun ebox--discard-reflow-prewarm-scratch (state scratch) + "Discard SCRATCH when it is still STATE's active isolated tree." + (when (eq scratch (plist-get state :reflow-prewarm-scratch)) + (plist-put state :reflow-prewarm-scratch nil))) + +(defun ebox--render-reflow-prewarm-scratch + (buffer state revision scratch viewport-width viewport-height) + "Render SCRATCH offscreen for BUFFER under the supplied viewport context." + (with-current-buffer buffer + (let ((ebox-viewport-width viewport-width) + (ebox-viewport-height viewport-height) + (ebox--render-runtime-revision revision) + (ebox--render-cache-table (plist-get state :render-cache)) + (ebox--render-cache-signature-cache + (plist-get scratch :render-signature-cache)) + (ebox--viewport-dependent-node-ids-cache + (plist-get scratch :viewport-dependent-node-ids-cache)) + (ebox--viewport-dependent-subtree-cache + (plist-get scratch :viewport-dependent-subtree-cache)) + (ebox--viewport-height-dependent-subtree-cache + (plist-get scratch :viewport-height-dependent-subtree-cache)) + (ebox--render-cache-scroll-state-restorable-p nil) + (ebox--scroll-window-initial-lookahead-lines-override 0) + (ebox--defer-scroll-content-index t) + (ebox--scroll-global-state (make-hash-table :test 'equal)) + (ebox--region-box-table (make-hash-table :test 'equal)) + (ebox--scroll-idle-prefetch-timers + (make-hash-table :test 'equal)) + (ebox-cache-report-buffer nil) + (ebox--render-region-id nil)) + (cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch) + (lambda (&rest _) nil))) + (ebox--with-render-gc + (let ((rendered + (ebox--render-with-cache + (plist-get scratch :tree) t))) + (ebox--rendered-root-metadata rendered))))))) + +(defun ebox--prewarm-buffer-render-cache-at-context + (buffer state revision source-width viewport-width viewport-height) + "Warm BUFFER's render cache for a predicted viewport context. +STATE and REVISION identify the runtime that scheduled the work. +SOURCE-WIDTH must remain BUFFER's published viewport width. VIEWPORT-WIDTH +and VIEWPORT-HEIGHT are used only in an isolated render; the buffer, runtime +tree, scroll state, region table, report, and body-signature cache are not +published or mutated. Return non-nil when the isolated render ran." + (when (and (buffer-live-p buffer) + (eq state (ebox--buffer-render-state buffer)) + (= revision (or (plist-get state :runtime-revision) 0)) + (= source-width (or (plist-get state :viewport-width) -1)) + (numberp viewport-width) + (> viewport-width 0) + (plist-get state :root-node)) + (let ((scratch (ebox--live-reflow-prewarm-scratch state revision)) + completed) + (when scratch + (unwind-protect + (progn + (ebox--render-reflow-prewarm-scratch + buffer state revision scratch viewport-width viewport-height) + (setq completed t)) + (unless completed + (ebox--discard-reflow-prewarm-scratch state scratch)))) + completed))) + +(defun ebox--root-region-box (root region-id) + "Return ROOT's box carrying REGION-ID, including a flex wrapper box." + (when-let ((owner (car (ebox--node-path-to-region root region-id)))) + (cond + ((eq (plist-get owner :ebox-type) 'box) owner) + ((and (eq (plist-get owner :ebox-type) 'flex) + (equal (ebox-get (plist-get owner :box) :region-id) region-id)) + (plist-get owner :box))))) + +(defun ebox--literal-root-pixel-width (box) + "Return BOX's demo-compatible literal pixel width, or nil." + (let ((width (and box (ebox-get box :width)))) + (cond + ((numberp width) width) + ((and (consp width) (null (cdr width)) (numberp (car width))) + (car width))))) + +(defun ebox--invalidate-reflow-prewarm-scratch-signatures + (scratch region-id) + "Invalidate REGION-ID's path in SCRATCH's recursive signature cache." + (let* ((root (plist-get scratch :tree)) + (cache (plist-get scratch :render-signature-cache)) + (box (ebox--root-region-box root region-id))) + (when cache + (dolist (node (ebox--node-path-to-region root region-id)) + (remhash node cache)) + ;; A flex wrapper's editable box is nested in the owner node and can have + ;; its own memoized signature. + (when box + (remhash box cache))))) + +(defun ebox--confirm-reflow-prewarm-scratch + (buffer region-id revision) + "Confirm BUFFER's predicted root REGION-ID at the new REVISION. +Return non-nil only when both live and isolated trees reached the exact width; +otherwise discard the isolated tree." + (when-let* ((state (ebox--buffer-render-state buffer)) + (scratch (plist-get state :reflow-prewarm-scratch))) + (let* ((root (plist-get state :root-node)) + (scratch-root (plist-get scratch :tree)) + (live-box (ebox--root-region-box root region-id)) + (scratch-box (ebox--root-region-box scratch-root region-id)) + (live-width (ebox--literal-root-pixel-width live-box)) + (scratch-width (ebox--literal-root-pixel-width scratch-box)) + (predicted-width (plist-get scratch :predicted-width)) + (matched + (and (eq (plist-get scratch :source-root) root) + (equal (plist-get scratch :predicted-region-id) region-id) + (equal (plist-get scratch :predicted-revision) revision) + (numberp predicted-width) + (numberp live-width) + (numberp scratch-width) + (= predicted-width live-width) + (= predicted-width scratch-width)))) + (if matched + (progn + (plist-put scratch :live-revision revision) + (plist-put scratch :predicted-region-id nil) + (plist-put scratch :predicted-width nil) + (plist-put scratch :predicted-revision nil) + t) + (ebox--discard-reflow-prewarm-scratch state scratch) + nil)))) + +(defun ebox--prewarm-buffer-render-cache-at-root-width + (buffer state revision region-id source-width target-width) + "Warm BUFFER cache for REGION-ID's predicted ordinary root TARGET-WIDTH. +STATE and REVISION guard the published runtime. SOURCE-WIDTH must still match +the live root constraint. Rendering mutates only an isolated tree copy and +publishes the prepared rendered-body entry plus its derived root templates." + (when (and (buffer-live-p buffer) + (eq state (ebox--buffer-render-state buffer)) + (= revision (or (plist-get state :runtime-revision) 0)) + (numberp source-width) + (numberp target-width) + (> target-width 0) + (let ((box (ebox--root-region-box + (plist-get state :root-node) region-id))) + (and box + (= source-width + (or (ebox--literal-root-pixel-width box) -1))))) + (let* ((scratch (ebox--live-reflow-prewarm-scratch state revision)) + (box-copy + (and scratch + (ebox--root-region-box + (plist-get scratch :tree) region-id))) + completed) + (if (not box-copy) + (ebox--discard-reflow-prewarm-scratch state scratch) + (unwind-protect + (progn + (ebox--invalidate-reflow-prewarm-scratch-signatures + scratch region-id) + (ebox-put box-copy :width target-width) + (ebox--render-reflow-prewarm-scratch + buffer state revision scratch + (plist-get state :viewport-width) + (plist-get state :viewport-height)) + (plist-put scratch :predicted-region-id region-id) + (plist-put scratch :predicted-width target-width) + (plist-put scratch :predicted-revision (1+ revision)) + (setq completed t)) + (unless completed + (ebox--discard-reflow-prewarm-scratch state scratch)))) + completed))) + +(defun ebox--run-root-width-cache-prewarm + (buffer state revision region-id source-width target-width) + "Run one scheduled ordinary root width cache warm." + (remhash buffer ebox--reflow-cache-prewarm-timers) + (prog1 + (ebox--call-with-reflow-cache-prewarm-gc + state + #'ebox--prewarm-buffer-render-cache-at-root-width + buffer state revision region-id source-width target-width) + (ebox--deferred-render-gc-raise-threshold + (plist-get state :reflow-prewarm-gc-threshold)))) + +(defun ebox--schedule-buffer-root-width-cache-prewarm + (buffer region-id source-width target-width) + "Schedule isolated cache warming for an ordinary root TARGET-WIDTH." + (when-let* ((state (and (ebox--reflow-cache-prewarm-enabled-p) + (buffer-live-p buffer) + (ebox--buffer-render-state buffer))) + (box (ebox--root-region-box + (plist-get state :root-node) region-id))) + (when (and (numberp source-width) (numberp target-width) + (> target-width 0) (/= source-width target-width) + (= source-width + (or (ebox--literal-root-pixel-width box) -1))) + (ebox--cancel-buffer-reflow-cache-prewarm buffer) + (let ((revision (or (plist-get state :runtime-revision) 0))) + (puthash + buffer + (run-with-idle-timer + (max 0 ebox-runtime-idle-reflow-cache-prewarm-delay) + nil #'ebox--run-root-width-cache-prewarm + buffer state revision region-id source-width target-width) + ebox--reflow-cache-prewarm-timers))))) + +(defun ebox--run-reflow-cache-prewarm + (buffer state revision source-width viewport-width viewport-height) + "Run one scheduled predicted reflow-cache warm for BUFFER." + (remhash buffer ebox--reflow-cache-prewarm-timers) + (prog1 + (ebox--call-with-reflow-cache-prewarm-gc + state + #'ebox--prewarm-buffer-render-cache-at-context + buffer state revision source-width viewport-width viewport-height) + (ebox--deferred-render-gc-raise-threshold + (plist-get state :reflow-prewarm-gc-threshold)))) + +(defun ebox--schedule-buffer-reflow-cache-prewarm + (buffer old-viewport-width viewport-width) + "Predict BUFFER's next steady viewport width and warm its render cache. +OLD-VIEWPORT-WIDTH and VIEWPORT-WIDTH describe the completed reflow. One job +is scheduled for the next width at the same nonzero delta; invalid nonpositive +predictions are skipped." + (ebox--cancel-buffer-reflow-cache-prewarm buffer) + (when-let ((state (and (ebox--reflow-cache-prewarm-enabled-p) + (buffer-live-p buffer) + (ebox--buffer-render-state buffer)))) + (let* ((delta (and (numberp old-viewport-width) + (numberp viewport-width) + (- viewport-width old-viewport-width))) + (target-width (and delta (+ viewport-width delta)))) + (plist-put state :last-viewport-width-delta + (and delta (/= delta 0) delta)) + (when (and delta + (/= delta 0) + (numberp target-width) + (> target-width 0)) + (let ((revision (or (plist-get state :runtime-revision) 0)) + (viewport-height (plist-get state :viewport-height))) + (puthash + buffer + (run-with-idle-timer + (max 0 ebox-runtime-idle-reflow-cache-prewarm-delay) + nil #'ebox--run-reflow-cache-prewarm + buffer state revision viewport-width target-width viewport-height) + ebox--reflow-cache-prewarm-timers)))))) + +(defun ebox--runtime-prewarm-new-job (state) + "Return a new runtime prewarm job for render STATE." + (list :render-state state + :runtime-revision (or (plist-get state :runtime-revision) 0) + :phase 'role-index + :snapshot-stack + (when-let ((root (plist-get state :root-node))) + (list (vector root :uninitialized t t))) + :node-region-ids-cache (make-hash-table :test 'eq) + :viewport-width-ids-rev nil + :viewport-height-ids-rev nil + :scroll-region-ids (plist-get state :scroll-region-ids) + :scroll-sources nil + :scroll-task nil)) + +(defun ebox--runtime-prewarm-role-index-slice (buffer job) + "Materialize BUFFER's prepared publication indexes before interactions." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (when-let ((table (ebox-buffer--region-role-span-table))) + (unless (ebox-buffer--marker-role-span-table-p table) + (ebox-buffer-materialize-region-role-spans buffer))) + (unless (or (ebox--materialize-box-extent-template buffer) + (when-let* ((root-id (ebox--buffer-root-node-id buffer)) + (extents (gethash root-id ebox--box-extents))) + (and (eq (marker-buffer (car extents)) buffer) + (eq (marker-buffer (cdr extents)) buffer)))) + (ebox--clear-buffer-extents buffer) + (ebox--register-box-extents-in-range (point-min) (point-max))))) + (plist-put job :phase 'snapshots)) + +(defun ebox--runtime-prewarm-schedule-timer (buffer &optional delay) + "Schedule BUFFER's next runtime prewarm slice after DELAY." + (when (and (buffer-live-p buffer) + (gethash buffer ebox--runtime-prewarm-jobs)) + (when-let ((old (gethash buffer ebox--runtime-prewarm-timers))) + (when (timerp old) + (cancel-timer old))) + (puthash buffer + (run-with-idle-timer + (max 0 (or delay ebox-runtime-idle-prewarm-delay)) + nil #'ebox--runtime-prewarm-timer buffer) + ebox--runtime-prewarm-timers))) + +(defun ebox--schedule-buffer-runtime-prewarm (buffer &optional delay) + "Schedule shared incremental-update prewarming for BUFFER." + (ebox--cancel-buffer-runtime-prewarm buffer) + (when-let ((state (and (ebox--runtime-prewarm-enabled-p) + (buffer-live-p buffer) + (ebox--buffer-render-state buffer)))) + (puthash buffer (ebox--runtime-prewarm-new-job state) + ebox--runtime-prewarm-jobs) + (ebox--runtime-prewarm-schedule-timer buffer delay))) + +(defun ebox--runtime-prewarm-record-viewport-node + (job node width-allowed) + "Record NODE's direct viewport dependencies in JOB. +Return non-nil when descendant width dependencies remain relevant." + (let ((width-direct (ebox--node-direct-viewport-width-dependent-p node)) + (vertical (ebox--node-direct-viewport-height-dependent-p node))) + (when (and width-direct width-allowed) + (plist-put job :viewport-width-ids-rev + (cons (ebox--ensure-node-id node) + (plist-get job :viewport-width-ids-rev)))) + (when vertical + (plist-put job :viewport-height-ids-rev + (cons (ebox--ensure-node-id node) + (plist-get job :viewport-height-ids-rev)))) + (and width-allowed + (not (ebox--render-cache-contained-viewport-node-p node))))) + +(defun ebox--runtime-prewarm-snapshot-slice (buffer job) + "Advance BUFFER's post-order lightweight snapshot frontier for JOB." + (let ((limit (max 1 ebox-runtime-idle-prewarm-slice-size)) + (count 0) + (ebox--node-region-ids-cache + (plist-get job :node-region-ids-cache))) + (while (and (< count limit) (plist-get job :snapshot-stack)) + (let* ((stack (plist-get job :snapshot-stack)) + (frame (car stack)) + (node (aref frame 0)) + (children (aref frame 1)) + (width-allowed (aref frame 2))) + (cond + ((eq children :uninitialized) + (aset frame 1 (ebox-tree--children-raw node)) + (aset frame 3 + (ebox--runtime-prewarm-record-viewport-node + job node width-allowed))) + (children + (aset frame 1 (cdr children)) + (plist-put job :snapshot-stack + (cons (vector (car children) :uninitialized + (aref frame 3) (aref frame 3)) + stack))) + (t + (plist-put job :snapshot-stack (cdr stack)) + (when (and (listp node) (not (stringp node))) + (let ((node-id (ebox--ensure-node-id node))) + (ebox--put-layout-snapshot + buffer node-id + (ebox--node-layout-snapshot buffer node nil)))))) + (setq count (1+ count)))) + (unless (plist-get job :snapshot-stack) + (when-let ((state (ebox--buffer-render-state buffer))) + (let ((width-ids (nreverse + (plist-get job :viewport-width-ids-rev))) + (height-ids (nreverse + (plist-get job :viewport-height-ids-rev)))) + (plist-put state :layout-snapshots-complete-p t) + (plist-put state :viewport-dependent-node-id-axes + (cons width-ids height-ids)) + (plist-put state :viewport-dependent-node-ids + (delete-dups + (copy-sequence (append width-ids height-ids))))) + (plist-put state :viewport-dependent-node-ids-ready t)) + (plist-put job :phase 'scroll)))) + +(defun ebox--runtime-prewarm-add-line (task line) + "Add LINE to TASK's index without a later whole-index sort." + (let ((index (plist-get task :index)) + (tails (plist-get task :tails)) + (line-index (plist-get task :line-index))) + (dolist (region-id (ebox--scroll-line-region-ids line)) + (when-let ((span (ebox--scroll-line-region-span + line (ebox--region-id-set (list region-id))))) + (let* ((cell (list (cons line-index span))) + (tail (gethash region-id tails))) + (if tail + (setcdr tail cell) + (puthash region-id cell index)) + (puthash region-id cell tails)))) + (plist-put task :line-index (1+ line-index)))) + +(defun ebox--runtime-prewarm-scroll-source-task (buffer source) + "Return a bounded line-index task for SOURCE owned by BUFFER." + (let* ((region-id (plist-get source :region-id)) + (state (plist-get source :state)) + (lines-key (plist-get source :lines-key)) + (lines (plist-get state lines-key))) + (when (and (eq (ebox--scroll-get-state region-id) state) + (eq (ebox--scroll-state-buffer state) buffer) + (plist-get state (plist-get source :deferred-key)) + lines) + (list :region-id region-id + :state state + :index-key (plist-get source :index-key) + :deferred-key (plist-get source :deferred-key) + :lines-key lines-key + :lines lines + :remaining-lines lines + :line-index 0 + :index (make-hash-table :test 'equal) + :tails (make-hash-table :test 'equal))))) + +(defun ebox--runtime-prewarm-scroll-sources (region-id state) + "Return the two cached-line index sources for REGION-ID and STATE." + (list + (list :region-id region-id + :state state + :index-key :region-line-span-index + :deferred-key :region-line-span-index-deferred + :lines-key :content-lines) + (list :region-id region-id + :state state + :index-key :rendered-region-line-span-index + :deferred-key :rendered-region-line-span-index-deferred + :lines-key :rendered-content-lines))) + +(defun ebox--runtime-prewarm-scroll-slice (buffer job) + "Prepare one cached scroll-line index slice for BUFFER's JOB." + (let ((limit (max 1 ebox-runtime-idle-prewarm-slice-size)) + (count 0)) + (while (and (< count limit) + (not (eq (plist-get job :phase) 'done))) + (cond + ((plist-get job :scroll-task) + (let* ((task (plist-get job :scroll-task)) + (state (plist-get task :state)) + (lines (plist-get task :lines)) + (remaining (plist-get task :remaining-lines))) + (cond + ((or (not (eq (ebox--scroll-get-state + (plist-get task :region-id)) + state)) + (not (eq lines + (plist-get state (plist-get task :lines-key))))) + (plist-put job :scroll-task nil)) + (remaining + (ebox--runtime-prewarm-add-line task (car remaining)) + (plist-put task :remaining-lines (cdr remaining)) + (unless (cdr remaining) + (plist-put state (plist-get task :index-key) + (plist-get task :index)) + (plist-put state (plist-get task :deferred-key) nil) + (plist-put job :scroll-task nil))) + (t + (plist-put job :scroll-task nil)))) + (setq count (1+ count))) + ((plist-get job :scroll-sources) + (let* ((sources (plist-get job :scroll-sources)) + (source (car sources))) + (plist-put job :scroll-sources (cdr sources)) + (when-let ((task + (ebox--runtime-prewarm-scroll-source-task + buffer source))) + (plist-put job :scroll-task task))) + (setq count (1+ count))) + ((plist-get job :scroll-region-ids) + (let* ((region-ids (plist-get job :scroll-region-ids)) + (region-id (car region-ids)) + (state (ebox--scroll-get-state region-id))) + (plist-put job :scroll-region-ids (cdr region-ids)) + (when (and state (eq (ebox--scroll-state-buffer state) buffer)) + (plist-put job :scroll-sources + (ebox--runtime-prewarm-scroll-sources + region-id state)))) + (setq count (1+ count))) + (t + (plist-put job :phase 'done)))))) + +(defun ebox--runtime-prewarm-step (buffer) + "Run one bounded runtime prewarm slice for BUFFER. +Return non-nil while more work remains." + (when-let ((job (gethash buffer ebox--runtime-prewarm-jobs))) + (let ((state (ebox--buffer-render-state buffer))) + (if (or (not (buffer-live-p buffer)) + (not (eq state (plist-get job :render-state))) + (/= (or (plist-get state :runtime-revision) 0) + (plist-get job :runtime-revision))) + (ebox--cancel-buffer-runtime-prewarm buffer) + (pcase (plist-get job :phase) + ('role-index + (ebox--runtime-prewarm-role-index-slice buffer job)) + ('snapshots + (ebox--runtime-prewarm-snapshot-slice buffer job)) + ('scroll + (ebox--runtime-prewarm-scroll-slice buffer job))) + (if (eq (plist-get job :phase) 'done) + (progn + (remhash buffer ebox--runtime-prewarm-jobs) + nil) + t))))) + +(defun ebox--runtime-prewarm-timer (buffer) + "Run BUFFER's scheduled runtime prewarm slice." + (remhash buffer ebox--runtime-prewarm-timers) + (when (ebox--runtime-prewarm-step buffer) + ;; A newly registered idle timer is measured from the beginning of the + ;; current idle period. Scheduling it with zero (or only the base delay) + ;; makes every remaining slice immediately ripe once the first slice runs, + ;; monopolizing redisplay until a large runtime is fully prewarmed. + (ebox--runtime-prewarm-schedule-timer + buffer + (ebox--idle-continuation-delay ebox-runtime-idle-prewarm-delay)))) + +(defun ebox--runtime-prewarm-drain (buffer) + "Synchronously drain BUFFER's scheduled prewarm job for tests and profiling." + (when-let ((timer (gethash buffer ebox--runtime-prewarm-timers))) + (when (timerp timer) + (cancel-timer timer)) + (remhash buffer ebox--runtime-prewarm-timers)) + (let ((limit 100000) + completed) + (while (and (> limit 0) (not completed)) + (unless (gethash buffer ebox--runtime-prewarm-jobs) + (when-let ((state (and (buffer-live-p buffer) + (ebox--buffer-render-state buffer)))) + (puthash buffer (ebox--runtime-prewarm-new-job state) + ebox--runtime-prewarm-jobs))) + (if (ebox--runtime-prewarm-step buffer) + (setq limit (1- limit)) + (setq completed t))) + (when (not completed) + (error "Runtime prewarm did not converge for %S" buffer)))) + +(defun ebox--refresh-layout-snapshots-for-node (buffer node) + "Refresh lightweight snapshots for NODE's runtime subtree in BUFFER." + (ebox--invalidate-buffer-layout-snapshot-details buffer) + (let ((ebox--node-region-ids-cache (make-hash-table :test 'eq))) + (ebox--capture-layout-snapshots buffer node))) + +(defun ebox--scroll-line-with-preserved-wrapper-owner + (line span replacement rendered-owner-set) + "Return REPLACEMENT preserving wrapper context from LINE SPAN. +RENDERED-OWNER-SET identifies owners supplied by the complete fresh render." + (let ((owners (or (get-text-property (car span) 'ebox-content-owners line) + (when-let ((owner (get-text-property + (car span) + 'ebox-content-owner + line))) + (list owner))))) + (if owners + (ebox--string-with-preserved-wrapper-context + replacement owners rendered-owner-set) + replacement))) + +(defun ebox--scroll-pad-line-to-slot-width (line width) + "Return LINE padded to visual slot WIDTH, or nil when LINE is too wide." + (let* ((line-width (ebox--string-pixel-width line)) + (extra (- width line-width))) + (when (>= extra 0) + (concat line (ebox-pixel-space extra))))) + +(defun ebox--scroll-patch-lines-for-region + (lines region-ids rendered &optional spans rendered-owner-set) + "Return LINES with REGION-IDS replaced by RENDERED, or nil on mismatch. +RENDERED-OWNER-SET reuses an existing content-owner scan when supplied." + (let* ((spans (or spans + (ebox--scroll-state-region-line-spans lines region-ids))) + (rendered-lines (ebox-string-lines rendered)) + (rendered-owner-set + (or rendered-owner-set + (ebox--string-content-owner-set rendered)))) + (when (and spans + (<= (length rendered-lines) (length spans))) + (catch 'failed + (let ((result (copy-sequence lines))) + (cl-loop for entry in spans + for replacement = (or (pop rendered-lines) + (ebox-pixel-space 0)) + do + (let* ((idx (car entry)) + (span (cdr entry)) + (line (nth idx result)) + (old-slot (substring line (car span) (cdr span))) + (slot-width (ebox--string-pixel-width old-slot)) + (padded (ebox--scroll-pad-line-to-slot-width + replacement slot-width))) + (unless padded + (throw 'failed nil)) + (setq padded + (ebox--scroll-line-with-preserved-wrapper-owner + line span padded rendered-owner-set)) + (setf (nth idx result) + (concat (substring line 0 (car span)) + padded + (substring line (cdr span)))))) + result))))) + +(defun ebox--scroll-patch-rendered-lines-for-region + (state region-ids rendered) + "Return rendered content lines patched for REGION-IDS, or nil." + (when-let* ((rendered-lines (plist-get state :rendered-content-lines)) + (rendered-spans + (ebox--scroll-rendered-line-spans-for-region-ids + state region-ids))) + (ebox--scroll-patch-lines-for-region + rendered-lines region-ids rendered rendered-spans))) + +(defun ebox--scroll-state-visible-range (state) + "Return visible content index range for scroll STATE as (START . END)." + (let* ((content-lines (plist-get state :content-lines)) + (content-height (or (plist-get state :content-height) 0)) + (max-offset (max 0 (- (length content-lines) content-height))) + (scroll-offset (max 0 (min max-offset + (or (plist-get state :scroll-offset) + 0))))) + (cons scroll-offset + (min (+ scroll-offset content-height) + (length content-lines))))) + +(defun ebox--scroll-state-spans-visible-p (state spans) + "Return non-nil when any SPANS entry falls inside STATE's visible range." + (pcase-let ((`(,start . ,end) (ebox--scroll-state-visible-range state))) + (cl-some (lambda (entry) + (let ((idx (car entry))) + (and (>= idx start) (< idx end)))) + spans))) + +(defun ebox--scroll-apply-visible-node-span-patch + (buffer node changed-keys region-ids rendered rendered-owner-set) + "Patch visible NODE directly while its cached scroll lines are synchronized. +Return backend patch metadata on success. The ordinary span backend preserves +the current parent slot and wrapper paint, avoiding a fixed-height scroll +viewport replacement when only NODE's proven-stable visible spans changed. +RENDERED and RENDERED-OWNER-SET reuse the scroll cache patch's fresh render." + (let* ((node-id (plist-get node :node-id)) + (dirty (list :node-id node-id + :dirty-kind 'geometry + :changed-keys changed-keys + :region-ids region-ids)) + (op (list :op 'span-patch :owner-id node-id :dirty dirty)) + (result (ebox-buffer-apply-span-patch + buffer op rendered rendered-owner-set))) + (when result + (ebox--refresh-runtime-indexes-for-patch-results + buffer (list result) 'span-patch) + (ebox--refresh-text-patch-layout-snapshots buffer (list result)) + result))) + +(defun ebox--try-scroll-state-span-patch + (buffer region-id node changed-keys + &optional skip-materialize allow-nonlocal-keys) + "Patch cached scroll state lines for REGION-ID/NODE in BUFFER. +Return an update report when all containing scroll states were patched. +When ALLOW-NONLOCAL-KEYS is non-nil, the rendered replacement still must fit +the cached line slots, but CHANGED-KEYS need not belong to the visible-buffer +span-patch whitelist. This is used only for semantically hidden nodes." + (when (and buffer node + changed-keys + (or allow-nonlocal-keys + (ebox--scroll-state-span-patch-keys-p changed-keys))) + (let* ((region-ids (ebox--node-all-region-ids node)) + (containing-scroll-state-count + (ebox--node-containing-scroll-state-count buffer node)) + rendered + rendered-owner-set + patched visible-patched span-count + rendered-range-patched + index-used + visible-buffer-patched + visible-direct-patched + patches + (ok + (catch 'scroll-state-patch-failed + (maphash + (lambda (scroll-region-id state) + (when (and + (ebox--scroll-state-covers-node-p buffer state node) + ;; A scroll container's own offset is state, not one + ;; of its cached descendant lines. Treating the whole + ;; container as a line replacement can publish the new + ;; viewport while leaving :scroll-offset stale. + (not (and (equal changed-keys '(:scroll-offset)) + (equal scroll-region-id region-id)))) + (let* ((content-lines (plist-get state :content-lines)) + (content-spans + (and content-lines + (progn + (when (plist-get + state :region-line-span-index) + (setq index-used t)) + (ebox--scroll-hot-content-line-spans + state region-ids))))) + (when (and (not skip-materialize) + (not content-spans) + (not (ebox--node-visible-in-buffer-p + buffer node)) + (plist-get state :materialize-content-lines)) + (setq state + (ebox--scroll-state-materialize-lines + scroll-region-id state)) + (setq content-lines (plist-get state :content-lines)) + (setq content-spans + (and content-lines + (progn + (when (plist-get + state :region-line-span-index) + (setq index-used t)) + (ebox--scroll-hot-content-line-spans + state region-ids))))) + (when content-spans + (unless rendered + (setq rendered + (ebox--with-buffer-render-context buffer + (or + (when-let ((snapshot + (and + (ebox--node-visible-in-buffer-p + buffer node) + (ebox--direct-flex-parent-p + buffer + (plist-get node :node-id)) + (ebox--ensure-layout-snapshot-spans + buffer + (plist-get node :node-id))))) + (ebox--render-node-in-current-flex-slot + buffer + (plist-get node :node-id) + snapshot changed-keys)) + (ebox-render node)))) + (setq rendered-owner-set + (ebox--string-content-owner-set rendered))) + (let* ((new-content-lines + (ebox--scroll-patch-lines-for-region + content-lines region-ids rendered + content-spans rendered-owner-set)) + (rendered-lines + (plist-get state :rendered-content-lines)) + (rendered-spans + (and rendered-lines + (progn + (when (plist-get + state + :rendered-region-line-span-index) + (setq index-used t)) + (ebox--scroll-hot-rendered-line-spans + state region-ids content-spans)))) + (new-rendered-lines + (if rendered-lines + (ebox--scroll-patch-lines-for-region + rendered-lines region-ids rendered + rendered-spans rendered-owner-set) + nil))) + (unless (and new-content-lines + (or (not rendered-lines) + new-rendered-lines)) + (throw 'scroll-state-patch-failed nil)) + (setq patched t) + (when (and rendered-lines new-rendered-lines) + (setq rendered-range-patched t)) + (setq span-count (+ (or span-count 0) + (length content-spans))) + (push (list :scroll-region-id scroll-region-id + :state state + :content-lines new-content-lines + :rendered-lines new-rendered-lines + :has-rendered-lines rendered-lines + :content-spans content-spans + :rendered-spans rendered-spans) + patches)))))) + ebox--scroll-global-state) + t))) + (when (and ok patched) + (catch 'scroll-state-apply-failed + (dolist (patch (nreverse patches)) + (let ((state (plist-get patch :state)) + (scroll-region-id (plist-get patch :scroll-region-id)) + (new-content-lines (plist-get patch :content-lines))) + (setq state + (ebox--scroll-state-patch-lines + state new-content-lines + (plist-get patch :content-spans) + (and (plist-get patch :has-rendered-lines) + (plist-get patch :rendered-lines)) + (plist-get patch :rendered-spans) + region-ids)) + (puthash scroll-region-id state ebox--scroll-global-state) + (when (ebox--scroll-state-spans-visible-p + state (plist-get patch :content-spans)) + (setq visible-patched t) + (unless visible-buffer-patched + (setq visible-direct-patched + (ebox--scroll-apply-visible-node-span-patch + buffer node changed-keys region-ids + rendered rendered-owner-set)) + (setq visible-buffer-patched + (or visible-direct-patched + (and + (= containing-scroll-state-count 1) + (let* ((range + (ebox--scroll-state-visible-range state)) + (visible-lines + (seq-subseq new-content-lines + (car range) (cdr range)))) + (with-current-buffer buffer + (ebox--scroll-update-content + scroll-region-id visible-lines)))))) + (unless visible-buffer-patched + (throw 'scroll-state-apply-failed nil)))))) + (when visible-patched + (unless visible-direct-patched + (ebox--refresh-layout-snapshots-for-node buffer node))) + (ebox--update-report + region-id 'span-patch + :dirty-count 1 + :patch-count 1 + :patch-ops '(span-patch) + :owner-id (plist-get node :node-id) + :owner-ids (list (plist-get node :node-id)) + :owner-type (plist-get node :ebox-type) + :span-count span-count + :scroll-state-patch t + :scroll-state-index index-used + :scroll-rendered-range-patch rendered-range-patched + :dirty-kinds '(geometry) + :dirty-keys changed-keys)))))) + +(defun ebox--try-hidden-scroll-state-span-patch + (buffer region-id node changed-keys) + "Patch cached scroll lines for hidden NODE without touching the live buffer. +Complete scroll states still contain offscreen formatted lines, so safe + slot-compatible geometry changes should update those lines directly instead +of falling through to a live owner rerender at stale viewport coordinates." + (when (and buffer node changed-keys) + (when (and (not (ebox--node-visible-in-buffer-p buffer node)) + (= (ebox--node-containing-scroll-state-count buffer node) + 1)) + (ebox--try-scroll-state-span-patch + buffer region-id node changed-keys t t)))) + +(defun ebox--node-containing-scroll-state-count (buffer node) + "Return the number of BUFFER scroll states that contain NODE." + (let ((count 0)) + (maphash + (lambda (_scroll-region-id state) + (when (ebox--scroll-state-covers-node-p buffer state node) + (setq count (1+ count)))) + ebox--scroll-global-state) + count)) + +(defun ebox--try-hidden-scroll-root-fallback + (buffer region-id node changed-keys) + "Rerender BUFFER root when a hidden scroll cache patch cannot fit. +This correctness fallback preserves the current scroll offset and never treats +offscreen owner coordinates as live patch spans." + (let ((region-ids (and node (ebox--node-all-region-ids node)))) + (when (and buffer node region-ids + (not (ebox--node-visible-in-buffer-p buffer node)) + (> (ebox--node-containing-scroll-state-count buffer node) 0)) + (ebox-buffer-apply-root-rerender buffer) + (ebox--update-report + region-id 'root-rerender + :dirty-count 1 + :patch-count 0 + :patch-ops nil + :owner-id (ebox--buffer-root-node-id buffer) + :owner-ids (list (ebox--buffer-root-node-id buffer)) + :root-rerender t + :scroll-state-patch t + :dirty-kinds '(geometry) + :dirty-keys changed-keys)))) + +(defun ebox--sync-scroll-state-lines-for-node-fallback + (buffer region-id node changed-keys report) + "Synchronize cached scroll lines for updated NODE in BUFFER by rerendering. +Ordinary incremental patching mutates the visible buffer. Scroll containers +also keep their own cached content lines, so those lines must be updated or a +later scroll-window replacement can replay stale content." + (when (and buffer node changed-keys) + (let* ((region-ids (ebox--node-all-region-ids node)) + targets + rendered + patched-count + refreshed-count + deferred-count + failed-count + span-count + marker-refresh-count + (visible-node-p + (ebox--node-visible-in-buffer-p buffer node))) + (maphash + (lambda (scroll-region-id state) + (when (and + (ebox--scroll-state-covers-node-p buffer state node) + ;; The dedicated scroll-offset transaction has already + ;; committed this container's own state/window. Only outer + ;; containing scroll caches still need the rendered node. + (not (and (equal changed-keys '(:scroll-offset)) + (equal scroll-region-id region-id)))) + (let* ((content-lines (plist-get state :content-lines)) + (content-spans + (and content-lines + (ebox--scroll-hot-content-line-spans + state region-ids)))) + (when (or content-spans visible-node-p) + (push (list :scroll-region-id scroll-region-id + :state state + :content-lines content-lines + :content-spans content-spans + :rendered-lines + (plist-get state :rendered-content-lines)) + targets))))) + ebox--scroll-global-state) + (when targets + (setq rendered + (ebox--with-buffer-render-context buffer + (or + (when-let ((snapshot + (and + visible-node-p + (ebox--direct-flex-parent-p + buffer (plist-get node :node-id)) + (ebox--ensure-layout-snapshot-spans + buffer (plist-get node :node-id))))) + (ebox--render-node-in-current-flex-slot + buffer (plist-get node :node-id) snapshot changed-keys)) + (ebox-render node)))) + (dolist (target targets) + (let* ((scroll-region-id (plist-get target :scroll-region-id)) + (state (plist-get target :state)) + (content-lines (plist-get target :content-lines)) + (content-spans (plist-get target :content-spans)) + (new-content-lines + (ebox--scroll-patch-lines-for-region + content-lines region-ids rendered content-spans)) + (rendered-lines (plist-get target :rendered-lines)) + (rendered-spans + (and new-content-lines rendered-lines + (ebox--scroll-hot-rendered-line-spans + state region-ids content-spans))) + (new-rendered-lines + (if (and new-content-lines rendered-lines) + (and rendered-spans + (ebox--scroll-patch-lines-for-region + rendered-lines region-ids rendered + rendered-spans)) + nil))) + (when (and new-content-lines + (or (not rendered-lines) + new-rendered-lines)) + (setq state + (ebox--scroll-state-patch-lines + state new-content-lines content-spans + (and rendered-lines new-rendered-lines) + rendered-spans region-ids)) + (setq state (plist-put state :buffer buffer)) + (puthash scroll-region-id state ebox--scroll-global-state) + (plist-put target :cache-synchronized t) + (setq patched-count (1+ (or patched-count 0))) + (setq span-count (+ (or span-count 0) + (length content-spans)))) + (unless (and new-content-lines + (or (not rendered-lines) + new-rendered-lines)) + ;; A geometry change can outgrow its cached line slot. Lazy + ;; prefixes can be rebuilt from the current runtime tree. A + ;; visible update must publish that cache generation before it + ;; returns; otherwise redisplay can expose a buffer/state split + ;; until an unrelated annotation or scroll repairs the window. + (if (plist-get state :render-content-prefix) + (progn + (setq state + (plist-put state :lazy-scroll-prefix-dirty t)) + (puthash scroll-region-id state + ebox--scroll-global-state) + (let* ((required-lines + (+ (or (plist-get state :scroll-offset) 0) + (or (plist-get state :content-height) 0))) + (rebuilt + (ebox--scroll-state-ensure-prefix-lines + scroll-region-id state required-lines t))) + (if (plist-get rebuilt :lazy-scroll-prefix-dirty) + (progn + (plist-put target :deferred t) + (setq deferred-count + (1+ (or deferred-count 0)))) + (plist-put target :cache-synchronized t)))) + ;; A complete window has no prefix producer left to + ;; rebuild from. The publication itself was verified + ;; by footprint proofs and only touched visible rows, + ;; so adopting the published window back into the + ;; cache is exact; marker verification below still + ;; guards the result. + (let ((adopted + (ebox--scroll-state-adopt-buffer-window-lines + buffer scroll-region-id state))) + (if adopted + (progn + (setq adopted + (plist-put adopted :buffer buffer)) + (puthash scroll-region-id adopted + ebox--scroll-global-state) + (plist-put target :cache-synchronized t) + (setq patched-count + (1+ (or patched-count 0)))) + (plist-put target :failed t) + (setq failed-count + (1+ (or failed-count 0)))))))))) + ;; Commit one marker generation after every cache mutation. Marker-only + ;; publication never rewrites visible text, so nested scroll windows + ;; cannot invalidate each other as they did with per-state zero-delta + ;; scrolling. + (when targets + (ebox--refresh-buffer-scroll-content-markers buffer) + (dolist (target targets) + (when (plist-get target :cache-synchronized) + (let* ((scroll-region-id (plist-get target :scroll-region-id)) + (state (ebox--scroll-get-state scroll-region-id))) + (if (and state + (ebox--scroll-state-window-markers-healthy-p + buffer scroll-region-id state)) + (progn + (when (plist-get + state :lazy-scroll-window-refresh-required) + (setq refreshed-count + (1+ (or refreshed-count 0)))) + (setq state + (ebox--plist-remove + state :lazy-scroll-window-refresh-required)) + (puthash scroll-region-id state + ebox--scroll-global-state) + (setq marker-refresh-count + (1+ (or marker-refresh-count 0)))) + (unless (plist-get target :failed) + ;; In-slot patching cannot represent structural line + ;; changes (for example :overflow transitions adding + ;; or removing unowned visible-overflow lines), and + ;; the divergence only surfaces at this verification. + ;; The published window is footprint-verified truth, + ;; so first adopt it back into the visible cache + ;; slice; only when adoption cannot apply rebuild the + ;; prefix from the current runtime exactly like the + ;; outgrown-slot branch. Publishing a stale cache + ;; that carries no repair marker is never an option. + (cl-flet ((verify () + (ebox--refresh-buffer-scroll-content-markers + buffer) + (when-let ((current + (ebox--scroll-get-state + scroll-region-id))) + (ebox--scroll-state-window-markers-healthy-p + buffer scroll-region-id current)))) + (let* ((adopted + (when-let ((current + (ebox--scroll-get-state + scroll-region-id))) + (ebox--scroll-state-adopt-buffer-window-lines + buffer scroll-region-id current))) + (healthy + (when adopted + (puthash scroll-region-id + (plist-put adopted :buffer buffer) + ebox--scroll-global-state) + (verify))) + (rebuilt-healthy + (unless healthy + (when (plist-get + state :render-content-prefix) + (setq state + (plist-put + state :lazy-scroll-prefix-dirty + t)) + (puthash scroll-region-id state + ebox--scroll-global-state) + (let* ((required-lines + (+ (or (plist-get + state :scroll-offset) + 0) + (or (plist-get + state :content-height) + 0))) + (rebuilt + (ebox--scroll-state-ensure-prefix-lines + scroll-region-id state + required-lines t))) + (and rebuilt + (not (plist-get + rebuilt + :lazy-scroll-prefix-dirty)) + (verify)))))) + (healthy (or healthy rebuilt-healthy))) + (if healthy + (progn + (when-let ((current (ebox--scroll-get-state + scroll-region-id))) + (puthash scroll-region-id + (ebox--plist-remove + current + :lazy-scroll-window-refresh-required) + ebox--scroll-global-state)) + (setq refreshed-count + (1+ (or refreshed-count 0)) + marker-refresh-count + (1+ (or marker-refresh-count 0)))) + (plist-put target :failed t) + (setq failed-count + (1+ (or failed-count 0)))))))))))) + (if (or patched-count refreshed-count deferred-count failed-count) + (append report + (list :scroll-state-sync + (and (not (null (or patched-count + refreshed-count))) + (= (or deferred-count 0) 0) + (= (or failed-count 0) 0)) + :scroll-state-sync-count patched-count + :scroll-state-sync-span-count + (or span-count 0) + :scroll-state-sync-refreshed + (not (null refreshed-count)) + :scroll-state-sync-refreshed-count + (or refreshed-count 0) + :scroll-state-sync-deferred + (not (null deferred-count)) + :scroll-state-sync-deferred-count + (or deferred-count 0) + :scroll-state-sync-failed-count + (or failed-count 0) + :scroll-state-window-marker-refresh-count + (or marker-refresh-count 0) + :scroll-state-window-markers-invalidated + (and (not (null patched-count)) + (null marker-refresh-count)))) + report)))) + +(defun ebox--scroll-line-role-spans (line role region-set) + "Return spans in LINE where ROLE belongs to REGION-SET." + (let* ((property (ebox-buffer--role-property role)) + (limit (length line)) + (pos 0) + spans) + (while (< pos limit) + (let* ((next (or (next-single-property-change pos property line limit) + limit)) + (region-id (get-text-property pos property line))) + (when (and region-id (gethash region-id region-set)) + (push (cons pos next) spans)) + (setq pos next))) + (nreverse spans))) + +(defun ebox--scroll-patch-face-key-in-line-span (line start end key value) + "Patch LINE face KEY to VALUE in START..END." + (let ((pos start)) + (while (< pos end) + (let* ((next (or (next-single-property-change pos 'face line end) + end)) + (face (get-text-property pos 'face line))) + (put-text-property pos next 'face + (ebox-buffer--face-put face key value) + line) + (setq pos next))))) + +(defun ebox--scroll-patch-horizontal-border-face-in-line-span + (line start end key value) + "Patch horizontal border face KEY to VALUE in LINE START..END." + (if (eq value ebox-buffer--remove-face-key) + (ebox--scroll-patch-face-key-in-line-span line start end key value) + (add-face-text-property start end (list key value) nil line))) + +(defun ebox--scroll-paint-lines-for-region + (lines region-ids surfaces &optional candidate-spans) + "Return LINES with paint SURFACES patched for REGION-IDS. +CANDIDATE-SPANS limits scanning to known lines owned by REGION-IDS. Return a +plist with :lines and possibly empty :spans, or nil when a required inferred +surface is absent." + (let* ((region-set (ebox--region-id-set region-ids)) + (result (copy-sequence lines)) + (line-indices (if candidate-spans + (delete-dups + (mapcar #'car (copy-sequence candidate-spans))) + (number-sequence 0 (1- (length lines))))) + changed-spans + failed) + (catch 'failed + (dolist (spec surfaces) + (let ((role (plist-get spec :role)) + (face-key (plist-get spec :face-key)) + (face-value (plist-get spec :face-value)) + (missing (plist-get spec :missing)) + (remove-role (plist-get spec :remove-role)) + (surface-patched nil)) + (dolist (idx line-indices) + (when-let ((line (nth idx result))) + (let ((line-copy nil)) + (dolist (span (ebox--scroll-line-role-spans + line role region-set)) + (unless line-copy + (setq line-copy (copy-sequence line)) + (setf (nth idx result) line-copy)) + (if (memq role '(bt bb)) + (ebox--scroll-patch-horizontal-border-face-in-line-span + line-copy (car span) (cdr span) face-key face-value) + (ebox--scroll-patch-face-key-in-line-span + line-copy (car span) (cdr span) face-key face-value)) + (when remove-role + (remove-text-properties + (car span) (cdr span) + (list (ebox-buffer--role-property role) nil) + line-copy)) + (push (cons idx span) changed-spans) + (setq surface-patched t))))) + (when (and (not surface-patched) + (eq missing 'infer)) + (setq failed t) + (throw 'failed nil)))) + (unless failed + (list :lines result :spans (nreverse changed-spans)))))) + +(defun ebox--scroll-state-adopt-buffer-window-lines (buffer region-id state) + "Adopt BUFFER's live window text into STATE's visible rendered cache. +Buffer and cache paint patches run through different patchers, so +visually identical lines can diverge in raw representation. The +published buffer is the display truth: copying its window lines back +over the visible slice of `:rendered-content-lines' restores exact +representational identity, which keeps later window verification on +the cheap raw-equality path and keeps the settled cache replaying +exactly what is on screen. Only chrome-free windows adopt; decorated +windows keep their semantically patched lines. Return the updated +state, or nil when no adoption happened." + (when-let* ((box (plist-get state :box)) + ((ebox--scroll-chrome-free-rendered-window-p box)) + (rendered-lines (plist-get state :rendered-content-lines)) + (height (plist-get state :content-height)) + ((integerp height)) + ((> height 0)) + (extents (ebox--scroll-state-window-extents region-id state)) + (start (car extents)) + (end (cdr extents)) + ((markerp start)) + ((markerp end)) + ((eq (marker-buffer start) buffer)) + ((eq (marker-buffer end) buffer)) + (start-pos (marker-position start)) + (end-pos (marker-position end)) + ((< start-pos end-pos))) + (with-current-buffer buffer + (when (= (count-lines start-pos end-pos) height) + (let* ((window-lines + (ebox-string-lines + (buffer-substring start-pos end-pos))) + (offset (or (plist-get state :scroll-offset) 0))) + (when (and (= (length window-lines) height) + (<= (+ offset height) (length rendered-lines))) + (let ((copy (copy-sequence rendered-lines))) + (cl-loop for line in window-lines + for index from offset + do (setf (nth index copy) line)) + (setq state + (plist-put state :rendered-content-lines copy))) + (when-let* ((content-lines (plist-get state :content-lines)) + ((<= (+ offset height) (length content-lines)))) + (let ((copy (copy-sequence content-lines))) + (cl-loop for line in window-lines + for index from offset + do (setf (nth index copy) line)) + (setq state (plist-put state :content-lines copy)))) + state)))))) + +(defun ebox--sync-scroll-state-paint-lines-for-node + (buffer node changed-keys report) + "Synchronize scroll cached lines for NODE paint-only CHANGED-KEYS. +This mirrors buffer paint patches by editing cached line text properties rather +than rerendering NODE, keeping GUI dynamic updates bounded." + (when-let* ((surfaces (and (cl-every + (lambda (key) + (memq key ebox--paint-style-signature-keys)) + changed-keys) + (ebox--paint-text-property-surfaces + node changed-keys))) + (_ (ebox-buffer--paint-surfaces-safe-for-node-p + node surfaces))) + (let* ((region-ids (ebox--node-all-region-ids node)) + (index-stable + (cl-every (lambda (spec) + (not (plist-get spec :remove-role))) + surfaces)) + patched-region-ids + patched-count + span-count) + (catch 'failed + (maphash + (lambda (scroll-region-id state) + (let ((covering-lazy-state + (and (plist-get state :materialize-content-lines) + (not (plist-get state :content-lines-complete-p)) + (ebox--scroll-state-covers-node-p + buffer state node)))) + (let* ((content-lines (plist-get state :content-lines)) + (content-spans + (and content-lines + (ebox--scroll-hot-content-line-spans + state region-ids)))) + (if (not content-spans) + (when covering-lazy-state + (setq state + (plist-put state :lazy-scroll-prefix-dirty t)) + (puthash scroll-region-id state + ebox--scroll-global-state)) + (let* ((content-patch + (ebox--scroll-paint-lines-for-region + content-lines region-ids surfaces content-spans)) + (rendered-lines + (plist-get state :rendered-content-lines)) + (rendered-spans + (and rendered-lines + (ebox--scroll-hot-rendered-line-spans + state region-ids content-spans))) + (rendered-patch + (and rendered-lines + rendered-spans + (ebox--scroll-paint-lines-for-region + rendered-lines region-ids surfaces + rendered-spans)))) + (if (not (and content-patch + (or (not rendered-lines) + rendered-patch))) + ;; A surface the cached lines cannot express + ;; yet (for example enabling a border whose + ;; role span does not exist in the cache) + ;; already patched the published buffer + ;; through the inferring buffer patcher. The + ;; buffer is the display truth and the paint + ;; only touched rows inside the visible + ;; window, so adopting the window back into + ;; the cache is exact; only when even + ;; adoption is impossible does the fallback + ;; take over. + (let ((adopted + (ebox--scroll-state-adopt-buffer-window-lines + buffer scroll-region-id state))) + (unless adopted + (throw 'failed nil)) + (setq adopted + (plist-put adopted :buffer buffer)) + (puthash scroll-region-id adopted + ebox--scroll-global-state) + (cl-pushnew scroll-region-id + patched-region-ids + :test #'equal) + (setq patched-count + (1+ (or patched-count 0)))) + (setq state + (if index-stable + (let ((state + (plist-put + state :content-lines + (plist-get content-patch + :lines)))) + (if rendered-lines + (plist-put + state :rendered-content-lines + (plist-get rendered-patch + :lines)) + state)) + (ebox--scroll-state-patch-lines + state + (plist-get content-patch :lines) + (plist-get content-patch :spans) + (and rendered-lines + (plist-get rendered-patch :lines)) + (and rendered-lines + (plist-get rendered-patch :spans)) + region-ids))) + (setq state (plist-put state :buffer buffer)) + ;; Restore exact representational identity + ;; between the published window and its visible + ;; cache slice so the settled cache replays + ;; exactly what is on screen and verification + ;; stays on the raw fast path. + (setq state + (or (ebox--scroll-state-adopt-buffer-window-lines + buffer scroll-region-id state) + state)) + (puthash scroll-region-id state + ebox--scroll-global-state) + (cl-pushnew scroll-region-id patched-region-ids + :test #'equal) + (setq patched-count (1+ (or patched-count 0))) + (setq span-count + (+ (or span-count 0) + (length + (plist-get content-patch + :spans)))))))))) + ebox--scroll-global-state) + (when patched-count + ;; Pure property patches move no buffer text, so existing + ;; marker windows stay anchored to the same positions and the + ;; full-buffer marker rebuild is normally unnecessary. Verify + ;; first; rebuild one marker generation and re-verify only + ;; when a patched window no longer proves healthy (for + ;; example after a role-removing patch replaced cached line + ;; structure). Existing markers are never cleared without a + ;; replacement, so the live window cannot detach. + (let ((refreshed 0)) + (cl-flet ((windows-healthy-p () + (cl-every + (lambda (scroll-region-id) + (when-let ((state + (ebox--scroll-get-state + scroll-region-id))) + (ebox--scroll-state-window-markers-healthy-p + buffer scroll-region-id state))) + patched-region-ids))) + (unless (windows-healthy-p) + (ebox--refresh-buffer-scroll-content-markers buffer) + (setq refreshed (length patched-region-ids)) + (unless (windows-healthy-p) + (throw 'failed nil)))) + (setq report + (append report + (list :scroll-state-paint-sync t + :scroll-state-sync-count patched-count + :scroll-state-sync-span-count + (or span-count 0) + :scroll-state-index-preserved index-stable + :scroll-state-window-marker-refresh-count + refreshed + :scroll-state-window-markers-invalidated + nil))))) + report)))) + +(defun ebox--sync-scroll-state-lines-for-node + (buffer region-id node changed-keys report) + "Synchronize cached scroll lines for updated NODE in BUFFER. +Ordinary incremental patching mutates the visible buffer. Scroll containers +also keep their own cached content lines, so those lines must be updated or a +later scroll-window replacement can replay stale content." + (or (ebox--sync-scroll-state-paint-lines-for-node + buffer node changed-keys report) + (ebox--sync-scroll-state-lines-for-node-fallback + buffer region-id node changed-keys report))) + +;;; ============================================================ +;;; Scroll Interaction Commands +;;; ============================================================ + +(defun ebox--region-id-at-point () + "Get region-id at current point by checking ebox-content property." + (get-text-property (point) 'ebox-content)) + +(defun ebox--scroll-region-ids-in-extents-at-pos (pos) + "Return scroll region ids whose live extents contain POS, inner first." + (let (candidates) + (maphash + (lambda (region-id _state) + (when-let ((extents (ebox--live-box-extents region-id))) + (let ((start (car extents)) + (end (cdr extents))) + (when (and (markerp start) + (markerp end) + (eq (marker-buffer start) (current-buffer)) + (<= (marker-position start) pos) + (< pos (marker-position end))) + (push (cons region-id + (- (marker-position end) + (marker-position start))) + candidates))))) + ebox--scroll-global-state) + (mapcar #'car + (sort candidates + (lambda (a b) (< (cdr a) (cdr b))))))) + +(defun ebox--scroll-region-ids-in-buffer-outer-first () + "Return scroll region ids in the current buffer from outer to inner." + (let (candidates) + (maphash + (lambda (region-id _state) + (when-let ((extents (ebox--live-box-extents region-id))) + (let ((start (car extents)) + (end (cdr extents))) + (when (and (markerp start) + (markerp end) + (eq (marker-buffer start) (current-buffer))) + (push (cons region-id + (- (marker-position end) + (marker-position start))) + candidates))))) + ebox--scroll-global-state) + (mapcar #'car + (sort candidates + (lambda (a b) (> (cdr a) (cdr b))))))) + +(defun ebox--scroll-region-ids-at-pos (pos) + "Return scroll candidate region ids at POS from inner to outer." + (let (ids) + (cl-labels ((add (region-id) + (when (and region-id + (not (member region-id ids))) + (setq ids (append ids (list region-id)))))) + (add (get-text-property pos 'ebox-content)) + (dolist (region-id (get-text-property pos 'ebox-content-owners)) + (add region-id)) + (add (get-text-property pos 'ebox-content-owner)) + (dolist (entry ebox-region-types) + (unless (memq (cdr entry) '(ebox-content ebox-content-owner)) + (add (get-text-property pos (cdr entry))))) + (unless (cl-some #'ebox--scroll-get-state ids) + (dolist (region-id (ebox--scroll-region-ids-in-extents-at-pos pos)) + (add region-id)))) + ids)) + +(defun ebox--scroll-region-ids-at-point () + "Return scroll candidate region ids at point from inner to outer." + (when (< (point-min) (point-max)) + (ebox--scroll-region-ids-at-pos + (min (max (point) (point-min)) + (1- (point-max)))))) + +(defun ebox--scroll-refresh-dirty-prefix-for-offset + (region-id state desired-offset) + "Refresh dirty lazy STATE through REGION-ID's next visible window. +Geometry updates can invalidate cached prefix lines without changing their +length. Refresh them before any scroll, even when DESIRED-OFFSET remains +inside the old prefix, so a line-slide never mixes two layout versions." + (if (not (plist-get state :lazy-scroll-prefix-dirty)) + state + (let* ((scroll-offset (or (plist-get state :scroll-offset) 0)) + (content-height (max 0 (or (plist-get state :content-height) 0))) + (required-lines + (+ (max 0 scroll-offset desired-offset) content-height))) + (ebox--scroll-state-ensure-prefix-lines + region-id state required-lines t)))) + +(defun ebox--scroll-region-by (region-id delta &optional prefix-budget-lines) + "Scroll REGION-ID content by DELTA lines when it has scroll capacity. +Return the consumed line delta, `pending' for lazy cache misses, or nil. +PREFIX-BUDGET-LINES caps synchronous lazy-prefix rendering for this call." + (when-let ((state (and region-id (ebox--scroll-get-state region-id)))) + (catch 'native-prefix-pending + (when (and (plist-get state :native-reflow-materialize-p) + (plist-get state :materialize-content-lines)) + (setq state (ebox--scroll-state-materialize-lines region-id state))) + (when (plist-get state :native-reflow-target-prefix-p) + (let* ((scroll-offset (or (plist-get state :scroll-offset) 0)) + (content-height + (max 0 (or (plist-get state :content-height) 0))) + (required-lines + (+ (max 0 scroll-offset (+ scroll-offset delta)) + content-height))) + (if (ebox--scroll-sync-prefix-render-p) + (setq state + (ebox--scroll-state-ensure-prefix-lines + region-id state required-lines t)) + ;; The exact native window is already visible. Preserve it and + ;; let bounded idle slices prepare the target-width cache instead + ;; of freezing the GUI on a first wheel event. + (setq state + (plist-put state :cache-miss-prefetch-target-lines + required-lines)) + (puthash region-id state ebox--scroll-global-state) + (ebox--scroll-schedule-idle-prefetch region-id 0) + (throw 'native-prefix-pending 'pending)))) + (let* ((scroll-offset (or (plist-get state :scroll-offset) 0)) + (desired-offset (+ scroll-offset delta)) + (dirty-prefix-p (plist-get state :lazy-scroll-prefix-dirty)) + refresh-window-p) + (setq state + (ebox--scroll-refresh-dirty-prefix-for-offset + region-id state desired-offset)) + (setq refresh-window-p + (or dirty-prefix-p + (plist-get state :lazy-scroll-window-refresh-required))) + (if (plist-get state :lazy-scroll-prefix-dirty) + 'pending + (when (and (> delta 0) + (or prefix-budget-lines + (and (ebox--scroll-sync-prefix-render-p) + (<= delta + (ebox--scroll-prefetch-slice-lines))))) + (setq state + (ebox--scroll-ensure-bounded-prefix-for-offset + region-id state desired-offset prefix-budget-lines))) + (setq refresh-window-p + (or refresh-window-p + (plist-get state :lazy-scroll-window-refresh-required))) + (let* ((content-lines (plist-get state :content-lines)) + (content-height (plist-get state :content-height)) + (max-offset (max 0 (- (length content-lines) content-height))) + (cache-miss-p + (and (> delta 0) + (> desired-offset max-offset) + (plist-get state :render-content-prefix) + (not (plist-get state :content-lines-complete-p)))) + (new-offset (max 0 (min max-offset desired-offset)))) + (when cache-miss-p + (setq state + (ebox--scroll-schedule-cache-miss-prefetch + region-id state))) + (cond + ((and (= new-offset scroll-offset) refresh-window-p) + (let* ((new-visible-lines + (seq-subseq content-lines + new-offset + (min (+ new-offset content-height) + (length content-lines)))) + (inhibit-read-only t) + (updated-spans + (ebox--scroll-update-content + region-id new-visible-lines nil)) + (all-spans-updated + (and updated-spans + (cl-every #'identity updated-spans)))) + (when (or all-spans-updated + (ebox--scroll-replace-rendered-window region-id state) + (ebox--scroll-rerender-region region-id)) + (when-let ((current-state + (ebox--scroll-get-state region-id))) + (puthash + region-id + (ebox--plist-remove + current-state :lazy-scroll-window-refresh-required) + ebox--scroll-global-state)) + (unless (gethash region-id ebox--smooth-scroll-state-table) + (ebox--scroll-schedule-idle-prefetch region-id)) + 'refreshed))) + ((= new-offset scroll-offset) + (and cache-miss-p 'pending)) + (t + (let ((old-offset scroll-offset) + (box (plist-get state :box)) + (buffer (ebox--scroll-state-buffer state))) + (when box + (ebox-put box :scroll-offset new-offset) + (when buffer + (ebox--invalidate-buffer-render-signatures + buffer (ebox--ensure-node-id box)))) + (plist-put state :scroll-offset new-offset) + (let* ((new-visible-lines + (seq-subseq content-lines + new-offset + (min (+ new-offset content-height) + (length content-lines)))) + (inhibit-read-only t) + (updated-spans + (ebox--scroll-update-content + region-id new-visible-lines + (unless refresh-window-p old-offset))) + (all-spans-updated + (and updated-spans + (cl-every #'identity updated-spans)))) + (if (or all-spans-updated + (ebox--scroll-replace-rendered-window region-id state) + (ebox--scroll-rerender-region region-id)) + (progn + (when refresh-window-p + (when-let ((current-state + (ebox--scroll-get-state region-id))) + (puthash + region-id + (ebox--plist-remove + current-state + :lazy-scroll-window-refresh-required) + ebox--scroll-global-state))) + (unless (gethash region-id + ebox--smooth-scroll-state-table) + (ebox--scroll-schedule-idle-prefetch region-id)) + (- new-offset old-offset)) + (when box + (ebox-put box :scroll-offset old-offset)) + (plist-put state :scroll-offset old-offset) + nil))))))))))) + +(defun ebox--scroll-progress-p (result) + "Return non-nil when RESULT represents consumed or pending scroll work." + (or (memq result '(pending refreshed)) + (and (numberp result) + (/= result 0)))) + +(defun ebox--scroll-redisplay-after-tick () + "Flush GUI redisplay after an asynchronous scroll tick." + (unless noninteractive + (redisplay t))) + +(defun ebox--scroll-rerender-region (region-id) + "Rerender scroll REGION-ID when direct content marker updates are unavailable." + (when-let* ((extents (ebox--live-box-extents region-id)) + (start-marker (car extents)) + (buffer (marker-buffer start-marker))) + (condition-case nil + (progn + (ebox--with-buffer-render-context buffer + (ebox--rerender-box region-id)) + (when (buffer-live-p buffer) + (ebox--refresh-buffer-scroll-content-markers buffer) + (ebox-buffer-refresh-region-role-spans buffer) + (ebox--refresh-buffer-layout-snapshots buffer)) + t) + (error nil)))) + +(defun ebox--scroll-decorate-rendered-window-lines (lines box region-id) + "Apply fixed scroll viewport chrome from BOX to rendered LINES." + (let* ((padding-top (floor (ebox-get box :padding-top-height))) + (padding-bottom (floor (ebox-get box :padding-bottom-height))) + (content-pixel (ebox--content-pixel box)) + (color (ebox-get box :color)) + (bgcolor (ebox-get box :bgcolor))) + (cl-labels + ((style-line (line) + (ebox--propertize-colors line color bgcolor)) + (padding-lines (height property) + (when (> height 0) + (mapcar #'style-line + (ebox-string-lines + (ebox--propertize-region + (or (ebox--pixel-blank content-pixel height) "") + property region-id)))))) + (let ((lines (append (padding-lines padding-top 'ebox-pt) + ;; These are already fully styled when + ;; `:rendered-content-lines' is produced during the + ;; original render. Reapplying faces to every + ;; complex child-layout line on each wheel tick is + ;; expensive enough to freeze GUI Emacs. + (mapcar #'copy-sequence lines) + (padding-lines padding-bottom 'ebox-pb)))) + (when (and lines (ebox-get box :border-top-p)) + (let ((line (ebox--propertize-overline + (car lines) + (ebox-get box :border-top-color)))) + (add-text-properties 0 (length line) `(ebox-bt ,region-id) line) + (setcar lines line))) + (when (and lines (ebox-get box :border-bottom-p)) + (let ((line (ebox--propertize-underline + (car (last lines)) + (ebox-get box :border-bottom-color)))) + (add-text-properties 0 (length line) `(ebox-bb ,region-id) line) + (setcar (last lines) line))) + lines)))) + +(defun ebox--render-scroll-state-window (state region-id) + "Render scroll STATE's current visible window for REGION-ID. +This uses the already formatted `:content-lines' stored in STATE, so scrolling +child-layout content does not need to rerender the whole source tree." + (setq state (ebox--scroll-state-materialize-lines region-id state)) + (when-let* ((box (plist-get state :box)) + (content-lines (plist-get state :content-lines)) + (content-height (plist-get state :content-height))) + (let* ((text-height (length content-lines)) + (rendered-content-lines (plist-get state :rendered-content-lines)) + (max-offset (max 0 (- text-height content-height))) + (scroll-offset (max 0 (min max-offset + (or (plist-get state :scroll-offset) + 0))))) + (if rendered-content-lines + (ebox-lines-join + (ebox--scroll-decorate-rendered-window-lines + (seq-subseq rendered-content-lines + scroll-offset + (min (+ scroll-offset content-height) text-height)) + box region-id)) + (let* ( + (content-pixel (ebox--content-pixel box)) + (padding-left (ebox-get box :padding-left-pixel)) + (padding-right (ebox-get box :padding-right-pixel)) + (padding-top (floor (ebox-get box :padding-top-height))) + (padding-bottom (floor (ebox-get box :padding-bottom-height))) + (margin-left (ebox-get box :margin-left-pixel)) + (margin-right (ebox-get box :margin-right-pixel)) + (margin-top (floor (ebox-get box :margin-top-height))) + (margin-bottom (floor (ebox-get box :margin-bottom-height))) + (border-left-pixel (ebox-get box :border-left-pixel)) + (border-left-color (ebox-get box :border-left-color)) + (border-right-pixel (ebox-get box :border-right-pixel)) + (border-right-color (ebox-get box :border-right-color)) + (border-top-p (ebox-get box :border-top-p)) + (border-top-color (ebox-get box :border-top-color)) + (border-bottom-p (ebox-get box :border-bottom-p)) + (border-bottom-color (ebox-get box :border-bottom-color)) + (color (ebox-get box :color)) + (bgcolor (ebox-get box :bgcolor)) + (inner-height (+ content-height padding-top padding-bottom)) + (padding-line-filler (ebox-pixel-space content-pixel)) + (visible-lines + (seq-subseq content-lines + scroll-offset + (min (+ scroll-offset content-height) text-height))) + (result + (ebox-lines-join + (cl-loop for line in visible-lines + for idx from 0 + collect (ebox--propertize-content-line + line region-id idx + padding-line-filler))))) + (setq result + (ebox--lines-align-vertical + result content-height (ebox-get box :vertical-align))) + (setq result + (ebox--maplines + (lambda (line) + (if (and (string-empty-p (string-trim line)) + (not (ebox--line-has-non-content-properties-p line))) + (let ((content-id (get-text-property 0 'ebox-content line)) + (content-idx (get-text-property 0 'ebox-content-idx line))) + (if content-id + (propertize padding-line-filler + 'ebox-content content-id + 'ebox-content-idx content-idx + 'ebox-content-owner region-id) + (propertize padding-line-filler + 'ebox-content region-id + 'ebox-content-idx 0 + 'ebox-content-owner region-id))) + line)) + result)) + (when (or (> padding-top 0) (> padding-bottom 0)) + (let ((pt-str (when (> padding-top 0) + (ebox--propertize-region + (ebox--pixel-blank content-pixel padding-top) + 'ebox-pt region-id))) + (pb-str (when (> padding-bottom 0) + (ebox--propertize-region + (ebox--pixel-blank content-pixel padding-bottom) + 'ebox-pb region-id)))) + (setq result (ebox--lines-stack-vertical pt-str result pb-str)))) + (let ((pl-str (when (> padding-left 0) + (ebox--propertize-region + (ebox--pixel-blank padding-left inner-height) + 'ebox-pl region-id))) + (pr-str (when (> padding-right 0) + (ebox--propertize-region + (ebox--pixel-blank padding-right inner-height) + 'ebox-pr region-id)))) + (when (or pl-str pr-str) + (setq result (ebox--lines-concat-horizontal pl-str result pr-str)))) + (when (or color bgcolor) + (setq result + (ebox--maplines + (lambda (line) + (ebox--propertize-colors line color bgcolor)) + result))) + (when (or (> border-left-pixel 0) (> border-right-pixel 0)) + (let ((bl-str (when (> border-left-pixel 0) + (ebox--propertize-region + (ebox--pixel-border border-left-pixel + inner-height border-left-color) + 'ebox-bl region-id))) + (br-str (when (> border-right-pixel 0) + (ebox--propertize-region + (ebox--pixel-border border-right-pixel + inner-height border-right-color) + 'ebox-br region-id)))) + (setq result (ebox--lines-concat-horizontal bl-str result br-str)))) + (when (or border-top-p border-bottom-p) + (let ((lines (ebox-string-lines result))) + (when border-top-p + (setf (car lines) + (propertize + (ebox--propertize-overline (car lines) border-top-color) + 'ebox-bt region-id))) + (when border-bottom-p + (setf (car (last lines)) + (propertize + (ebox--propertize-underline + (car (last lines)) border-bottom-color) + 'ebox-bb region-id))) + (setq result (ebox-lines-join lines)))) + ;; Visibility hides the box's own ink (content glyphs, colors, + ;; borders) after assembly; margins below stay blank either way. + (when (eq (ebox-get box :visibility) 'hidden) + (setq result (ebox--apply-hidden-visibility result))) + (when (or (> margin-left 0) (> margin-right 0)) + (let* ((box-height (ebox-string-height result)) + (ml-str (when (> margin-left 0) + (ebox--propertize-region + (ebox--pixel-blank margin-left box-height) + 'ebox-ml region-id))) + (mr-str (when (> margin-right 0) + (ebox--propertize-region + (ebox--pixel-blank margin-right box-height) + 'ebox-mr region-id)))) + (setq result (ebox--lines-concat-horizontal ml-str result mr-str)))) + (when (or (> margin-top 0) (> margin-bottom 0)) + (let ((total-pixel-width (ebox--total-pixel box))) + (let ((mt-str (when (> margin-top 0) + (ebox--propertize-region + (ebox--pixel-blank total-pixel-width margin-top) + 'ebox-mt region-id))) + (mb-str (when (> margin-bottom 0) + (ebox--propertize-region + (ebox--pixel-blank total-pixel-width margin-bottom) + 'ebox-mb region-id)))) + (setq result (ebox--lines-stack-vertical mt-str result mb-str))))) + result))))) + +(defun ebox--scroll-replace-rendered-window (region-id state) + "Patch scroll REGION-ID by replacing its fixed rendered viewport from STATE." + (when-let* ((extents (ebox--live-box-extents region-id)) + (start-marker (car extents)) + (end-marker (cdr extents)) + (buffer (marker-buffer start-marker)) + ((eq buffer (marker-buffer end-marker))) + (rendered (ebox--render-scroll-state-window state region-id))) + (with-current-buffer buffer + (let* ((start (marker-position start-marker)) + (end (marker-position end-marker)) + (old-height (count-lines start end)) + (new-height (length (ebox-string-lines rendered))) + (window-states + (mapcar (lambda (window) + (list :window window + :start (window-start window) + :point (window-point window) + :hscroll (window-hscroll window))) + (get-buffer-window-list buffer nil t))) + (inhibit-read-only t)) + (when (= old-height new-height) + (save-excursion + (goto-char start) + (delete-region start end) + (insert rendered) + (ebox--scroll-store-rendered-window-extents + buffer region-id state start (point) new-height)) + (dolist (window-state window-states) + (let ((window (plist-get window-state :window))) + (when (and (window-live-p window) + (eq (window-buffer window) buffer)) + (set-window-start + window + (min (plist-get window-state :start) (point-max)) + t) + (set-window-point + window + (min (plist-get window-state :point) (point-max))) + (set-window-hscroll + window (plist-get window-state :hscroll))))) + t))))) + +(defun ebox--scroll-first-region-by (region-ids delta) + "Scroll the first region in REGION-IDS that can consume DELTA." + (catch 'scrolled + (dolist (region-id region-ids) + (when (ebox--scroll-progress-p + (ebox--scroll-region-by region-id delta)) + (throw 'scrolled t))) + nil)) + +(defvar ebox--smooth-scroll-state-table (make-hash-table :test 'equal) + "Region-keyed pending smooth wheel scroll state.") + +(defun ebox--scrollable-region-p (region-id delta) + "Return non-nil when REGION-ID can consume DELTA lines." + (when-let ((state (and region-id (ebox--scroll-get-state region-id)))) + (let* ((scroll-offset (or (plist-get state :scroll-offset) 0)) + (content-lines (plist-get state :content-lines)) + (content-height (plist-get state :content-height)) + (max-offset (max 0 (- (length content-lines) content-height))) + (new-offset (max 0 (min max-offset (+ scroll-offset delta))))) + (or (and (/= delta 0) + (plist-get state :native-reflow-target-prefix-p)) + (and (> delta 0) + (plist-get state :render-content-prefix) + (not (plist-get state :content-lines-complete-p))) + (and (> max-offset 0) + (/= new-offset scroll-offset)))))) + +(defun ebox--first-scrollable-region (region-ids delta) + "Return the first region in REGION-IDS that can consume DELTA." + (cl-find-if (lambda (region-id) + (ebox--scrollable-region-p region-id delta)) + region-ids)) + +(defun ebox--smooth-scroll-stop (region-id) + "Stop pending smooth wheel scroll animation for REGION-ID." + (when-let ((entry (gethash region-id ebox--smooth-scroll-state-table))) + (when-let ((timer (plist-get entry :timer))) + (cancel-timer timer)) + (remhash region-id ebox--smooth-scroll-state-table) + (ebox--deferred-render-gc-schedule-restore) + (ebox--scroll-schedule-idle-prefetch region-id))) + +(defun ebox--smooth-scroll-pause-for-prefetch + (region-id entry pending) + "Pause REGION-ID smooth scroll ENTRY until lazy prefetch extends the cache." + (when-let ((timer (plist-get entry :timer))) + (when (timerp timer) + (cancel-timer timer))) + (plist-put entry :timer nil) + (plist-put entry :pending pending) + (plist-put entry :waiting-prefetch t) + (puthash region-id entry ebox--smooth-scroll-state-table)) + +(defun ebox--smooth-scroll-clamp-pending (region-id pending) + "Clamp PENDING lines to REGION-ID's remaining scrollable range." + (if-let ((state (and region-id (ebox--scroll-get-state region-id)))) + (let* ((scroll-offset (or (plist-get state :scroll-offset) 0)) + (lazy-forward + (and (> pending 0) + (plist-get state :render-content-prefix) + (not (plist-get state :content-lines-complete-p)))) + (content-line-count (length (plist-get state :content-lines))) + (content-height (plist-get state :content-height)) + (max-offset (max 0 (- content-line-count content-height)))) + (if lazy-forward + pending + (max (- scroll-offset) + (min (- max-offset scroll-offset) + pending)))) + 0)) + +(defun ebox--smooth-scroll-tick-magnitude (pending) + "Return how many lines one smooth tick should consume from PENDING." + (let* ((pending (abs pending)) + (base (max 1 ebox-wheel-smooth-scroll-lines-per-tick)) + (target (max 1 ebox-wheel-smooth-scroll-target-ticks)) + (adaptive (ceiling pending target))) + (min pending (max base adaptive)))) + +(defun ebox--smooth-scroll-lazy-boundary-p (region-id delta) + "Return non-nil when DELTA would cross REGION-ID's lazy prefix boundary." + (when-let ((state (and (> delta 0) + (ebox--scroll-get-state region-id)))) + (let* ((scroll-offset (or (plist-get state :scroll-offset) 0)) + (content-lines (plist-get state :content-lines)) + (content-height (or (plist-get state :content-height) 0)) + (max-offset (max 0 (- (length content-lines) content-height)))) + (and (> (+ scroll-offset delta) max-offset) + (plist-get state :render-content-prefix) + (not (plist-get state :content-lines-complete-p)))))) + +(defun ebox--smooth-scroll-tick (region-id) + "Run one smooth wheel scroll tick for REGION-ID." + (let* ((entry (gethash region-id ebox--smooth-scroll-state-table)) + (pending (and entry + (ebox--smooth-scroll-clamp-pending + region-id + (or (plist-get entry :pending) 0))))) + (cond + ((or (not entry) (= pending 0) (not (ebox--scroll-get-state region-id))) + (ebox--smooth-scroll-stop region-id)) + (t + (let* ((magnitude (ebox--smooth-scroll-tick-magnitude pending)) + (delta (if (> pending 0) magnitude (- magnitude)))) + (when (ebox--smooth-scroll-lazy-boundary-p region-id delta) + (setq magnitude + (min magnitude + (max 1 ebox-wheel-smooth-scroll-lines-per-tick))) + (setq delta (if (> pending 0) magnitude (- magnitude)))) + (let* ((state-before (ebox--scroll-get-state region-id)) + (offset-before (or (plist-get state-before :scroll-offset) 0)) + (result (ebox--scroll-region-by region-id delta magnitude)) + (state-after (ebox--scroll-get-state region-id)) + (offset-after (or (plist-get state-after :scroll-offset) + offset-before)) + (consumed (- offset-after offset-before))) + (cond + ((and (ebox--scroll-progress-p result) + (/= consumed 0)) + (ebox--scroll-redisplay-after-tick) + (let ((remaining (- pending consumed))) + (if (= remaining 0) + (ebox--smooth-scroll-stop region-id) + (plist-put entry :pending remaining) + (puthash region-id entry ebox--smooth-scroll-state-table)))) + ((eq result 'pending) + (ebox--smooth-scroll-pause-for-prefetch + region-id entry pending)) + (t + (ebox--smooth-scroll-stop region-id))))))))) + +(defun ebox--smooth-scroll-region-by (region-id delta) + "Animate wheel scroll REGION-ID by DELTA lines." + (let* ((existing (gethash region-id ebox--smooth-scroll-state-table)) + (entry (or existing (list :pending 0 :timer nil))) + (had-timer (timerp (plist-get entry :timer))) + (pending (ebox--smooth-scroll-clamp-pending + region-id + (+ (or (plist-get entry :pending) 0) delta)))) + (if (= pending 0) + (progn + (ebox--smooth-scroll-stop region-id) + nil) + ;; Enter the deferred-GC lease once per animation: entry insertion + ;; below pairs with the single schedule-restore in + ;; `ebox--smooth-scroll-stop'. Wheel events that only add distance + ;; to an active animation must not deepen the lease, or the depth + ;; never returns to zero and the raised GC thresholds leak for the + ;; rest of the session. + (unless existing + (ebox--deferred-render-gc-enter)) + (ebox--scroll-cancel-idle-prefetch region-id) + (plist-put entry :pending pending) + (puthash region-id entry ebox--smooth-scroll-state-table) + ;; Move the first tick immediately so the UI responds to the initial + ;; event. While a timer is already active, new wheel events only update + ;; pending distance; the timer coalesces them into frame-sized edits. + (unless had-timer + (ebox--smooth-scroll-tick region-id)) + (when-let ((entry (gethash region-id ebox--smooth-scroll-state-table))) + (unless (timerp (plist-get entry :timer)) + (plist-put + entry :timer + (run-at-time ebox-wheel-smooth-scroll-interval + ebox-wheel-smooth-scroll-interval + #'ebox--smooth-scroll-tick region-id)) + (puthash region-id entry ebox--smooth-scroll-state-table))) + t))) + +(defun ebox--smooth-scroll-clear-regions (region-ids) + "Stop smooth wheel scroll timers for REGION-IDS." + (dolist (region-id region-ids) + (ebox--smooth-scroll-stop region-id))) + +(defun ebox--scroll-by (delta fallback) + "Scroll box content by DELTA lines. Positive = down, negative = up. +FALLBACK is the Emacs scroll command to call when ebox cannot scroll." + (if (ebox--scroll-first-region-by + (ebox--scroll-region-ids-at-point) + delta) + t + (funcall fallback 1))) + +(defun ebox--scroll-page-lines (&optional arg) + "Return the number of content lines to move for a page scroll ARG." + (if arg + (prefix-numeric-value arg) + (max ebox-scroll-step + (- (window-body-height nil) next-screen-context-lines)))) + +(defun ebox--wheel-region-ids (event) + "Return ebox scroll candidate region ids under mouse wheel EVENT." + (when-let* ((start (ignore-errors (event-start event))) + (window (posn-window start)) + ((window-live-p window))) + (with-current-buffer (window-buffer window) + (let* ((position (posn-point start)) + (ids + (when (and (integer-or-marker-p position) + (< (point-min) (point-max))) + (let ((pos (if (markerp position) + (marker-position position) + position))) + (ebox--scroll-region-ids-at-pos + (min (max pos (point-min)) + (1- (point-max)))))))) + (or ids + (ebox--scroll-region-ids-in-buffer-outer-first)))))) + +(defun ebox--wheel-region-id (event) + "Return the innermost ebox region id under mouse wheel EVENT, or nil." + (car (ebox--wheel-region-ids event))) + +(defun ebox--wheel-scroll (event delta &optional arg smooth) + "Scroll an ebox region under EVENT by DELTA, or delegate to `mwheel-scroll'. +When SMOOTH is non-nil, animate the scroll over short line steps." + (let* ((region-ids (ebox--wheel-region-ids event)) + (region-id (and smooth + ebox-wheel-smooth-scroll + (ebox--first-scrollable-region region-ids delta)))) + (cond + (region-id + (ebox--smooth-scroll-region-by region-id delta)) + ((ebox--scroll-first-region-by region-ids delta) + t) + (t + (mwheel-scroll event arg))))) + +(defun ebox-wheel-scroll-down (event &optional arg) + "Handle mouse wheel down EVENT in an ebox buffer. +Scrollable ebox content under the mouse consumes the event; otherwise delegate +to Emacs' normal `mwheel-scroll' so preview buffers keep native wheel speed." + (interactive "e\nP") + (ebox--wheel-scroll event ebox-wheel-scroll-step arg t)) + +(defun ebox-wheel-scroll-up (event &optional arg) + "Handle mouse wheel up EVENT in an ebox buffer. +Scrollable ebox content under the mouse consumes the event; otherwise delegate +to Emacs' normal `mwheel-scroll' so preview buffers keep native wheel speed." + (interactive "e\nP") + (ebox--wheel-scroll event (- ebox-wheel-scroll-step) arg t)) + +;;;###autoload +(defun ebox-scroll-down (&optional n) + "Scroll box content down by N lines. +When called from Lisp with nil N, use `ebox-scroll-step'." + (interactive "p") + (ebox--scroll-by (or n ebox-scroll-step) #'scroll-up)) + +;;;###autoload +(defun ebox-scroll-up (&optional n) + "Scroll box content up by N lines. +When called from Lisp with nil N, use `ebox-scroll-step'." + (interactive "p") + (ebox--scroll-by (- (or n ebox-scroll-step)) #'scroll-down)) + +;;;###autoload +(defun ebox-scroll-page-down (&optional arg) + "Scroll ebox content down by a page. +With prefix ARG, scroll by that many content lines." + (interactive "P") + (let ((delta (ebox--scroll-page-lines arg))) + (ebox--scroll-by + delta + (lambda (&optional _n) + (scroll-up-command arg))))) + +;;;###autoload +(defun ebox-scroll-page-up (&optional arg) + "Scroll ebox content up by a page. +With prefix ARG, scroll by that many content lines." + (interactive "P") + (let ((delta (- (ebox--scroll-page-lines arg)))) + (ebox--scroll-by + delta + (lambda (&optional _n) + (scroll-down-command arg))))) + +(defun ebox--scroll-replace-content-marker (state region-id idx string) + "Replace visible scroll line IDX for REGION-ID using STATE markers." + (when-let* ((markers (plist-get state :content-span-markers)) + ((< idx (length markers))) + (span (aref markers idx)) + ((consp span)) + (start-marker (car span)) + (end-marker (cdr span)) + ((markerp start-marker)) + ((markerp end-marker)) + (buffer (marker-buffer start-marker)) + ((eq buffer (marker-buffer end-marker))) + (start (marker-position start-marker)) + (end (marker-position end-marker))) + (with-current-buffer buffer + (when (and (<= start end) + (or + (and + (eql (get-text-property start 'ebox-content) region-id) + (eql (get-text-property start 'ebox-content-idx) idx)) + (eql (get-text-property start 'ebox-content-owner) region-id) + (member region-id + (get-text-property start 'ebox-content-owners)))) + (let ((inhibit-read-only t) + (props (copy-sequence (text-properties-at start)))) + (save-excursion + (let ((replacement + (ebox--scroll-propertize-visible-line + string region-id idx props))) + ;; Let Emacs retain unchanged buffer intervals and markers, then + ;; copy the replacement's exact per-character properties. A + ;; delete/insert of the whole display-bearing span can make the + ;; first GUI redisplay publish a torn flex row even when the + ;; resulting buffer string is otherwise correct. + (replace-region-contents start end (lambda () replacement)) + (ebox--copy-string-text-properties-to-buffer replacement start) + (goto-char (+ start (length replacement)))) + (set-marker start-marker start) + (set-marker end-marker (point))) + (cons start (marker-position end-marker))))))) + +(defun ebox--scroll-content-window (state line-count) + "Return marker-backed visible scroll window for STATE and LINE-COUNT. +The return value is (BUFFER START END ENTRIES). Each entry is +(IDX START-MARKER END-MARKER PROPS)." + (when-let ((markers (plist-get state :content-span-markers))) + (catch 'invalid + (let (buffer window-start window-end previous-end entries) + (dotimes (idx line-count) + (unless (< idx (length markers)) + (throw 'invalid nil)) + (let* ((span (aref markers idx)) + (start-marker (and (consp span) (car span))) + (end-marker (and (consp span) (cdr span)))) + (unless (and (markerp start-marker) + (markerp end-marker) + (marker-buffer start-marker) + (eq (marker-buffer start-marker) + (marker-buffer end-marker))) + (throw 'invalid nil)) + (let ((start (marker-position start-marker)) + (end (marker-position end-marker)) + (span-buffer (marker-buffer start-marker))) + (unless (and (<= start end) + (or (not buffer) (eq buffer span-buffer)) + (or (not previous-end) + (and (= start (1+ previous-end)) + (with-current-buffer span-buffer + (eq (char-after previous-end) ?\n))))) + (throw 'invalid nil)) + (unless (and (< start end) + (with-current-buffer span-buffer + (let ((region-id (plist-get state :region-id))) + (or (and (equal (get-text-property + start 'ebox-content) + region-id) + (eql (get-text-property + start 'ebox-content-idx) + idx)) + (equal (get-text-property + start 'ebox-content-owner) + region-id) + (member region-id + (get-text-property + start 'ebox-content-owners)))))) + (throw 'invalid nil)) + (setq buffer span-buffer) + (unless window-start + (setq window-start start)) + (setq window-end end + previous-end end) + (push (list idx start-marker end-marker + (with-current-buffer span-buffer + (copy-sequence (text-properties-at start)))) + entries)))) + (and buffer window-start window-end + (list buffer window-start window-end (nreverse entries))))))) + +(defun ebox--scroll-propertize-visible-line (line region-id idx props) + "Return LINE with visible scroll marker PROPS for REGION-ID and IDX." + (let ((line (copy-sequence line))) + (if (text-property-not-all 0 (length line) 'ebox-content nil line) + (ebox--add-content-owner line region-id) + (let ((props (copy-sequence props))) + ;; LINE already owns its formatted `display' runs. In particular, + ;; centered content uses a width-bearing display space only at each + ;; edge. Copying the old first character's display value over the + ;; entire replacement line makes every glyph consume that width and + ;; visibly tears apart neighboring flex boxes until a later rerender. + (setq props (ebox--plist-remove props 'display)) + (setq props (plist-put props 'ebox-content region-id)) + (setq props (plist-put props 'ebox-content-idx idx)) + (when (> (length line) 0) + (add-text-properties 0 (length line) props line)) + line)))) + +(defun ebox--scroll-replace-content-window (state region-id new-lines) + "Replace all visible scroll NEW-LINES for REGION-ID in one buffer edit." + (let ((state (plist-put (copy-sequence state) :region-id region-id))) + (pcase-let ((`(,buffer ,window-start ,window-end ,entries) + (ebox--scroll-content-window state (length new-lines)))) + (when (and buffer entries) + (with-current-buffer buffer + (let ((inhibit-read-only t) + (line-count (length new-lines)) + spans + marker-updates + replacement-lines + (offset 0)) + (save-excursion + (cl-loop for line in new-lines + for entry in entries + for line-idx from 0 + do + (pcase-let ((`(,idx ,start-marker ,end-marker ,props) + entry)) + (let* ((replacement-line + (ebox--scroll-propertize-visible-line + line region-id idx props)) + (line-start (+ window-start offset)) + (line-end (+ line-start + (length replacement-line)))) + (push replacement-line replacement-lines) + (push (list start-marker end-marker + line-start line-end) + marker-updates) + (push (cons line-start line-end) spans) + (setq offset + (+ offset (length replacement-line) + (if (< line-idx (1- line-count)) + 1 + 0)))))) + (let ((replacement + (ebox-lines-join (nreverse replacement-lines)))) + (replace-region-contents + window-start window-end (lambda () replacement)) + (ebox--copy-string-text-properties-to-buffer + replacement window-start)) + (dolist (update marker-updates) + (pcase-let ((`(,start-marker ,end-marker ,start ,end) + update)) + (set-marker start-marker start) + (set-marker end-marker end)))) + (let ((spans (nreverse spans))) + (ebox--scroll-state-clear-window-region-id-set + region-id (or (ebox--scroll-get-state region-id) state) + buffer) + spans))))))) + +(defun ebox--scroll-chrome-free-rendered-window-p (box) + "Return non-nil when BOX's scroll viewport has no dynamic chrome rows." + (and (= (floor (ebox-get box :padding-top-height)) 0) + (= (floor (ebox-get box :padding-bottom-height)) 0) + (not (ebox-get box :border-top-p)) + (not (ebox-get box :border-bottom-p)) + (= (floor (ebox-get box :margin-top-height)) 0) + (= (floor (ebox-get box :margin-bottom-height)) 0))) + +(defun ebox--scroll-state-window-extents (region-id state) + "Return marker-backed visible viewport extents for chrome-free scroll STATE. +The scroll state may own a lightweight rendered-window boundary marker pair. +For a chrome-free viewport those markers are the rendered box window, so they +are the most accurate source after viewport resize invalidates global extents. +Older content-span markers are kept as a fallback for freshly rendered buffers +and dynamic update marker refreshes." + (when-let* ((box (plist-get state :box)) + ((ebox--scroll-chrome-free-rendered-window-p box)) + (content-height (plist-get state :content-height)) + ((> content-height 0))) + (let* ((start-marker (plist-get state :rendered-window-start-marker)) + (end-marker (plist-get state :rendered-window-end-marker)) + (line-count (plist-get state :rendered-window-line-count)) + (buffer (and (markerp start-marker) + (marker-buffer start-marker)))) + (or + (when (and buffer + (markerp end-marker) + (eq buffer (marker-buffer end-marker)) + (integerp line-count) + (= line-count content-height) + (marker-position start-marker) + (marker-position end-marker) + (< (marker-position start-marker) + (marker-position end-marker))) + (cons start-marker end-marker)) + (when-let ((markers (plist-get state :content-span-markers))) + (let (buffer start end) + (dolist (span (ebox--scroll-content-marker-spans markers)) + (let* ((start-marker (car-safe span)) + (end-marker (cdr-safe span)) + (span-buffer (and (markerp start-marker) + (marker-buffer start-marker))) + (span-start (and (markerp start-marker) + (marker-position start-marker))) + (span-end (and (markerp end-marker) + (marker-position end-marker)))) + (when (and (markerp start-marker) + (markerp end-marker) + span-buffer + (eq span-buffer (marker-buffer end-marker)) + span-start + span-end + (< span-start span-end) + (or (not buffer) (eq buffer span-buffer))) + (setq buffer span-buffer) + (setq start (if start (min start span-start) span-start)) + (setq end (if end (max end span-end) span-end))))) + (when (and buffer start end + (= (with-current-buffer buffer + (count-lines start end)) + content-height)) + (when-let ((stored + (ebox--scroll-store-rendered-window-extents + buffer region-id state start end content-height))) + (cons (plist-get stored :rendered-window-start-marker) + (plist-get stored + :rendered-window-end-marker)))))))))) + +(defun ebox--scroll-rendered-window-slide-preferred-p () + "Return non-nil when rendered scroll windows should use line sliding. +Line sliding keeps scroll edits proportional to the consumed delta instead of +the viewport height. GUI and terminal frames both need this bounded edit path; +expensive hidden content should be prefetched asynchronously rather than paid +inside the scroll event." + t) + +(defun ebox--scroll-capture-window-states (buffer) + "Return live window positions for BUFFER." + (mapcar (lambda (window) + (list :window window + :start (window-start window) + :point (window-point window) + :hscroll (window-hscroll window))) + (get-buffer-window-list buffer nil t))) + +(defun ebox--scroll-lines-with-live-wrapper-context + (lines position region-id) + "Return cached LINES with wrapper context recovered at POSITION. +The live owner stack is inner-to-outer. Only owners after REGION-ID are outer +wrappers; copying an outgoing child's owners onto an entering line would make +section boundaries semantically stale. Each line is decorated independently +because complete box rendering leaves the separators inserted by +`ebox-lines-join' unpropertized." + (let* ((owners + (or (get-text-property position 'ebox-content-owners) + (when-let ((owner + (get-text-property position 'ebox-content-owner))) + (list owner)))) + (owner-tail (member region-id owners))) + (when owner-tail + (mapcar + (lambda (line) + (let ((result + (ebox--string-with-preserved-wrapper-context + line (cdr owner-tail) + (ebox--string-content-owner-set line)))) + (add-text-properties + 0 (length result) (list 'ebox-scroll-window region-id) result) + result)) + lines)))) + +(defun ebox--scroll-restore-window-states (buffer window-states) + "Restore WINDOW-STATES for live windows still showing BUFFER." + (dolist (window-state window-states) + (let ((window (plist-get window-state :window))) + (when (and (window-live-p window) + (eq (window-buffer window) buffer)) + (set-window-start + window + (min (plist-get window-state :start) (point-max)) + t) + (set-window-point + window + (min (plist-get window-state :point) (point-max))) + (set-window-hscroll + window (plist-get window-state :hscroll)))))) + +(defun ebox--scroll-replace-rendered-content-window-slide + (state region-id old-offset scroll-offset rendered-content-lines + content-height start-marker end-marker buffer) + "Slide a rendered scroll viewport by mutating only entering/leaving lines." + (when-let* (((integerp old-offset)) + (delta (- scroll-offset old-offset)) + ((/= delta 0)) + (step (abs delta)) + ((< step content-height)) + (start (marker-position start-marker)) + (end (marker-position end-marker))) + (let* ((line-count (length rendered-content-lines)) + (entering-lines + (if (> delta 0) + (and (<= (+ scroll-offset content-height) line-count) + (seq-subseq rendered-content-lines + (+ old-offset content-height) + (+ scroll-offset content-height))) + (and (<= old-offset line-count) + (seq-subseq rendered-content-lines + scroll-offset old-offset))))) + (when (and entering-lines (= (length entering-lines) step)) + (with-current-buffer buffer + (let* ((old-height (count-lines start end)) + (window-states (ebox--scroll-capture-window-states buffer)) + (inhibit-read-only t)) + (when (= old-height content-height) + (when-let ((entering-lines + (ebox--scroll-lines-with-live-wrapper-context + entering-lines start region-id))) + (ebox--scroll-clear-content-markers state) + (setq state (plist-put state :content-span-markers nil)) + (setq state + (plist-put state :content-window-region-id-set nil)) + (setq state (plist-put state :buffer buffer)) + (puthash region-id state ebox--scroll-global-state) + (save-excursion + (if (> delta 0) + (progn + (goto-char start) + (forward-line step) + (delete-region start (point)) + (goto-char (marker-position end-marker)) + (insert "\n") + (insert (ebox-lines-join entering-lines))) + (goto-char start) + (forward-line (- content-height step)) + (let ((delete-start (if (> (point) start) + (1- (point)) + (point)))) + (delete-region + delete-start (marker-position end-marker))) + (goto-char start) + (insert (ebox-lines-join entering-lines)) + (insert "\n"))) + (let* ((spans (ebox--scroll-line-spans-in-window + start content-height)) + (new-end (if spans (cdr (car (last spans))) start))) + (ebox--scroll-store-rendered-window-extents + buffer region-id state start new-end content-height) + (ebox--scroll-restore-window-states buffer window-states) + spans))))))))) + +(defun ebox--scroll-replace-rendered-content-window + (state region-id &optional old-offset) + "Replace chrome-free rendered scroll viewport for REGION-ID from STATE." + (let ((marker-extents (ebox--scroll-state-window-extents region-id state))) + (when-let* ((rendered-content-lines + (plist-get state :rendered-content-lines)) + (content-height (plist-get state :content-height)) + (box (plist-get state :box)) + ((ebox--scroll-chrome-free-rendered-window-p box)) + (extents (or marker-extents + (ebox--live-box-extents region-id))) + (start-marker (car extents)) + (end-marker (cdr extents)) + (buffer (marker-buffer start-marker)) + ((eq buffer (marker-buffer end-marker)))) + (let* ((max-offset (max 0 (- (length rendered-content-lines) + content-height))) + (scroll-offset (max 0 (min max-offset + (or (plist-get state :scroll-offset) + 0)))) + (visible-lines + (seq-subseq rendered-content-lines + scroll-offset + (min (+ scroll-offset content-height) + (length rendered-content-lines))))) + (or (and marker-extents + (ebox--scroll-rendered-window-slide-preferred-p) + (ebox--scroll-replace-rendered-content-window-slide + state region-id old-offset scroll-offset rendered-content-lines + content-height start-marker end-marker buffer)) + (with-current-buffer buffer + (let* ((start (marker-position start-marker)) + (rendered + (when-let ((lines + (ebox--scroll-lines-with-live-wrapper-context + visible-lines start region-id))) + (ebox-lines-join lines))) + (end (marker-position end-marker)) + (old-height (count-lines start end)) + (new-height (length visible-lines)) + (window-states + (ebox--scroll-capture-window-states buffer)) + (inhibit-read-only t)) + (when (and rendered (= old-height new-height)) + (ebox--scroll-clear-content-markers state) + (setq state (plist-put state :content-span-markers nil)) + (setq state + (plist-put state :content-window-region-id-set nil)) + (setq state (plist-put state :buffer buffer)) + (puthash region-id state ebox--scroll-global-state) + (save-excursion + (goto-char start) + (delete-region start end) + (insert rendered)) + (let ((spans (ebox--scroll-line-spans-in-window + start new-height))) + (ebox--scroll-store-rendered-window-extents + buffer region-id state start + (if spans (cdr (car (last spans))) start) + new-height) + (ebox--scroll-restore-window-states buffer window-states) + spans))))))))) + +(defun ebox--scroll-ancestor-horizontal-border-p (buffer region-id state) + "Return non-nil when REGION-ID has a bordered ancestor in BUFFER. +Horizontal ancestor borders are painted by viewport slot rather than by the +cached child line. Moving cached lines directly would move or drop that +position-dependent paint." + (when-let* (((buffer-live-p buffer)) + (extents (or (ebox--scroll-state-window-extents region-id state) + (ebox--live-box-extents region-id))) + (start-marker (car extents)) + ((markerp start-marker)) + ((eq (marker-buffer start-marker) buffer)) + (position (marker-position start-marker)) + (owners + (with-current-buffer buffer + (or (get-text-property position 'ebox-content-owners) + (when-let ((owner + (get-text-property + position 'ebox-content-owner))) + (list owner))))) + (outer-owners (cdr (member region-id owners)))) + (cl-some + (lambda (owner) + (when-let ((box (gethash owner ebox--region-box-table))) + (or (ebox-get box :border-top-p) + (ebox-get box :border-bottom-p)))) + outer-owners))) + +(defun ebox--scroll-rerender-root-window (buffer region-id) + "Rerender BUFFER and return REGION-ID's freshly published line spans." + (when (and (buffer-live-p buffer) + (ebox-buffer-apply-root-rerender buffer)) + (when-let* ((state (ebox--scroll-get-state region-id)) + (height (plist-get state :content-height)) + (extents (ebox--scroll-state-window-extents region-id state)) + (start-marker (car extents)) + ((eq (marker-buffer start-marker) buffer))) + (with-current-buffer buffer + (ebox--scroll-line-spans-in-window + (marker-position start-marker) height))))) + +(defun ebox--scroll-update-content (region-id new-lines &optional old-offset) + "Update visible content lines for scroll REGION-ID with NEW-LINES. +This is a dedicated fast path for overflow='scroll' boxes whose outer box +geometry stays unchanged while only the visible window contents move." + (let* ((state (ebox--scroll-get-state region-id)) + (buffer (or (and state (ebox--scroll-state-buffer state)) + (current-buffer))) + (result + (or (and state + (ebox--scroll-ancestor-horizontal-border-p + buffer region-id state) + (ebox--scroll-rerender-root-window buffer region-id)) + (and state + (ebox--scroll-replace-rendered-content-window + state region-id old-offset)) + (and state + (ebox--scroll-replace-content-window + state region-id new-lines)) + (when (or (not state) + (>= (length (ebox--scroll-content-marker-spans + (plist-get state :content-span-markers))) + (length new-lines))) + (cl-loop for line in new-lines + for idx from 0 + collect + (or (and state + (ebox--scroll-replace-content-marker + state region-id idx line)) + (ebox--region-replace-line + region-id idx line))))))) + (when (and result (buffer-live-p buffer)) + ;; Scroll replacement changes which runtime regions occupy otherwise + ;; stable marker coordinates. Discard only the derived indexes here; + ;; role spans and snapshot details are reconstructed lazily on demand. + (ebox--set-buffer-region-role-span-table buffer nil) + (ebox--invalidate-buffer-layout-snapshot-details buffer)) + result)) + +(defun ebox--scroll-marker-spans-compatible-p (state new-lines) + "Return non-nil when NEW-LINES can replace STATE markers without shifting text." + (when-let ((markers (plist-get state :content-span-markers))) + (cl-loop for line in new-lines + for idx from 0 + always + (and (< idx (length markers)) + (when-let* ((span (aref markers idx)) + ((consp span)) + (start-marker (car span)) + (end-marker (cdr span)) + ((markerp start-marker)) + ((markerp end-marker)) + ((eq (marker-buffer start-marker) + (marker-buffer end-marker))) + (start (marker-position start-marker)) + (end (marker-position end-marker))) + (= (- end start) (length line))))))) + +(defun ebox--scroll-fast-path-report + (buffer region-id node-id new-spans changed-keys) + "Return an update report for a scroll-offset fast-path patch." + (when (and buffer node-id new-spans) + (ebox--update-report + region-id 'span-patch + :dirty-count 1 + :patch-count 1 + :patch-ops '(span-patch) + :owner-id node-id + :owner-ids (list node-id) + :owner-type 'box + :span-count (length new-spans) + :scroll-fast-path t + :dirty-kinds '(geometry) + :dirty-keys changed-keys))) + +(defun ebox--scroll-transaction-report + (buffer region-id node-id span-count changed-keys) + "Return a report for an atomically synchronized scroll transaction." + (when (and buffer node-id) + (ebox--update-report + region-id 'span-patch + :dirty-count 1 + :patch-count 1 + :patch-ops '(span-patch) + :owner-id node-id + :owner-ids (list node-id) + :owner-type 'box + :span-count (or span-count 0) + :scroll-state-transaction t + :scroll-state-sync t + :scroll-state-sync-count 1 + :dirty-kinds '(geometry) + :dirty-keys changed-keys))) + +(defun ebox--scroll-offset-state-settled-p (region-id offset) + "Return non-nil when REGION-ID atomically exposes OFFSET." + (when-let ((state (ebox--scroll-get-state region-id))) + (and (= (or (plist-get state :scroll-offset) 0) offset) + (not (plist-get state :lazy-scroll-prefix-dirty)) + (not (plist-get state :lazy-scroll-window-refresh-required))))) + +(defun ebox--try-scroll-offset-fast-path + (buffer region-id changed-keys) + "Patch REGION-ID for a scroll-offset-only update in BUFFER. +Return an update report only after its runtime, cache, and window agree." + (when (and buffer + (equal changed-keys '(:scroll-offset))) + (when-let ((state (ebox--scroll-get-state region-id))) + (setq state (ebox--scroll-state-materialize-lines region-id state)) + (when-let* ((box (plist-get state :box)) + ((eq (ebox-get box :overflow) 'scroll)) + (content-lines (plist-get state :content-lines)) + (content-height (plist-get state :content-height))) + (let* ((max-offset (max 0 (- (length content-lines) content-height))) + (scroll-offset (max 0 (min max-offset + (or (ebox-get box :scroll-offset) + 0)))) + (old-offset (or (plist-get state :scroll-offset) 0)) + (visible-lines + (seq-subseq content-lines + scroll-offset + (min (+ scroll-offset content-height) + (length content-lines)))) + (node-id (ebox--buffer-region-render-owner-node-id + buffer region-id)) + (direct-eligible-p + (or (ebox--scroll-marker-spans-compatible-p + state visible-lines) + (and (null (plist-get state :content-span-markers)) + (= (length visible-lines) content-height) + (plist-get state :rendered-content-lines) + (ebox--scroll-state-window-extents + region-id state)))) + direct-report) + (when direct-eligible-p + (let ((old-state (copy-sequence state))) + (ebox-put box :scroll-offset scroll-offset) + (setq state (plist-put state :scroll-offset scroll-offset)) + (setq state (plist-put state :box box)) + ;; `ebox--scroll-update-content' reads the shared state. Publish + ;; the staged offset before replacing the window, then retain it + ;; only when every visible span was replaced successfully. + (puthash region-id state ebox--scroll-global-state) + (with-current-buffer buffer + (let ((new-spans + (delq nil + (ebox--scroll-update-content + region-id visible-lines old-offset)))) + (if new-spans + (progn + (when-let ((current-state + (ebox--scroll-get-state region-id))) + (puthash + region-id + (ebox--plist-remove + current-state + :lazy-scroll-window-refresh-required) + ebox--scroll-global-state)) + (when (ebox--scroll-offset-state-settled-p + region-id scroll-offset) + (setq direct-report + (ebox--scroll-fast-path-report + buffer region-id node-id new-spans + changed-keys)))) + (puthash region-id old-state + ebox--scroll-global-state))))) + (or direct-report + ;; Marker slots can legitimately be incompatible after a large + ;; jump or viewport reflow. Reuse the ordinary scroll engine so + ;; it commits the offset and visible window together instead of + ;; letting the generic cached-line patch split those states. + (let ((delta (- scroll-offset old-offset))) + (ebox--scroll-region-by region-id delta) + (if (ebox--scroll-offset-state-settled-p + region-id scroll-offset) + (ebox--scroll-transaction-report + buffer region-id node-id content-height changed-keys) + ;; `ebox--scroll-region-by' rolls the box back on failure; + ;; restore the requested runtime value for the conservative + ;; planner that follows this failed transaction. + (ebox-put box :scroll-offset scroll-offset) + nil))))))))) + + +;;; ============================================================ +;;; Region Update API +;;; ============================================================ + +(defconst ebox-region-types + '((content . ebox-content) + (content-owner . ebox-content-owner) + (pt . ebox-pt) (pb . ebox-pb) (pl . ebox-pl) (pr . ebox-pr) + (mt . ebox-mt) (mb . ebox-mb) (ml . ebox-ml) (mr . ebox-mr) + (bt . ebox-bt) (bb . ebox-bb) (bl . ebox-bl) (br . ebox-br)) + "Mapping from region type symbol to text property name.") + +(defsubst ebox--region-property (type) + "Get text property name for region TYPE." + (or (alist-get type ebox-region-types) + (error "Unknown region type: %S" type))) + +(defun ebox--region-id-at-pos (pos) + "Return the owning ebox region-id at POS, if any." + (cl-loop for (_type . prop) in ebox-region-types + for region-id = (get-text-property pos prop) + when region-id return region-id)) + +(defun ebox--set-box-extents (region-id start end) + "Record REGION-ID extents from START to END using markers." + (let* ((existing (gethash region-id ebox--box-extents)) + (start-marker (or (car-safe existing) (make-marker))) + (end-marker (or (cdr-safe existing) (make-marker)))) + (set-marker-insertion-type start-marker nil) + (set-marker-insertion-type end-marker t) + (set-marker start-marker start) + (set-marker end-marker end) + (puthash region-id (cons start-marker end-marker) ebox--box-extents))) + +(defun ebox--clear-buffer-extents (&optional buffer preserve-template) + "Remove cached extents whose markers belong to BUFFER. +Defaults to the current buffer. +When PRESERVE-TEMPLATE is non-nil, keep BUFFER's prepared numeric extent +template while stale live marker extents are removed." + (let ((buffer (or buffer (current-buffer))) + stale-ids) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (unless preserve-template + (setq-local ebox--box-extent-template nil)))) + (maphash + (lambda (region-id extents) + (when (eq (marker-buffer (car extents)) buffer) + (push region-id stale-ids))) + ebox--box-extents) + (dolist (region-id stale-ids) + (when-let ((extents (gethash region-id ebox--box-extents))) + (set-marker (car extents) nil) + (set-marker (cdr extents) nil)) + (remhash region-id ebox--box-extents)))) + +(defun ebox--install-box-extent-template (buffer template start) + "Install prepared numeric box extent TEMPLATE in BUFFER at root START." + (when (and (buffer-live-p buffer) (hash-table-p template)) + (with-current-buffer buffer + (setq-local + ebox--box-extent-template + (if (= start (point-min)) + template + (let ((translated (make-hash-table :test 'equal)) + (delta (- start (point-min)))) + (maphash + (lambda (region-id span) + (puthash region-id + (cons (+ delta (car span)) (+ delta (cdr span))) + translated)) + template) + translated)))))) + +(defun ebox--materialize-box-extent-template (buffer) + "Promote BUFFER's prepared extent template to live marker pairs." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (when (hash-table-p ebox--box-extent-template) + (maphash + (lambda (region-id span) + (ebox--set-box-extents region-id (car span) (cdr span))) + ebox--box-extent-template) + (setq-local ebox--box-extent-template nil) + t)))) + +(defun ebox--clear-box-extents-for-region-ids (region-ids) + "Remove cached box extents for REGION-IDS." + (dolist (region-id region-ids) + (when-let ((extents (gethash region-id ebox--box-extents))) + (set-marker (car extents) nil) + (set-marker (cdr extents) nil)) + (remhash region-id ebox--box-extents))) + +(defun ebox--refresh-box-extents-for-region-ids-in-spans (region-ids spans) + "Refresh box extents for REGION-IDS inside SPANS." + (ebox--clear-box-extents-for-region-ids region-ids) + (when spans + (let ((start (apply #'min (mapcar #'car spans))) + (end (apply #'max (mapcar #'cdr spans))) + (region-id-allow-set (make-hash-table :test 'equal))) + (dolist (region-id region-ids) + (puthash region-id t region-id-allow-set)) + (when (< start end) + (ebox--register-box-extents-in-range + start end region-id-allow-set))))) + +(defun ebox--span-bounds (span) + "Return numeric bounds for SPAN." + (cons (if (markerp (car span)) + (marker-position (car span)) + (car span)) + (if (markerp (cdr span)) + (marker-position (cdr span)) + (cdr span)))) + +(defun ebox--spans-bounding-range (spans) + "Return a numeric bounding range for SPANS." + (when spans + (let* ((first (ebox--span-bounds (car spans))) + (start (car first)) + (end (cdr first))) + (dolist (span (cdr spans)) + (pcase-let ((`(,span-start . ,span-end) + (ebox--span-bounds span))) + (setq start (min start span-start)) + (setq end (max end span-end)))) + (cons start end)))) + +(defun ebox--box-extents-contained-in-spans-p (region-id spans) + "Return non-nil when REGION-ID's live extents are inside SPANS." + (when-let* ((extents (gethash region-id ebox--box-extents)) + ((markerp (car extents))) + ((markerp (cdr extents))) + (range (ebox--spans-bounding-range spans))) + (let ((start (marker-position (car extents))) + (end (marker-position (cdr extents)))) + (and start end + (<= (car range) start) + (<= end (cdr range)))))) + +(defun ebox--box-extent-refresh-region-ids + (region-ids spans &optional active-segments) + "Return REGION-IDS whose cached extents can be refreshed in SPANS." + (let ((root-id (ebox--buffer-root-node-id (current-buffer)))) + (cl-loop for region-id in region-ids + for active-region-p = (and active-segments + (gethash region-id active-segments)) + for contained-p = (ebox--box-extents-contained-in-spans-p + region-id spans) + when (or contained-p + (and active-region-p + (not (equal region-id root-id)))) + collect region-id))) + +(defun ebox--refresh-box-extents-from-segments (segments-by-region) + "Refresh box extents from active SEGMENTS-BY-REGION." + (maphash + (lambda (region-id region-spans) + (when region-spans + (let* ((ordered + (sort region-spans + (lambda (left right) + (if (= (car left) (car right)) + (< (cdr left) (cdr right)) + (< (car left) (car right)))))) + (range (copy-tree (pop ordered))) + (interleaved + (ebox--foreign-region-in-range-p + (car range) (cdr range) region-id))) + (dolist (segment ordered) + (unless interleaved + (cond + ((ebox--foreign-region-in-range-p + (car segment) (cdr segment) region-id) + (setq interleaved t)) + ((<= (car segment) (cdr range)) + (setcdr range (max (cdr range) (cdr segment)))) + ((ebox--foreign-region-in-range-p + (cdr range) (car segment) region-id) + (setq interleaved t)) + (t + (setcdr range (cdr segment)))))) + (unless interleaved + (ebox--set-box-extents region-id (car range) (cdr range)))))) + segments-by-region)) + +(defun ebox--active-extent-segments-for-region-ids + (active-segments region-ids) + "Return ACTIVE-SEGMENTS filtered to REGION-IDS." + (let ((region-set (ebox--region-id-set region-ids)) + (filtered (make-hash-table :test 'equal))) + (maphash + (lambda (region-id segments) + (when (gethash region-id region-set) + (puthash region-id segments filtered))) + active-segments) + filtered)) + +(defun ebox--refresh-box-extents-from-role-spans + (region-ids spans &optional active-segments) + "Refresh REGION-IDS extents in SPANS from the current role-span index. +The role index has already scanned the replacement text. Reusing it avoids a +second per-character pass over every Ebox property. Wrapper-only ownership is +merged from `ebox-content-owners'. Fall back to the ordinary scoped scanner +when no role index is available." + (if (not (ebox-buffer--region-role-span-table)) + (ebox--refresh-box-extents-for-region-ids-in-spans region-ids spans) + (let* ((refresh-region-ids + (ebox--box-extent-refresh-region-ids + region-ids spans active-segments)) + (region-set (ebox--region-id-set refresh-region-ids))) + (ebox--clear-box-extents-for-region-ids refresh-region-ids) + (when spans + (let* ((range (ebox--spans-bounding-range spans)) + (start (car range)) + (end (cdr range)) + (segments-by-region (make-hash-table :test 'equal)) + (table (ebox-buffer--region-role-span-table))) + (if active-segments + (ebox--refresh-box-extents-from-segments + (ebox--active-extent-segments-for-region-ids + active-segments refresh-region-ids)) + (cl-labels + ((record (region-id segment-start segment-end) + (when (and (gethash region-id region-set) + (< segment-start segment-end) + (< segment-start end) + (> segment-end start)) + (puthash region-id + (cons (cons (max start segment-start) + (min end segment-end)) + (gethash region-id segments-by-region)) + segments-by-region)))) + (dolist (region-id region-ids) + (dolist (entry ebox-region-types) + (dolist (span + (gethash (ebox-buffer--region-role-key + region-id (car entry)) + table)) + (let ((span-start + (if (markerp (car span)) + (marker-position (car span)) + (car span))) + (span-end + (if (markerp (cdr span)) + (marker-position (cdr span)) + (cdr span)))) + (when (and span-start span-end) + (record region-id span-start span-end)))))) + (let ((pos start)) + (while (< pos end) + (let* ((owners (get-text-property + pos 'ebox-content-owners)) + (next (or (next-single-property-change + pos 'ebox-content-owners nil end) + end))) + (dolist (region-id owners) + (record region-id pos next)) + (setq pos (max next (1+ pos))))))) + (ebox--refresh-box-extents-from-segments + segments-by-region))))))) + +(defun ebox--clear-region-runtime-caches (region-ids) + "Remove region-scoped runtime cache entries for REGION-IDS." + (ebox--smooth-scroll-clear-regions region-ids) + (dolist (region-id region-ids) + (remhash region-id ebox--region-box-table) + (ebox--scroll-clear-state region-id))) + +(defun ebox--clear-buffer-runtime-state (&optional buffer) + "Remove all runtime caches owned by BUFFER. +Defaults to the current buffer." + (let ((buffer (or buffer (current-buffer)))) + (ebox--cancel-buffer-runtime-prewarm buffer) + (when-let ((root (ebox--buffer-root-node buffer))) + (ebox--clear-region-runtime-caches + (ebox--node-all-region-ids root))) + (ebox--clear-buffer-extents buffer) + (ebox--clear-buffer-render-state buffer))) + +(defun ebox--cleanup-current-buffer () + "Remove Ebox runtime state owned by the current buffer." + (ebox--clear-buffer-runtime-state (current-buffer))) + +(defun ebox--foreign-region-in-range-p (start end region-id) + "Return non-nil if START..END contains another ebox region." + (let ((pos start) + found) + (while (and (< pos end) (not found)) + (let ((next (or (next-property-change pos nil end) end))) + (save-excursion + (goto-char pos) + (skip-chars-forward "\n" next) + (when (< (point) next) + (let ((owners (ebox--pos-region-ids (point))) + (owner-stack + (get-text-property (point) 'ebox-content-owners))) + (when (cl-some (lambda (owner) + (and (not (equal owner region-id)) + (not (member region-id owner-stack)))) + owners) + (setq found t))))) + (setq pos next))) + found)) + +(defun ebox--region-property-segments-in-range (start end) + "Return sorted ebox region property segments between START and END." + (let (segments) + (let ((pos start)) + (while (< pos end) + (let* ((owners (get-text-property pos 'ebox-content-owners)) + (next (or (next-single-property-change + pos 'ebox-content-owners nil end) + end))) + (dolist (region-id owners) + (push (list region-id pos next) segments)) + (setq pos (max next (1+ pos)))))) + (dolist (entry ebox-region-types) + (let ((prop (cdr entry)) + (pos start)) + (while (< pos end) + (let* ((region-id (get-text-property pos prop)) + (next (or (next-single-property-change pos prop nil end) + end))) + (when region-id + (push (list region-id pos next) segments)) + (setq pos (max next (1+ pos))))))) + (sort segments + (lambda (left right) + (if (= (nth 1 left) (nth 1 right)) + (< (nth 2 left) (nth 2 right)) + (< (nth 1 left) (nth 1 right))))))) + +(defconst ebox--box-extents-segment-scan-min-chars 2048 + "Minimum span size before box extent refresh uses segment scanning.") + +(defun ebox--register-box-extents-by-position-scan + (start end &optional region-id-allow-set) + "Cache extents only for boxes that occupy one safe buffer slice. +Gaps containing only separators, such as newlines, are fine. Gaps +containing another ebox region mean the box is interleaved by a concat +layout and must not be replaced through a single `delete-region'. +When REGION-ID-ALLOW-SET is non-nil, register only ids in that hash set." + (let ((ranges (make-hash-table :test 'equal)) + (interleaved (make-hash-table :test 'equal)) + (pos start)) + (while (< pos end) + (dolist (region-id (ebox--pos-region-ids pos)) + (when (or (null region-id-allow-set) + (gethash region-id region-id-allow-set)) + (let ((range (gethash region-id ranges))) + (if range + (progn + (when (and (> pos (cdr range)) + (ebox--foreign-region-in-range-p + (cdr range) pos region-id)) + (puthash region-id t interleaved)) + (setcdr range (1+ pos))) + (puthash region-id (cons pos (1+ pos)) ranges))))) + (setq pos (1+ pos))) + (maphash + (lambda (region-id range) + (unless (gethash region-id interleaved) + (ebox--set-box-extents region-id (car range) (cdr range)))) + ranges))) + +(defun ebox--register-box-extents-by-segment-scan + (start end &optional region-id-allow-set) + "Cache extents in START..END by scanning region property segments. +When REGION-ID-ALLOW-SET is non-nil, register only ids in that hash set." + (let ((ranges (make-hash-table :test 'equal)) + (interleaved (make-hash-table :test 'equal))) + (dolist (segment (ebox--region-property-segments-in-range start end)) + (pcase-let ((`(,region-id ,segment-start ,segment-end) segment)) + (when (or (null region-id-allow-set) + (gethash region-id region-id-allow-set)) + (let ((range (gethash region-id ranges))) + (if range + (progn + (when (and (> segment-start (cdr range)) + (ebox--foreign-region-in-range-p + (cdr range) segment-start region-id)) + (puthash region-id t interleaved)) + (setcdr range (max (cdr range) segment-end))) + (puthash region-id + (cons segment-start segment-end) + ranges)))))) + (maphash + (lambda (region-id range) + (unless (gethash region-id interleaved) + (ebox--set-box-extents region-id (car range) (cdr range)))) + ranges))) + +(defun ebox--register-box-extents-in-range + (start end &optional region-id-allow-set) + "Cache live box extents for ebox regions between START and END. +When REGION-ID-ALLOW-SET is non-nil, register only ids in that hash set." + (if (< (- end start) ebox--box-extents-segment-scan-min-chars) + (ebox--register-box-extents-by-position-scan + start end region-id-allow-set) + (ebox--register-box-extents-by-segment-scan + start end region-id-allow-set))) + +(defun ebox--ensure-live-box-extents (region-id) + "Compute and cache live extents for REGION-ID on demand." + (when-let ((buffer (and (fboundp 'ebox--region-buffer) + (ebox--region-buffer region-id)))) + (with-current-buffer buffer + (if-let ((span (and (hash-table-p ebox--box-extent-template) + (gethash region-id ebox--box-extent-template)))) + (ebox--set-box-extents region-id (car span) (cdr span)) + (ebox--register-box-extents-in-range (point-min) (point-max))) + (gethash region-id ebox--box-extents)))) + +(defun ebox--live-box-extents (region-id) + "Return live extents for REGION-ID, or nil if unavailable." + (or (when-let ((extents (gethash region-id ebox--box-extents))) + (let* ((start-marker (car extents)) + (end-marker (cdr extents)) + (buffer (marker-buffer start-marker)) + (start (marker-position start-marker)) + (end (marker-position end-marker))) + (when (and buffer + (eq buffer (marker-buffer end-marker)) + start end + (< start end)) + extents))) + (ebox--ensure-live-box-extents region-id))) + +(defun ebox--box-visible-overflow-p (box) + "Return non-nil when BOX renders unowned visible overflow lines." + (and (eq (ebox-get box :overflow) 'visible) + (let* ((formatted-content (ebox--format-content box)) + (text-height (ebox-string-height formatted-content))) + (> text-height (ebox--content-height box text-height))))) + +(defun ebox--plist-remove (plist key) + "Remove KEY and its value from PLIST, return new plist." + (let ((result nil)) + (while plist + (unless (eq (car plist) key) + (push (car plist) result) + (push (cadr plist) result)) + (setq plist (cddr plist))) + (nreverse result))) + +(defconst ebox--region-update-longhand-props + '(:box-sizing + :width :min-width :max-width + :height :min-height :max-height + :padding-left-pixel :padding-right-pixel + :padding-top-height :padding-bottom-height + :margin-left-pixel :margin-right-pixel + :margin-top-height :margin-bottom-height + :border-left-pixel :border-left-color + :border-right-pixel :border-right-color + :border-top-p :border-top-color + :border-bottom-p :border-bottom-color + :color :bgcolor + :text-align :vertical-align + :overflow :wrap-mode + :scroll-offset :visibility) + "Longhand box properties accepted by `ebox-region-update'.") + +(defun ebox--region-update-normalize-property (property) + "Return the box longhand field for region-update PROPERTY." + (pcase property + (:background-color :bgcolor) + (_ property))) + +(defun ebox--region-update-analysis (box expanded) + "Return the effective change analysis for BOX and EXPANDED properties." + (let (content-seen + content-value + style-changed-p + unknown-keys) + (cl-loop for (key value) on expanded by #'cddr + for target-key = (ebox--region-update-normalize-property key) + do + (cond + ((eq target-key :content) + (setq content-seen t + content-value value)) + ((memq target-key ebox--region-update-longhand-props) + (when (not (equal (ebox-get box target-key) value)) + (setq style-changed-p t))) + (t + (push key unknown-keys)))) + (list :changed-p + (or style-changed-p + (and content-seen + (not (equal (ebox-get box :content) content-value)))) + :unknown-keys (nreverse unknown-keys)))) + +(defun ebox--region-update-buffer (region-id) + "Return the render buffer for REGION-ID, preferring a valid buffer hint." + (or (when (and ebox--region-update-buffer-hint + (buffer-live-p ebox--region-update-buffer-hint)) + (let ((region-id-set + (ebox--buffer-region-id-set ebox--region-update-buffer-hint))) + (when (and region-id-set + (gethash region-id region-id-set)) + ebox--region-update-buffer-hint))) + (ebox--region-buffer region-id))) + +(defun ebox--mirror-region-update-box (region-box-table region-id box) + "Mirror REGION-ID's BOX into REGION-BOX-TABLE and the legacy side table." + (when region-box-table + (puthash region-id box region-box-table)) + (puthash region-id box ebox--region-box-table)) + +(defun ebox--scroll-state-sync-after-update-p (report) + "Return non-nil when REPORT requires scroll cache synchronization." + (and report + (not (eq (plist-get report :strategy) 'no-op)) + (not (plist-get report :scroll-state-patch)) + (not (plist-get report :lazy-scroll-deferred-patch)))) + +(defun ebox--sync-scroll-offset-state-window (region-id changed-keys) + "Synchronize REGION-ID's own scroll state/window after CHANGED-KEYS. +Return non-nil only when the runtime offset, cached offset, and rendered window +are settled in the same update transaction." + (if (not (equal changed-keys '(:scroll-offset))) + t + (if-let ((state (ebox--scroll-get-state region-id))) + (when-let* ((box (plist-get state :box)) + (content-lines (plist-get state :content-lines)) + (content-height (plist-get state :content-height))) + (let* ((max-offset + (max 0 (- (length content-lines) content-height))) + (target (max 0 (min max-offset + (or (ebox-get box :scroll-offset) 0)))) + (current (or (plist-get state :scroll-offset) 0))) + (ebox-put box :scroll-offset target) + (unless (ebox--scroll-offset-state-settled-p region-id target) + (ebox--scroll-region-by region-id (- target current))) + (ebox--scroll-offset-state-settled-p region-id target))) + t))) + +(defun ebox--sync-scroll-state-after-batch-flush (buffer pending report) + "Synchronize final runtime nodes from successful batch PENDING entries." + (when (and buffer pending + (ebox--scroll-state-sync-after-update-p report)) + (let ((sources (make-hash-table :test 'equal)) + (final-report report) + source-order) + (dolist (entry pending) + (when-let* ((region-id (plist-get entry :region-id)) + (node (ebox--buffer-region-render-owner-node + buffer region-id)) + (node-id (ebox--ensure-node-id node))) + (if-let ((source (gethash node-id sources))) + (plist-put + source :changed-keys + (delete-dups + (append + (copy-sequence (plist-get source :changed-keys)) + (copy-sequence (plist-get entry :changed-keys))))) + (puthash node-id + (list :region-id region-id + :node node + :changed-keys + (copy-sequence (plist-get entry :changed-keys))) + sources) + (push node-id source-order)))) + (dolist (node-id (nreverse source-order)) + (let ((source (gethash node-id sources))) + (unless (ebox--sync-scroll-offset-state-window + (plist-get source :region-id) + (plist-get source :changed-keys)) + (error "Ebox could not atomically synchronize scroll state %S" + (plist-get source :region-id))) + (setq final-report + (ebox--sync-scroll-state-lines-for-node + buffer + (plist-get source :region-id) + (plist-get source :node) + (plist-get source :changed-keys) + final-report)))) + (ebox--set-buffer-update-report buffer final-report)))) + +(add-hook 'ebox-incremental--after-successful-batch-flush-hook + #'ebox--sync-scroll-state-after-batch-flush) + +(defun ebox--schedule-runtime-prewarm-after-batch-flush + (buffer _pending _report) + "Schedule shared runtime prewarming after BUFFER's batch flush." + (ebox--schedule-buffer-runtime-prewarm buffer)) + +(add-hook 'ebox-incremental--after-successful-batch-flush-hook + #'ebox--schedule-runtime-prewarm-after-batch-flush t) + +(defun ebox--root-width-reflow-update-p (buffer node changed-keys) + "Return non-nil when NODE changes BUFFER's root width constraint." + (and buffer node + (equal (ebox--ensure-node-id node) + (ebox--buffer-root-node-id buffer)) + (cl-some (lambda (key) + (memq key '(:width :min-width :max-width))) + changed-keys))) + +;;;###autoload +(defun ebox-region-update (region-id &rest props) + "Update one or more properties of the box identified by REGION-ID. + +PROPS accepts the same shorthand and longhand keywords as `ebox-create'. +Properties are expanded through `ebox-property-rules', written to the box +plist, then rendered once. Unsupported pseudo properties such as +:display are ignored by the shared rule table; :visibility affects rendering. +Return nil when the change is queued in an active explicit batch. Otherwise +return the report stored by `ebox-buffer-update-report'; legal same-value +updates return a `no-op' report. + +Examples: + (ebox-region-update id :content \"Hello\") + (ebox-region-update id :padding (list 1 2) :border-color \"red\") + (ebox-region-update id :content \"Hi\" :width 30 :color \"white\")" + (ebox--with-render-gc + (ebox--with-validated-display-cache + (catch 'ebox-region-update-result + (let* ((inhibit-read-only t) + (buffer (ebox--region-update-buffer region-id)) + (region-box-table + (and buffer (ebox--buffer-region-box-table buffer))) + (box + (if (hash-table-p region-box-table) + (or (gethash region-id region-box-table) + (error "No box found in buffer runtime for region %S" + region-id)) + (or (gethash region-id ebox--region-box-table) + (error "No box found for region %S" region-id)))) + (expanded (ebox--expand-plist props)) + (analysis (ebox--region-update-analysis box expanded))) + (dolist (key (plist-get analysis :unknown-keys)) + (message "ebox-region-update: unknown key %S (ignored)" key)) + (unless (plist-get analysis :changed-p) + (if (and buffer (ebox-incremental-batching-p buffer)) + (throw 'ebox-region-update-result nil) + (let ((report + (ebox--update-report + region-id 'no-op + :dirty-count 0 + :patch-count 0 + :patch-ops nil))) + (when buffer + (ebox--set-buffer-update-report buffer report)) + (throw 'ebox-region-update-result report)))) + (when (and buffer ebox--prepared-root-render + (not (ebox-incremental-batching-p buffer)) + (= (length expanded) 2) + (eq (ebox--region-update-normalize-property (car expanded)) + :width)) + (let* ((state (ebox--buffer-render-state buffer)) + (root (and state (plist-get state :root-node))) + (root-box + (pcase (plist-get root :ebox-type) + ('box root) + ('flex (plist-get root :box)))) + (node (ebox--buffer-region-render-owner-node buffer region-id)) + (target-box + (and root-box + (plist-put (copy-sequence root-box) + :width (cadr expanded)))) + (target-width + (and target-box + (ebox--literal-root-pixel-width target-box))) + (report + (and (eq box root-box) target-width + (ebox--root-width-reflow-update-p + buffer node '(:width)) + (with-current-buffer buffer + (ebox-incremental--publish-prepared-root-native + buffer + (list + :kind 'root-width :region-id region-id :box box + :model-node-id (ebox--ensure-node-id box) + :target-root-width target-width + :target-viewport-width + (plist-get state :viewport-width) + :target-viewport-height + (plist-get state :viewport-height))))))) + (when report + (ebox--mirror-region-update-box + region-box-table region-id box) + (ebox--schedule-buffer-runtime-prewarm buffer) + (throw 'ebox-region-update-result report)))) + (let* ((state (and buffer (ebox--buffer-render-state buffer))) + (node (and buffer + (ebox--buffer-region-render-owner-node + buffer region-id))) + (affected-scroll-ids + (let ((subtree-ids + (and node (ebox--node-all-region-ids node))) + (containing-ids + (and buffer node + (ebox--scroll-state-region-ids-containing-node-ids + buffer (list (ebox--ensure-node-id node)))))) + (delete-dups + (delq nil (append (list region-id) + subtree-ids + containing-ids))))) + (box-before (copy-tree box)) + (state-revision-before + (and state (plist-get state :runtime-revision))) + (state-reflow-scratch-before + (and state (plist-get state :reflow-prewarm-scratch))) + (state-report-before + (and state (plist-get state :last-update-report))) + (scroll-before + (mapcar + (lambda (scroll-id) + (let ((scroll-state (ebox--scroll-get-state scroll-id))) + (list + scroll-id scroll-state + (cl-loop for (key value) on scroll-state by #'cddr + append + (list + key + (if (or + (eq key :box) + (memq + key + ebox--scroll-cache-transient-state-keys)) + value + (copy-tree value))))))) + affected-scroll-ids)) + (transaction-completed nil) + (prewarm-cancelled nil) + (mutation-notified-p nil) + changed-keys + old-style-values + result-report + (content-seen nil) + content-val + content-changed) + (ebox--mirror-region-update-box region-box-table region-id box) + (cl-labels + ((restore-plist-object + (object snapshot) + (when object + (setcar object (car snapshot)) + (setcdr object (cdr snapshot)))) + (restore-model () + (let ((inhibit-quit t)) + (restore-plist-object box box-before) + (ebox--mirror-region-update-box + region-box-table region-id box) + (dolist (entry scroll-before) + (pcase-let ((`(,scroll-id ,scroll-state ,snapshot) entry)) + (if scroll-state + (progn + (restore-plist-object scroll-state snapshot) + (puthash scroll-id scroll-state + ebox--scroll-global-state)) + (remhash scroll-id ebox--scroll-global-state)))) + (when state + (plist-put state :runtime-revision state-revision-before) + (plist-put state :reflow-prewarm-scratch + state-reflow-scratch-before) + (plist-put state :last-update-report state-report-before)) + (when (buffer-live-p buffer) + (ebox--refresh-buffer-box-extents buffer) + (ebox-buffer-refresh-region-role-spans buffer) + (ebox--refresh-buffer-scroll-content-markers buffer)))) + (run-update () + (cl-loop for (k v) on expanded by #'cddr do + (let ((target-key + (ebox--region-update-normalize-property k))) + (cond + ((eq target-key :content) + (setq content-seen t + content-val v)) + ((memq target-key ebox--region-update-longhand-props) + (unless (equal (ebox-get box target-key) v) + (when (and (eq target-key :overflow) + (not (plist-member old-style-values + target-key))) + (setq old-style-values + (plist-put old-style-values target-key + (ebox-get box target-key)))) + (when (and buffer (not prewarm-cancelled)) + (ebox--cancel-buffer-runtime-prewarm buffer) + (setq prewarm-cancelled t)) + (unless mutation-notified-p + (when buffer + (ebox-incremental--notify-before-runtime-mutation + buffer 'region-update)) + (setq mutation-notified-p t)) + (ebox-put box target-key v) + (push target-key changed-keys))) + (t nil)))) + (setq content-changed + (and content-seen + (not (equal (ebox-get box :content) content-val)))) + (when (and (null changed-keys) + (not content-changed) + (not (and buffer + (ebox-incremental-batching-p buffer)))) + (setq result-report + (ebox--update-report + region-id 'no-op + :dirty-count 0 + :patch-count 0 + :patch-ops nil)) + (when buffer + (ebox--set-buffer-update-report buffer result-report))) + (when (and buffer (or changed-keys content-changed)) + (unless prewarm-cancelled + (ebox--cancel-buffer-runtime-prewarm buffer) + (setq prewarm-cancelled t)) + (ebox--invalidate-buffer-render-signatures + buffer (ebox--ensure-node-id box))) + ;; content sync (scroll state + rerender) + (when content-changed + (unless mutation-notified-p + (when buffer + (ebox-incremental--notify-before-runtime-mutation + buffer 'region-update)) + (setq mutation-notified-p t)) + (let ((scroll-state (ebox--scroll-get-state region-id))) + (ebox-put box :content content-val) + (push :content changed-keys) + (when scroll-state + (let* ((formatted (ebox--format-content box)) + (formatted-lines (ebox-string-lines formatted)) + (text-height (length formatted-lines)) + (content-height (ebox--content-height box text-height)) + (requested-offset + (if (plist-member expanded :scroll-offset) + (or (ebox-get box :scroll-offset) 0) + (or (plist-get scroll-state :scroll-offset) 0))) + (scroll-offset + (max 0 + (min requested-offset + (max 0 (- text-height content-height)))))) + (ebox-put box :scroll-offset scroll-offset) + (setq scroll-state + (ebox--scroll-state-set-lines + scroll-state formatted-lines)) + (plist-put scroll-state :content-height content-height) + (plist-put scroll-state :scroll-offset scroll-offset) + (plist-put scroll-state :box box))))) + ;; Merge non-content changes into one dirty kind, then let the + ;; shared constraint-change executor choose the smallest valid + ;; patch owner. + (when changed-keys + (setq changed-keys (nreverse changed-keys)) + (let* ((dirty-kind (ebox--region-update-dirty-kind changed-keys)) + (batched + (and buffer + (ebox-incremental-record-region-change + buffer region-id dirty-kind changed-keys + old-style-values))) + root-width-reflow) + (when batched + (ebox--cancel-buffer-runtime-prewarm buffer)) + (unless batched + (setq result-report + (let* ((_root-width-reflow + (setq root-width-reflow + (ebox--root-width-reflow-update-p + buffer node changed-keys)))) + (when (and node (not root-width-reflow)) + (ebox--ensure-visible-node-runtime-indexes + buffer node region-id)) + (or (ebox--try-scroll-offset-fast-path + buffer region-id changed-keys) + (and node (not root-width-reflow) + (eq dirty-kind 'geometry) + (ebox--try-lazy-scroll-deferred-patch + buffer region-id node changed-keys)) + (and node (not root-width-reflow) + (eq dirty-kind 'geometry) + (ebox--try-hidden-scroll-state-span-patch + buffer region-id node changed-keys)) + (and node (not root-width-reflow) + (eq dirty-kind 'geometry) + (ebox--try-hidden-scroll-root-fallback + buffer region-id node changed-keys)) + (and node (not root-width-reflow) + (eq dirty-kind 'geometry) + (ebox--try-scroll-state-span-patch + buffer region-id node changed-keys)) + (ebox--rerender-region-for-dirty-kind + region-id dirty-kind changed-keys buffer + old-style-values)))) + (when buffer + (let* ((confirm-reflow-scratch + (and root-width-reflow + (equal changed-keys '(:width)))) + (revision + (ebox--bump-buffer-runtime-revision + buffer confirm-reflow-scratch))) + (when confirm-reflow-scratch + (ebox--confirm-reflow-prewarm-scratch + buffer region-id revision)))) + (when (and buffer + (not root-width-reflow) + (ebox--scroll-state-sync-after-update-p + result-report)) + (unless (ebox--sync-scroll-offset-state-window + region-id changed-keys) + (error + "Ebox could not atomically synchronize scroll state %S" + region-id)) + (setq result-report + (ebox--sync-scroll-state-lines-for-node + buffer region-id node changed-keys result-report))) + (when (and buffer result-report) + (ebox--set-buffer-update-report buffer result-report)) + (when buffer + (ebox--schedule-buffer-runtime-prewarm buffer))))))) + (unwind-protect + (progn + (if (buffer-live-p buffer) + (with-current-buffer buffer + (atomic-change-group + (run-update))) + (run-update)) + (setq transaction-completed t)) + (unless transaction-completed + (restore-model))) + result-report))))))) + +(defvar ebox-scroll-map nil + "Keymap for scroll interaction within ebox.") + +(setq ebox-scroll-map + (let ((map (make-sparse-keymap))) + (dolist (area '(nil right-fringe left-fringe right-margin left-margin + vertical-scroll-bar horizontal-scroll-bar + mode-line header-line tool-bar)) + (let ((prefix (if area (vector area) []))) + (dolist (event '(wheel-down double-wheel-down triple-wheel-down + mouse-5)) + (define-key map (vconcat prefix (vector event)) + #'ebox-wheel-scroll-down)) + (dolist (event '(wheel-up double-wheel-up triple-wheel-up + mouse-4)) + (define-key map (vconcat prefix (vector event)) + #'ebox-wheel-scroll-up)))) + (define-key map [remap scroll-up-command] #'ebox-scroll-page-down) + (define-key map [remap scroll-down-command] #'ebox-scroll-page-up) + (define-key map (kbd "n") #'ebox-scroll-down) + (define-key map (kbd "p") #'ebox-scroll-up) + (dolist (key '("C-v" "" "" "SPC")) + (define-key map (kbd key) #'ebox-scroll-page-down)) + (dolist (key '("M-v" "" "" "S-SPC" "")) + (define-key map (kbd key) #'ebox-scroll-page-up)) + (define-key map (kbd "q") #'quit-window) + map)) + +(defconst ebox--scroll-overridden-minor-modes + '(pixel-scroll-precision-mode pixel-scroll-mode) + "Minor modes whose scroll bindings should not override ebox buffers.") + +(defun ebox--install-scroll-map-overrides () + "Make ebox scroll bindings win over global pixel-scroll bindings locally." + (setq-local minor-mode-overriding-map-alist + (cl-remove-if + (lambda (entry) + (memq (car entry) ebox--scroll-overridden-minor-modes)) + minor-mode-overriding-map-alist)) + (dolist (mode ebox--scroll-overridden-minor-modes) + (push (cons mode ebox-scroll-map) + minor-mode-overriding-map-alist))) + +(defun ebox--clear-scroll-map-overrides () + "Remove ebox-local scroll binding overrides from the current buffer." + (setq-local minor-mode-overriding-map-alist + (cl-remove-if + (lambda (entry) + (memq (car entry) ebox--scroll-overridden-minor-modes)) + minor-mode-overriding-map-alist))) + +;;;###autoload +(define-minor-mode ebox-buffer-mode + "Minor mode for interactive ebox buffers." + :lighter " Ebox" + :keymap ebox-scroll-map + (if ebox-buffer-mode + (ebox--install-scroll-map-overrides) + (ebox--clear-scroll-map-overrides))) + +;;;###autoload +(defun ebox-render-to-buffer (buffer-or-name node) + "Render layout NODE into BUFFER-OR-NAME and return the buffer. +NODE can be any box/concat/stack node created by `ebox-create', +`ebox-concat', or `ebox-stack', or a pre-rendered string." + (declare (indent 1)) + (unless (stringp node) + ;; Validate opaque host identities before clearing an existing target. + (ebox-tree-validate-host-refs node)) + (let* ((runtime-root + (if (stringp node) + node + (ebox--prepare-buffer-runtime-root + node buffer-or-name))) + (buffer (get-buffer-create buffer-or-name))) + (ebox--with-render-gc + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (ebox--clear-buffer-runtime-state buffer) + (erase-buffer) + (progn + (if (stringp runtime-root) + (insert runtime-root) + (ebox--set-buffer-render-state buffer runtime-root) + (condition-case err + (insert + (ebox--with-buffer-render-context buffer + (ebox-render runtime-root))) + (error + (ebox--clear-buffer-render-state buffer) + (signal (car err) (cdr err))))) + (unless (stringp runtime-root) + (ebox--refresh-buffer-scroll-content-markers buffer) + (ebox-buffer-refresh-region-role-spans buffer))) + (goto-char (point-min))) + (add-hook 'kill-buffer-hook #'ebox--cleanup-current-buffer nil t) + (ebox-buffer-mode 1) + (read-only-mode 1) + (ebox--schedule-buffer-runtime-prewarm buffer) + buffer)))) + +(defun ebox--host-ref-buffer (buffer-or-name) + "Return the live buffer named by BUFFER-OR-NAME, or nil." + (let ((buffer (and buffer-or-name (get-buffer buffer-or-name)))) + (and (buffer-live-p buffer) buffer))) + +(defun ebox--host-ref-node (buffer host-ref) + "Return BUFFER's runtime node indexed by opaque HOST-REF." + (when-let* ((table (ebox--buffer-host-ref-table buffer)) + (node-id (gethash host-ref table))) + (ebox--buffer-runtime-node buffer node-id))) + +(defun ebox--host-ref-role-bounds (buffer node) + "Return NODE's margin-free role bounds in BUFFER, or nil." + (with-current-buffer buffer + (save-restriction + (widen) + (let (start end) + (dolist (region-id (ebox--node-all-region-ids node)) + (dolist (role ebox--horizontal-border-anchor-roles) + (dolist (span (ebox--region-find region-id role)) + (let ((span-start (if (markerp (car span)) + (marker-position (car span)) + (car span))) + (span-end (if (markerp (cdr span)) + (marker-position (cdr span)) + (cdr span))) + (start-buffer (and (markerp (car span)) + (marker-buffer (car span)))) + (end-buffer (and (markerp (cdr span)) + (marker-buffer (cdr span))))) + (when (and span-start span-end (< span-start span-end) + (or (and (null start-buffer) (null end-buffer)) + (and (eq start-buffer buffer) + (eq end-buffer buffer)))) + (setq start (if start (min start span-start) span-start) + end (if end (max end span-end) span-end))))))) + (and start end (cons start end)))))) + +(defun ebox--host-ref-extent-bounds (buffer node) + "Return fallback live extent bounds for NODE in BUFFER, or nil." + (let (start end) + (dolist (region-id (ebox--node-all-region-ids node)) + (when-let* ((extents (ebox--live-box-extents region-id)) + (start-marker (car extents)) + (end-marker (cdr extents)) + (region-start (marker-position start-marker)) + (region-end (marker-position end-marker))) + (when (and (eq (marker-buffer start-marker) buffer) + (eq (marker-buffer end-marker) buffer)) + (setq start (if start (min start region-start) region-start) + end (if end (max end region-end) region-end))))) + (and start end (< start end) (cons start end)))) + +;;;###autoload +(defun ebox-host-ref-bounds (buffer-or-name host-ref) + "Return live integer bounds for HOST-REF in BUFFER-OR-NAME. + +The result is a `(START . END)' pair spanning the referenced runtime node's +rendered border-box characters (excluding margins), or nil when the buffer, +reference, or live box extents no longer exist. HOST-REF is opaque and +compared with `equal'." + (when-let* ((buffer (ebox--host-ref-buffer buffer-or-name)) + (node (ebox--host-ref-node buffer host-ref))) + (or (ebox--host-ref-role-bounds buffer node) + (ebox--host-ref-extent-bounds buffer node)))) + +;;;###autoload +(defun ebox-host-ref-position (buffer-or-name host-ref) + "Return the first live buffer position for HOST-REF, or nil. +See `ebox-host-ref-bounds' for lookup and lifetime semantics." + (car-safe (ebox-host-ref-bounds buffer-or-name host-ref))) + +;;;###autoload +(defun ebox-candidate-begin (buffer-or-name) + "Begin a one-shot logical transaction from BUFFER-OR-NAME's exact runtime." + (let ((buffer (get-buffer buffer-or-name))) + (unless (buffer-live-p buffer) + (error "Ebox candidate requires an existing live buffer: %S" + buffer-or-name)) + (ebox-incremental-candidate-begin buffer))) + +;;;###autoload +(defun ebox-candidate-replace + (candidate node-id next-subtree + &optional old-semantic-key new-semantic-key) + "Replace NODE-ID in CANDIDATE with declarative NEXT-SUBTREE. +OLD-SEMANTIC-KEY and NEW-SEMANTIC-KEY may identify a stable anchor's detached +semantic variants so a later return can reuse bounded runtime identity. +Return CANDIDATE for convenient transaction construction." + (ebox-incremental-candidate-replace + candidate node-id next-subtree old-semantic-key new-semantic-key)) + +;;;###autoload +(defun ebox-candidate-replace-host-ref + (candidate host-ref next-subtree + &optional old-semantic-key new-semantic-key) + "Replace HOST-REF in CANDIDATE with declarative NEXT-SUBTREE. +HOST-REF is resolved against the exact published runtime captured by +`ebox-candidate-begin'. OLD-SEMANTIC-KEY and NEW-SEMANTIC-KEY have the same +optional detached-identity meaning as in `ebox-candidate-replace'. +Return CANDIDATE." + (ebox-incremental-candidate-replace-host-ref + candidate host-ref next-subtree old-semantic-key new-semantic-key)) + +;;;###autoload +(defun ebox-commit (buffer-or-name next-root &optional after-publication) + "Atomically commit declarative NEXT-ROOT into BUFFER-OR-NAME. + +NEXT-ROOT may be a newly built Ebox node tree or a one-shot logical candidate +from `ebox-candidate-begin'. A complete root is copied into a buffer-owned +runtime. A logical candidate path-copies replaced ancestor chains, shares +untouched subtrees, and isolates retained scroll nodes before proof rendering. +Both forms reconcile Ebox-owned identity, +prove rendered owners in isolation, and publish only property-aware buffer +differences. Validation, render, and publication failures leave the current +buffer and runtime unchanged. + +When AFTER-PUBLICATION is non-nil, it must be a function of one update report. +Ebox invokes it inside the quit-free atomic publication boundary after the +buffer and runtime agree. This hook is for framework pointer promotion only; +it must not run application code. An error rolls the Ebox publication back. + +Return the successful publication report stored by `ebox-buffer-update-report'." + (unless (or (null after-publication) (functionp after-publication)) + (signal 'wrong-type-argument (list 'functionp after-publication))) + (let ((ebox-incremental--after-declarative-publication + (or after-publication + ebox-incremental--after-declarative-publication))) + (if (ebox-candidate-p next-root) + (ebox-incremental-commit-candidate buffer-or-name next-root) + (let ((buffer (get-buffer buffer-or-name))) + (unless (buffer-live-p buffer) + (error "Ebox declarative commit requires an existing live buffer: %S" + buffer-or-name)) + (ebox-incremental-commit buffer next-root))))) + +;;;###autoload +(defun ebox-rerender-buffer-with-context + (buffer viewport-width &optional viewport-height) + "Rerender BUFFER's stored runtime tree using VIEWPORT-WIDTH. +When VIEWPORT-HEIGHT is non-nil, height values using `(viewport-height)' are +resolved against that line count. +This preserves node and region identity and routes viewport-dependent changes +through dirty-set and patch-set execution before falling back to root rerender." + (let* ((state (ebox--buffer-render-state buffer)) + (old-viewport-width (and state + (plist-get state :viewport-width)))) + (ebox--cancel-buffer-runtime-prewarm buffer) + (prog1 + (ebox-incremental-rerender-buffer-with-context + buffer viewport-width viewport-height) + (ebox--schedule-buffer-runtime-prewarm buffer) + (ebox--schedule-buffer-reflow-cache-prewarm + buffer old-viewport-width viewport-width)))) + +;;;###autoload +(defun ebox-display-buffer (buffer-or-name node) + "Render NODE to BUFFER-OR-NAME and display the buffer with `pop-to-buffer'." + (declare (indent 1)) + (delete-other-windows) + (switch-to-buffer (ebox-render-to-buffer buffer-or-name node))) + +(defconst ebox-public-api + '(ebox-buffer-mode + ebox-buffer-update-report + ebox-build + ebox-byte-compile + ebox-candidate-begin + ebox-candidate-replace + ebox-candidate-replace-host-ref + ebox-clear-cache + ebox-column + ebox-commit + ebox-concat + ebox-create + ebox-display-buffer + ebox-flex + ebox-flex-item + ebox-host-ref-bounds + ebox-host-ref-position + ebox-pop-to-buffer + ebox-region-ids + ebox-region-update + ebox-render + ebox-render-to-buffer + ebox-rerender-buffer-with-context + ebox-row + ebox-scroll-down + ebox-scroll-map + ebox-scroll-page-down + ebox-scroll-page-up + ebox-scroll-state + ebox-scroll-up + ebox-select-all + ebox-selector-match-node-p + ebox-selector-parse + ebox-selector-query-all + ebox-selector-query-buffer + ebox-selector-update-buffer + ebox-spacer + ebox-stack + ebox-switch-to-buffer + ebox-update-selector + ebox-wheel-scroll-down + ebox-wheel-scroll-up) + "Stable core Ebox entry points available to applications and tooling.") + +(defconst ebox-feature-families + '(box-model + declarative-commit + direct-region-update + dsl + failure-atomic-rollback + flex-layout + host-reference + intrinsic-and-viewport-sizing + logical-candidate + native-reflow + overflow-and-wrapping + paint-and-text-properties + prepared-publication + render-cache + scroll-window + selector-runtime-index + stable-runtime-identity + surface-properties + viewport-reflow) + "Stable high-level Ebox capability families used by conformance tooling.") + +(provide 'ebox) +;;; ============================================================ +;;; Public API Summary +;;; ============================================================ +;; The following functions form the stable public interface of ebox. +;; All other ebox-- prefixed functions are internal implementation +;; details and may change without notice. +;; +;; ── Creation ────────────────────────────────────────────────── +;; ebox-create &rest plist +;; Create a box. Key options: :content :width :height +;; :padding :margin :border :border-color :bgcolor +;; :text-align :wrap-mode :box-sizing :surface-properties :host-ref +;; ebox-build dsl +;; Compile ETML-style (box/row/column/flex/item/spacer ...) forms into +;; normal ebox layout nodes. +;; +;; ── Layout ──────────────────────────────────────────────────── +;; ebox-concat left right → horizontal side-by-side +;; ebox-stack top bottom → vertical stacking (nest for 3+) +;; ebox-row &rest nodes → horizontal layout from many nodes +;; ebox-column &rest nodes → vertical layout from many nodes +;; ebox-spacer &rest plist → blank box node +;; ebox-flex &rest items → flex layout from boxes/items +;; Container may also carry normal ebox-create box properties. +;; ebox-flex-item node &rest plist +;; Attach :order :flex-grow :flex-shrink :flex-basis :flex +;; and :align-self metadata to a child node; non-flex properties +;; wrap the child in an ebox-create box. +;; +;; ── Rendering ───────────────────────────────────────────────── +;; ebox-render box → propertized string (no buffer side-effect) +;; ebox-render-to-buffer buf box → insert into buffer, returns buffer +;; ebox-commit buf next-root → atomically publish a declarative root +;; ebox-candidate-begin buf → begin a one-shot logical transaction +;; ebox-candidate-replace cand id root &optional old-key new-key +;; → replace one runtime node logically +;; ebox-candidate-replace-host-ref cand ref root &optional old-key new-key +;; → replace one semantic host boundary +;; ebox-display-buffer buf box → render and display with pop-to-buffer +;; ebox-host-ref-position buf ref → first live position for :host-ref +;; ebox-host-ref-bounds buf ref → live (START . END) bounds for :host-ref +;; +;; ── Region IDs ──────────────────────────────────────────────── +;; ebox-region-ids layout → list of region-ids in document order +;; call BEFORE render-to-buffer +;; +;; ── Dynamic Updates (call inside with-current-buffer + inhibit-read-only) ── +;; ebox-region-update region-id &rest props +;; Single entry-point for all dynamic changes. PROPS keywords mirror +;; those of ebox-create: +;; :content STRING +;; :width SIZE :padding-left SIZE :padding-right SIZE … +;; :border-color COLOR :border-top-color COLOR … +;; :bgcolor COLOR +;; Multiple keywords may be combined in one call. +;; +;; ── Scroll ──────────────────────────────────────────────────── +;; ebox-scroll-up &optional n +;; ebox-scroll-down &optional n +;; ebox-scroll-map (keymap, bind to buffer local map) +;; +;; ── Cache ───────────────────────────────────────────────────── +;; ebox-clear-cache + +;;; ebox.el ends here diff --git a/examples/ebox-basic-examples.el b/examples/ebox-basic-examples.el new file mode 100644 index 0000000..6077536 --- /dev/null +++ b/examples/ebox-basic-examples.el @@ -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 diff --git a/examples/playground/basic.ebox b/examples/playground/basic.ebox new file mode 100644 index 0000000..06379f3 --- /dev/null +++ b/examples/playground/basic.ebox @@ -0,0 +1,7 @@ +(box :content "Playground file" + :box-sizing border-box + :padding (1 (12)) + :border t + :border-color "#2563EB" + :bgcolor "#EFF6FF" + :color "#1E3A8A") diff --git a/examples/playground/comprehensive.ebox b/examples/playground/comprehensive.ebox new file mode 100644 index 0000000..a55b16c --- /dev/null +++ b/examples/playground/comprehensive.ebox @@ -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)))) diff --git a/examples/playground/flex-reference.ebox b/examples/playground/flex-reference.ebox new file mode 100644 index 0000000..63166f3 --- /dev/null +++ b/examples/playground/flex-reference.ebox @@ -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)) diff --git a/examples/playground/responsive-reference.ebox b/examples/playground/responsive-reference.ebox new file mode 100644 index 0000000..5c58b1f --- /dev/null +++ b/examples/playground/responsive-reference.ebox @@ -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)) diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 0000000..54ca302 --- /dev/null +++ b/native/Cargo.lock @@ -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" diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 0000000..bd5d416 --- /dev/null +++ b/native/Cargo.toml @@ -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" diff --git a/native/build.rs b/native/build.rs new file mode 100644 index 0000000..bf6c910 --- /dev/null +++ b/native/build.rs @@ -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"); +} diff --git a/native/c/ebox_module.c b/native/c/ebox_module.c new file mode 100644 index 0000000..91f6f80 --- /dev/null +++ b/native/c/ebox_module.c @@ -0,0 +1,492 @@ +#include + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#else +#include +#include +#include +#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; +} diff --git a/native/src/layout.rs b/native/src/layout.rs new file mode 100644 index 0000000..27566ea --- /dev/null +++ b/native/src/layout.rs @@ -0,0 +1,6782 @@ +use serde::Deserialize; +use std::collections::BTreeMap; + +#[cfg(test)] +use std::cell::Cell; + +#[cfg(test)] +thread_local! { + static TEST_RENDER_NODE_COUNT: Cell> = const { Cell::new(None) }; + static TEST_DISABLE_WINDOW_RENDER: Cell = const { Cell::new(false) }; +} + +const LAYOUT_VERSION: u32 = 2; +pub const TAPE_VERSION: u16 = 9; +pub const TAPE_HEADER_LEN: usize = 112; +pub const MIN_TAPE_BYTES: usize = TAPE_HEADER_LEN + 5; +const TAPE_MAGIC: &[u8; 4] = b"EBXT"; +const TAPE_FLAG_OK: u16 = 1; +const TAPE_FLAG_COMPLETE: u16 = 1 << 1; +const TAPE_FLAG_PATCH: u16 = 1 << 2; +const MAX_LAYOUT_DEPTH: usize = 256; +const MAX_LAYOUT_NODES: usize = 100_000; +pub const MAX_LAYOUT_DIMENSION: i64 = 1_000_000; +const MAX_LAYOUT_WORK_UNITS: usize = 250_000; +const MAX_TAPE_PROPERTY_ENTRIES: usize = MAX_LAYOUT_DEPTH * 16; +const MAX_PROPERTY_TEMPLATE_COUNT: u32 = MAX_LAYOUT_NODES as u32; +const MAX_TAPE_METADATA_RECORDS: usize = MAX_LAYOUT_WORK_UNITS; +const MAX_STYLE_STRING_BYTES: usize = 4096; + +const METADATA_ROLE_CONTENT: u8 = 1; +const METADATA_ROLE_CONTENT_OWNER: u8 = 2; +const METADATA_ROLE_PADDING_TOP: u8 = 3; +const METADATA_ROLE_PADDING_BOTTOM: u8 = 4; +const METADATA_ROLE_PADDING_LEFT: u8 = 5; +const METADATA_ROLE_PADDING_RIGHT: u8 = 6; +const METADATA_ROLE_MARGIN_TOP: u8 = 7; +const METADATA_ROLE_MARGIN_BOTTOM: u8 = 8; +const METADATA_ROLE_MARGIN_LEFT: u8 = 9; +const METADATA_ROLE_MARGIN_RIGHT: u8 = 10; +const METADATA_ROLE_BORDER_TOP: u8 = 11; +const METADATA_ROLE_BORDER_BOTTOM: u8 = 12; +const METADATA_ROLE_BORDER_LEFT: u8 = 13; +const METADATA_ROLE_BORDER_RIGHT: u8 = 14; +const METADATA_BOX_EXTENT: u8 = 15; +const METADATA_SCROLL_CONTENT: u8 = 16; +const METADATA_SCROLL_OWNER: u8 = 17; +const METADATA_SCROLL_WINDOW: u8 = 18; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct LayoutDocument { + version: u32, + space_width: i64, + style_count: u32, + #[serde(default)] + property_template_count: u32, + #[serde(default)] + pub(crate) styles: Vec, + root: LayoutNode, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub(crate) struct StyleTemplate { + mode: StyleMode, + face: FaceTemplate, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum StyleMode { + Set, + Add, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct FaceTemplate { + #[serde(default)] + inverse_video: Option, + #[serde(default)] + foreground: Option, + #[serde(default)] + background: Option, + #[serde(default)] + overline: Option, + #[serde(default)] + underline: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +enum ColorOrTrue { + Boolean(bool), + Color(String), +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct UnderlineTemplate { + position: bool, + #[serde(default)] + color: Option, +} + +#[derive(Debug, Deserialize)] +// Keeping the node payload inline avoids another allocation on the hot layout path. +#[allow(clippy::large_enum_variant)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "kebab-case", + deny_unknown_fields +)] +enum LayoutNode { + Box { + region_id: i64, + content: Option>, + child: Option>, + content_width_exact: bool, + #[serde(default)] + content_min_width: Option, + width: Size, + min_width: Size, + max_width: Size, + height: Size, + min_height: Size, + max_height: Size, + box_sizing: BoxSizing, + padding_left: i64, + padding_right: i64, + padding_top: i64, + padding_bottom: i64, + margin_left: i64, + margin_right: i64, + margin_top: i64, + margin_bottom: i64, + border_left: i64, + border_right: i64, + foreground_style: Option, + background_style: Option, + border_left_style: Option, + border_right_style: Option, + border_top_style: Option, + border_bottom_style: Option, + #[serde(default)] + surface_template_id: Option, + text_align: HorizontalAlign, + vertical_align: VerticalAlign, + overflow: Overflow, + wrap_mode: WrapMode, + scroll_offset: i64, + }, + Row { + children: Vec, + }, + Column { + children: Vec, + }, + Flex { + direction: FlexDirection, + wrap: FlexWrap, + justify: FlexAlign, + align_items: FlexAlign, + align_content: FlexAlign, + width: Size, + height: Size, + row_gap: i64, + column_gap: i64, + items: Vec, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +enum Size { + Auto, + Content, + None, + Pixels { + value: i64, + }, + Lines { + value: i64, + }, + Viewport, + ViewportHeight, + MinContent, + MaxContent, + FitContent { + #[serde(default)] + limit: Option>, + }, + Stretch, + Contain, + Add { + values: Box, + }, + Subtract { + values: Box, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(transparent)] +struct SizeValues(Box<[Size]>); + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum FlexDirection { + Row, + RowReverse, + Column, + ColumnReverse, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum FlexWrap { + Nowrap, + Wrap, + WrapReverse, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum FlexAlign { + Auto, + Normal, + Stretch, + FlexStart, + FlexEnd, + Center, + Start, + End, + SelfStart, + SelfEnd, + Left, + Right, + Top, + Bottom, + Baseline, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct FlexItem { + node: LayoutNode, + order: i64, + grow: f64, + shrink: f64, + basis: Size, + align_self: FlexAlign, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum BoxSizing { + ContentBox, + BorderBox, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum HorizontalAlign { + Left, + Center, + Right, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum VerticalAlign { + Top, + Center, + Bottom, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum Overflow { + Scroll, + Hidden, + Visible, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum WrapMode { + None, + Word, + Char, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MeasuredText { + lines: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MeasuredLine { + clusters: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct MeasuredCluster { + text: String, + width: i64, + cjk: bool, + space: bool, + #[serde(rename = "pixel-space", default)] + pixel_space: bool, + #[serde(default, rename = "source-template-id")] + source_template_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LayoutContext { + pub viewport_width: i64, + pub viewport_width_known: bool, + pub viewport_height: i64, +} + +#[derive(Debug, Clone, Copy)] +pub struct TapeIdentity { + pub session_id: u64, + pub generation: u64, + pub key: i64, + pub runtime_revision: u64, + pub context_hash: i64, + pub viewport_width: i64, + pub viewport_height: i64, + pub root_width: i64, + pub complete: bool, +} + +#[derive(Debug, Clone, Copy)] +pub struct TapeOutputOptions { + pub root_metadata: bool, + pub max_bytes: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LayoutTape { + style_count: u32, + lines: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TapeCharacter { + value: char, + pixel_width: Option, + properties: AtomProperties, +} + +#[derive(Debug)] +struct FlatLayoutTape { + style_count: u32, + line_count: u32, + characters: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TapePatch { + old_start: usize, + old_end: usize, + new_start: usize, + new_end: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TapeLine { + width: i64, + atoms: Vec, + break_after: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TapeAtom { + Text { + text: String, + width: i64, + properties: AtomProperties, + }, + Space { + width: i64, + properties: AtomProperties, + }, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct AtomProperties { + style_ids: Vec, + content: Option, + content_idx: Option, + owner: Option, + owners: Vec, + roles: Vec, + scroll_window: Option, + property_template_ids: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RegionRole { + PaddingLeft, + PaddingRight, + PaddingTop, + PaddingBottom, + BorderLeft, + BorderRight, + BorderTop, + BorderBottom, + MarginLeft, + MarginRight, + MarginTop, + MarginBottom, +} + +const REGION_ROLES: [RegionRole; 12] = [ + RegionRole::PaddingLeft, + RegionRole::PaddingRight, + RegionRole::PaddingTop, + RegionRole::PaddingBottom, + RegionRole::BorderLeft, + RegionRole::BorderRight, + RegionRole::BorderTop, + RegionRole::BorderBottom, + RegionRole::MarginLeft, + RegionRole::MarginRight, + RegionRole::MarginTop, + RegionRole::MarginBottom, +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RegionRoleEntry { + role: RegionRole, + region_id: i64, +} + +#[derive(Debug, Clone)] +enum Atom { + Text { + text: String, + width: i64, + cjk: bool, + space: bool, + properties: AtomProperties, + }, + Space { + width: i64, + properties: AtomProperties, + }, +} + +#[derive(Debug, Clone, Default)] +struct Line { + atoms: Vec, + width: i64, +} + +impl Line { + fn from_clusters(clusters: &[MeasuredCluster]) -> Self { + let mut line = Self::default(); + for cluster in clusters { + let properties = AtomProperties { + property_template_ids: cluster.source_template_id.into_iter().collect(), + ..AtomProperties::default() + }; + if cluster.pixel_space { + line.push_space_with_properties(cluster.width, properties); + } else { + line.width += cluster.width; + line.atoms.push(Atom::Text { + text: cluster.text.clone(), + width: cluster.width, + cjk: cluster.cjk, + space: cluster.space, + properties, + }); + } + } + line + } + + fn from_atoms(atoms: &[Atom]) -> Self { + Self { + atoms: atoms.to_vec(), + width: atoms.iter().map(Atom::width).sum(), + } + } + + fn push_space(&mut self, width: i64) { + self.push_space_with_properties(width, AtomProperties::default()); + } + + fn push_space_with_properties(&mut self, width: i64, properties: AtomProperties) { + if width <= 0 { + return; + } + self.width += width; + self.atoms.push(Atom::Space { width, properties }); + } + + fn prepend_space(&mut self, width: i64) { + self.prepend_space_with_properties(width, AtomProperties::default()); + } + + fn prepend_space_with_properties(&mut self, width: i64, properties: AtomProperties) { + if width <= 0 { + return; + } + self.width += width; + self.atoms.insert(0, Atom::Space { width, properties }); + } + + fn append(&mut self, other: &Self) { + self.width += other.width; + self.atoms.extend(other.atoms.iter().cloned()); + } + + fn padded(mut self, target: i64, align: HorizontalAlign) -> Self { + let remaining = (target - self.width).max(0); + let left = match align { + HorizontalAlign::Left => 0, + HorizontalAlign::Right => remaining, + HorizontalAlign::Center => remaining / 2, + }; + self.prepend_space(left); + self.push_space(remaining - left); + self + } + + fn blank(width: i64) -> Self { + let mut line = Self::default(); + line.push_space(width); + line + } + + fn blank_with_properties(width: i64, properties: AtomProperties) -> Self { + let mut line = Self::default(); + line.push_space_with_properties(width, properties); + line + } + + fn whitespace_only(&self) -> bool { + self.atoms.iter().all(|atom| match atom { + Atom::Space { .. } => true, + Atom::Text { text, .. } => text.trim().is_empty(), + }) + } + + fn has_noncontent_properties(&self) -> bool { + self.atoms.iter().any(|atom| { + let properties = atom.properties(); + !properties.style_ids.is_empty() + || !properties.roles.is_empty() + || !properties.property_template_ids.is_empty() + }) + } + + fn own_content(&mut self, region_id: i64, content_idx: i64) { + let has_owner = self + .atoms + .iter() + .any(|atom| atom.properties().owner.is_some() || !atom.properties().owners.is_empty()); + for atom in &mut self.atoms { + let properties = atom.properties_mut(); + if has_owner { + let mut owners = properties.owners.clone(); + if let Some(owner) = properties.owner { + if !owners.contains(&owner) { + owners.push(owner); + } + } + if !owners.contains(®ion_id) { + owners.push(region_id); + } + properties.owner = Some(region_id); + properties.owners = owners; + } else { + properties.owner = Some(region_id); + properties.owners = vec![region_id]; + } + } + let has_content = self + .atoms + .iter() + .any(|atom| atom.properties().content.is_some()); + if !has_content { + for atom in &mut self.atoms { + let properties = atom.properties_mut(); + properties.content = Some(region_id); + properties.content_idx = Some(content_idx); + } + } + } + + fn collapse_whitespace_content(self, width: i64, region_id: i64) -> Self { + if !self.whitespace_only() || self.has_noncontent_properties() { + return self; + } + let first = self.atoms.first().map(Atom::properties); + let mut properties = AtomProperties { + content: first + .and_then(|properties| properties.content) + .or(Some(region_id)), + content_idx: first + .and_then(|properties| properties.content_idx) + .or(Some(0)), + owner: Some(region_id), + ..AtomProperties::default() + }; + if properties.content.is_none() { + properties.content = Some(region_id); + } + let mut line = Self::default(); + line.push_space_with_properties(width, properties); + line + } + + fn apply_style(&mut self, style_id: Option) { + if let Some(style_id) = style_id { + for atom in &mut self.atoms { + atom.properties_mut().style_ids.push(style_id); + } + } + } + + fn apply_property_template(&mut self, template_id: Option) { + if let Some(template_id) = template_id { + for atom in &mut self.atoms { + atom.properties_mut() + .property_template_ids + .push(template_id); + } + } + } + + fn apply_role(&mut self, role: RegionRole, region_id: i64) { + for atom in &mut self.atoms { + atom.properties_mut() + .roles + .push(RegionRoleEntry { role, region_id }); + } + } +} + +fn region_properties(role: RegionRole, region_id: i64, style_id: Option) -> AtomProperties { + AtomProperties { + style_ids: style_id.into_iter().collect(), + roles: vec![RegionRoleEntry { role, region_id }], + ..AtomProperties::default() + } +} + +impl Atom { + fn width(&self) -> i64 { + match self { + Self::Text { width, .. } | Self::Space { width, .. } => *width, + } + } + + fn wrap_space(&self) -> bool { + match self { + Self::Text { space, .. } => *space, + Self::Space { .. } => true, + } + } + + fn wrap_cjk(&self) -> bool { + matches!(self, Self::Text { cjk: true, .. }) + } + + fn properties(&self) -> &AtomProperties { + match self { + Self::Text { properties, .. } | Self::Space { properties, .. } => properties, + } + } + + fn properties_mut(&mut self) -> &mut AtomProperties { + match self { + Self::Text { properties, .. } | Self::Space { properties, .. } => properties, + } + } +} + +#[derive(Debug, Clone)] +struct Rendered { + lines: Vec, + breaks: Vec, +} + +impl Rendered { + fn from_lines(lines: Vec) -> Self { + let breaks = vec![AtomProperties::default(); lines.len().saturating_sub(1)]; + Self { lines, breaks } + } + + fn first_width(&self) -> i64 { + self.lines.first().map_or(0, |line| line.width) + } + + fn max_width(&self) -> i64 { + self.lines.iter().map(|line| line.width).max().unwrap_or(0) + } + + fn min_content_width(&self, wrap_mode: WrapMode) -> i64 { + if wrap_mode == WrapMode::None { + return self.max_width(); + } + self.lines + .iter() + .map(|line| { + let mut maximum = 0; + let mut run = 0; + for atom in &line.atoms { + if atom.wrap_space() { + maximum = maximum.max(run); + run = 0; + } else if atom.wrap_cjk() { + maximum = maximum.max(run).max(atom.width()); + run = 0; + } else { + run += atom.width(); + } + } + maximum.max(run) + }) + .max() + .unwrap_or(0) + } + + fn height(&self) -> i64 { + self.lines.len() as i64 + } + + fn apply_scroll_window(&mut self, region_id: i64) { + for line in &mut self.lines { + for atom in &mut line.atoms { + atom.properties_mut().scroll_window = Some(region_id); + } + } + for properties in &mut self.breaks { + properties.scroll_window = Some(region_id); + } + } + + fn into_tape(self, style_count: u32) -> LayoutTape { + let last_line = self.lines.len().saturating_sub(1); + let mut breaks = self.breaks.into_iter(); + LayoutTape { + style_count, + lines: self + .lines + .into_iter() + .enumerate() + .map(|(index, line)| TapeLine { + width: line.width, + atoms: line + .atoms + .into_iter() + .map(|atom| match atom { + Atom::Text { + text, + width, + properties, + .. + } => TapeAtom::Text { + text, + width, + properties, + }, + Atom::Space { width, properties } => { + TapeAtom::Space { width, properties } + } + }) + .collect(), + break_after: (index < last_line) + .then(|| breaks.next().expect("rendered break invariant")), + }) + .collect(), + } + } +} + +struct TapeWriter { + bytes: Vec, + limit: usize, +} + +impl TapeWriter { + fn new(limit: usize) -> Result { + if limit < TAPE_HEADER_LEN { + return Err("Native layout tape limit is smaller than its header".to_owned()); + } + Ok(Self { + bytes: vec![0; TAPE_HEADER_LEN], + limit, + }) + } + + fn reserve(&self, additional: usize) -> Result<(), String> { + if self + .bytes + .len() + .checked_add(additional) + .is_none_or(|length| length > self.limit) + { + Err("Native layout tape exceeds the result byte limit".to_owned()) + } else { + Ok(()) + } + } + + fn push_bytes(&mut self, bytes: &[u8]) -> Result<(), String> { + self.reserve(bytes.len())?; + self.bytes.extend_from_slice(bytes); + Ok(()) + } + + fn push_u32(&mut self, value: u32) -> Result<(), String> { + self.push_bytes(&value.to_le_bytes()) + } + + fn push_u64(&mut self, value: u64) -> Result<(), String> { + self.push_bytes(&value.to_le_bytes()) + } + + fn patch_u16(&mut self, offset: usize, value: u16) { + self.bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn patch_u32(&mut self, offset: usize, value: u32) { + self.bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn patch_u64(&mut self, offset: usize, value: u64) { + self.bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); + } + + fn patch_i64(&mut self, offset: usize, value: i64) { + self.bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); + } + + fn finish_header( + mut self, + identity: TapeIdentity, + ok: bool, + patch: bool, + style_count: u32, + line_count: u32, + character_count: u64, + ) -> Vec { + let mut flags = if ok { TAPE_FLAG_OK } else { 0 }; + if identity.complete { + flags |= TAPE_FLAG_COMPLETE; + } + if patch { + flags |= TAPE_FLAG_PATCH; + } + let total_len = self.bytes.len() as u64; + let body_len = total_len - TAPE_HEADER_LEN as u64; + self.bytes[..4].copy_from_slice(TAPE_MAGIC); + self.patch_u16(4, TAPE_VERSION); + self.patch_u16(6, flags); + self.patch_u32(8, TAPE_HEADER_LEN as u32); + self.patch_u64(12, total_len); + self.patch_u64(20, identity.session_id); + self.patch_u64(28, identity.generation); + self.patch_i64(36, identity.key); + self.patch_u64(44, identity.runtime_revision); + self.patch_i64(52, identity.context_hash); + self.patch_i64(60, identity.viewport_width); + self.patch_i64(68, identity.viewport_height); + self.patch_i64(76, identity.root_width); + self.patch_u32(84, style_count); + self.patch_u32(88, line_count); + self.patch_u64(92, character_count); + self.patch_u64(100, body_len); + self.patch_u32(108, 0); + self.bytes + } +} + +impl RegionRole { + fn lisp_property(self) -> &'static str { + match self { + Self::PaddingLeft => "ebox-pl", + Self::PaddingRight => "ebox-pr", + Self::PaddingTop => "ebox-pt", + Self::PaddingBottom => "ebox-pb", + Self::BorderLeft => "ebox-bl", + Self::BorderRight => "ebox-br", + Self::BorderTop => "ebox-bt", + Self::BorderBottom => "ebox-bb", + Self::MarginLeft => "ebox-ml", + Self::MarginRight => "ebox-mr", + Self::MarginTop => "ebox-mt", + Self::MarginBottom => "ebox-mb", + } + } + + fn metadata_kind(self) -> u8 { + match self { + Self::PaddingLeft => METADATA_ROLE_PADDING_LEFT, + Self::PaddingRight => METADATA_ROLE_PADDING_RIGHT, + Self::PaddingTop => METADATA_ROLE_PADDING_TOP, + Self::PaddingBottom => METADATA_ROLE_PADDING_BOTTOM, + Self::BorderLeft => METADATA_ROLE_BORDER_LEFT, + Self::BorderRight => METADATA_ROLE_BORDER_RIGHT, + Self::BorderTop => METADATA_ROLE_BORDER_TOP, + Self::BorderBottom => METADATA_ROLE_BORDER_BOTTOM, + Self::MarginLeft => METADATA_ROLE_MARGIN_LEFT, + Self::MarginRight => METADATA_ROLE_MARGIN_RIGHT, + Self::MarginTop => METADATA_ROLE_MARGIN_TOP, + Self::MarginBottom => METADATA_ROLE_MARGIN_BOTTOM, + } + } +} + +fn count_u32(value: usize, label: &str) -> Result { + u32::try_from(value).map_err(|_| format!("Native layout tape has too many {label}")) +} + +fn tape_width(value: i64, label: &str) -> Result { + u64::try_from(value).map_err(|_| format!("Native layout tape has negative {label}")) +} + +#[derive(Debug)] +struct TapeSpaceSpan { + start: u64, + width: u64, +} + +#[derive(Debug)] +struct TapePropertySpan { + start: u64, + end: u64, + properties: AtomProperties, +} + +#[derive(Debug)] +struct LiteralInterval { + start: u64, + end: u64, + properties: String, +} + +#[derive(Debug)] +struct TapeMetadataRecord { + kind: u8, + region_id: i64, + index: u32, + start: u64, + end: u64, +} + +#[derive(Debug)] +struct CompiledStyle { + mode: StyleMode, + face: String, +} + +fn validate_style_string(value: &str, label: &str) -> Result<(), String> { + if value.len() > MAX_STYLE_STRING_BYTES { + return Err(format!("Native layout {label} exceeds its byte limit")); + } + Ok(()) +} + +fn push_lisp_string(output: &mut String, value: &str) { + output.push('"'); + for character in value.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + character if character.is_control() => { + output.push_str(&format!("\\u{:04x}", character as u32)); + } + character => output.push(character), + } + } + output.push('"'); +} + +fn push_property(output: &mut String, first: &mut bool, name: &str, value: &str) { + if !*first { + output.push(' '); + } + *first = false; + output.push_str(name); + output.push(' '); + output.push_str(value); +} + +impl FaceTemplate { + fn validate(&self) -> Result<(), String> { + if self.inverse_video.is_none() + && self.foreground.is_none() + && self.background.is_none() + && self.overline.is_none() + && self.underline.is_none() + { + return Err("Native layout face template is empty".to_owned()); + } + if self.inverse_video == Some(false) { + return Err("Native layout inverse-video face must be true".to_owned()); + } + if let Some(value) = &self.foreground { + validate_style_string(value, "foreground color")?; + } + if let Some(value) = &self.background { + validate_style_string(value, "background color")?; + } + if let Some(value) = &self.overline { + match value { + ColorOrTrue::Boolean(true) => {} + ColorOrTrue::Boolean(false) => { + return Err("Native layout overline face must be true or a color".to_owned()); + } + ColorOrTrue::Color(color) => { + validate_style_string(color, "overline color")?; + } + } + } + if let Some(underline) = &self.underline { + if !underline.position { + return Err("Native layout underline position must be true".to_owned()); + } + if let Some(color) = &underline.color { + validate_style_string(color, "underline color")?; + } + } + Ok(()) + } + + fn to_lisp(&self) -> String { + let mut output = String::from("("); + let mut first = true; + if self.inverse_video == Some(true) { + push_property(&mut output, &mut first, ":inverse-video", "t"); + } + if let Some(color) = &self.foreground { + let mut value = String::new(); + push_lisp_string(&mut value, color); + push_property(&mut output, &mut first, ":foreground", &value); + } + if let Some(color) = &self.background { + let mut value = String::new(); + push_lisp_string(&mut value, color); + push_property(&mut output, &mut first, ":background", &value); + } + if let Some(overline) = &self.overline { + let value = match overline { + ColorOrTrue::Boolean(true) => "t".to_owned(), + ColorOrTrue::Boolean(false) => unreachable!("validated face template"), + ColorOrTrue::Color(color) => { + let mut value = String::new(); + push_lisp_string(&mut value, color); + value + } + }; + push_property(&mut output, &mut first, ":overline", &value); + } + if let Some(underline) = &self.underline { + let mut value = String::from("(:position t"); + if let Some(color) = &underline.color { + value.push_str(" :color "); + push_lisp_string(&mut value, color); + } + value.push(')'); + push_property(&mut output, &mut first, ":underline", &value); + } + output.push(')'); + output + } +} + +impl StyleTemplate { + fn compile(&self) -> Result { + self.face.validate()?; + Ok(CompiledStyle { + mode: self.mode, + face: self.face.to_lisp(), + }) + } +} + +fn composed_face( + properties: &AtomProperties, + styles: &[CompiledStyle], +) -> Result, String> { + if properties.style_ids.len() > MAX_TAPE_PROPERTY_ENTRIES { + return Err("Native layout tape has too many style layers".to_owned()); + } + let mut faces: Vec = Vec::new(); + let mut list_value = false; + for style_id in &properties.style_ids { + let style_index = *style_id as usize; + let style = styles + .get(style_index) + .ok_or_else(|| format!("Native layout tape has invalid style id {style_id}"))?; + match style.mode { + StyleMode::Set => { + faces.clear(); + faces.push(style_index); + list_value = false; + } + StyleMode::Add => { + if !faces.is_empty() { + list_value = true; + } + faces.push(style_index); + } + } + } + if faces.is_empty() { + Ok(None) + } else if list_value { + Ok(Some(format!( + "({})", + faces + .iter() + .map(|index| format!("#{}#", index + 1)) + .collect::>() + .join(" ") + ))) + } else { + Ok(Some(format!("#{}#", faces[0] + 1))) + } +} + +fn properties_to_lisp( + properties: Option<&AtomProperties>, + pixel_width: Option, + styles: &[CompiledStyle], +) -> Result, String> { + let mut output = String::from("("); + let mut first = true; + if let Some(properties) = properties { + if properties.owners.len() > MAX_TAPE_PROPERTY_ENTRIES + || properties.roles.len() > MAX_TAPE_PROPERTY_ENTRIES + || properties.property_template_ids.len() > MAX_TAPE_PROPERTY_ENTRIES + { + return Err("Native layout tape property record exceeds its entry limit".to_owned()); + } + if let Some(face) = composed_face(properties, styles)? { + push_property(&mut output, &mut first, "face", &face); + } + if let Some(content) = properties.content { + push_property( + &mut output, + &mut first, + "ebox-content", + &content.to_string(), + ); + } + if let Some(content_idx) = properties.content_idx { + push_property( + &mut output, + &mut first, + "ebox-content-idx", + &content_idx.to_string(), + ); + } + if let Some(owner) = properties.owner { + push_property( + &mut output, + &mut first, + "ebox-content-owner", + &owner.to_string(), + ); + } + if !properties.owners.is_empty() { + let owners = format!( + "({})", + properties + .owners + .iter() + .map(i64::to_string) + .collect::>() + .join(" ") + ); + push_property(&mut output, &mut first, "ebox-content-owners", &owners); + } + for role in REGION_ROLES { + if let Some(entry) = properties + .roles + .iter() + .rev() + .find(|entry| entry.role == role) + { + push_property( + &mut output, + &mut first, + role.lisp_property(), + &entry.region_id.to_string(), + ); + } + } + if let Some(scroll_window) = properties.scroll_window { + push_property( + &mut output, + &mut first, + "ebox-scroll-window", + &scroll_window.to_string(), + ); + } + if !properties.property_template_ids.is_empty() { + let template_ids = format!( + "({})", + properties + .property_template_ids + .iter() + .map(u32::to_string) + .collect::>() + .join(" ") + ); + push_property( + &mut output, + &mut first, + "ebox-native-property-template-ids", + &template_ids, + ); + } + } + if let Some(pixel_width) = pixel_width { + push_property( + &mut output, + &mut first, + "display", + &format!("(space :width ({pixel_width}))"), + ); + } + output.push(')'); + if first { + Ok(None) + } else { + Ok(Some(output)) + } +} + +fn encode_lisp_literal( + text: &str, + spaces: &[TapeSpaceSpan], + property_spans: &[TapePropertySpan], + styles: &[CompiledStyle], + character_count: u64, + max_bytes: usize, +) -> Result { + encode_lisp_literal_inner( + text, + spaces, + property_spans, + styles, + character_count, + max_bytes, + true, + ) +} + +fn encode_lisp_literal_inner( + text: &str, + spaces: &[TapeSpaceSpan], + property_spans: &[TapePropertySpan], + styles: &[CompiledStyle], + character_count: u64, + max_bytes: usize, + define_styles: bool, +) -> Result { + let mut boundaries = Vec::with_capacity(2 + spaces.len() * 2 + property_spans.len() * 2); + boundaries.push(0); + boundaries.push(character_count); + for space in spaces { + boundaries.push(space.start); + boundaries.push(space.start + 1); + } + for span in property_spans { + boundaries.push(span.start); + boundaries.push(span.end); + } + boundaries.sort_unstable(); + boundaries.dedup(); + + let mut intervals: Vec = Vec::new(); + let mut property_index = 0; + let mut space_index = 0; + for boundary in boundaries.windows(2) { + let start = boundary[0]; + let end = boundary[1]; + if start == end { + continue; + } + while property_index < property_spans.len() && property_spans[property_index].end <= start { + property_index += 1; + } + while space_index < spaces.len() && spaces[space_index].start < start { + space_index += 1; + } + let properties = property_spans + .get(property_index) + .filter(|span| span.start <= start && end <= span.end) + .map(|span| &span.properties); + let pixel_width = spaces + .get(space_index) + .filter(|space| space.start == start) + .map(|space| space.width); + if let Some(properties) = properties_to_lisp(properties, pixel_width, styles)? { + if let Some(previous) = intervals.last_mut() { + if previous.end == start && previous.properties == properties { + previous.end = end; + continue; + } + } + intervals.push(LiteralInterval { + start, + end, + properties, + }); + } + } + + let mut shared_properties = shared_literal_properties(&intervals, styles.len()); + let mut literal = String::with_capacity(text.len().saturating_mul(2)); + literal.push_str("#("); + push_lisp_string(&mut literal, text); + if define_styles { + for (index, style) in styles.iter().enumerate() { + literal.push_str(" 0 0 (face #"); + literal.push_str(&(index + 1).to_string()); + literal.push('='); + literal.push_str(&style.face); + literal.push(')'); + } + } + for interval in intervals { + literal.push(' '); + literal.push_str(&interval.start.to_string()); + literal.push(' '); + literal.push_str(&interval.end.to_string()); + literal.push(' '); + if let Some(label) = shared_properties.get_mut(&interval.properties) { + if !label.defined { + literal.push('#'); + literal.push_str(&label.id.to_string()); + literal.push('='); + literal.push_str(&interval.properties); + label.defined = true; + } else { + literal.push('#'); + literal.push_str(&label.id.to_string()); + literal.push('#'); + } + } else { + literal.push_str(&interval.properties); + } + if literal.len() > max_bytes { + return Err("Native layout tape exceeds its byte limit".to_owned()); + } + } + literal.push(')'); + if literal.len() > max_bytes { + return Err("Native layout tape exceeds its byte limit".to_owned()); + } + Ok(literal) +} + +#[derive(Debug)] +struct SharedLiteralProperty { + id: usize, + defined: bool, +} + +fn shared_literal_properties( + intervals: &[LiteralInterval], + reserved_label_count: usize, +) -> BTreeMap { + let mut counts = BTreeMap::new(); + for interval in intervals { + *counts.entry(interval.properties.clone()).or_insert(0_usize) += 1; + } + let mut labels = BTreeMap::new(); + let mut next_label = reserved_label_count + 1; + for interval in intervals { + if labels.contains_key(&interval.properties) { + continue; + } + let count = *counts.get(&interval.properties).unwrap_or(&0); + if count < 2 { + continue; + } + let id = next_label; + let definition_overhead = format!("#{id}=").len(); + let reference_length = format!("#{id}#").len(); + let saved_references = count - 1; + let saved_bytes = saved_references + .saturating_mul(interval.properties.len().saturating_sub(reference_length)); + if saved_bytes > definition_overhead { + labels.insert( + interval.properties.clone(), + SharedLiteralProperty { id, defined: false }, + ); + next_label += 1; + } + } + labels +} + +fn tape_properties(mut properties: AtomProperties, complete: bool) -> AtomProperties { + if !complete { + properties.content = None; + properties.content_idx = None; + properties.owner = None; + properties.owners.clear(); + properties.roles.clear(); + properties.scroll_window = None; + properties.property_template_ids.clear(); + } + properties +} + +fn tape_properties_empty(properties: &AtomProperties) -> bool { + properties.style_ids.is_empty() + && properties.content.is_none() + && properties.content_idx.is_none() + && properties.owner.is_none() + && properties.owners.is_empty() + && properties.roles.is_empty() + && properties.scroll_window.is_none() + && properties.property_template_ids.is_empty() +} + +fn push_property_span( + spans: &mut Vec, + start: u64, + end: u64, + properties: AtomProperties, + complete: bool, +) { + let properties = tape_properties(properties, complete); + if start == end || tape_properties_empty(&properties) { + return; + } + if let Some(previous) = spans.last_mut() { + if previous.end == start && previous.properties == properties { + previous.end = end; + return; + } + } + spans.push(TapePropertySpan { + start, + end, + properties, + }); +} + +fn push_metadata_span( + records: &mut Vec, + last_by_key: &mut BTreeMap<(u8, i64, u32), usize>, + kind: u8, + region_id: i64, + index: u32, + start: u64, + end: u64, +) -> Result<(), String> { + if start >= end { + return Ok(()); + } + if region_id <= 0 { + return Err("Native layout tape metadata has an invalid region id".to_owned()); + } + let key = (kind, region_id, index); + if let Some(previous_index) = last_by_key.get(&key).copied() { + let previous = &mut records[previous_index]; + if previous.end == start { + previous.end = end; + return Ok(()); + } + } + if records.len() >= MAX_TAPE_METADATA_RECORDS { + return Err("Native layout tape has too many metadata records".to_owned()); + } + let record_index = records.len(); + records.push(TapeMetadataRecord { + kind, + region_id, + index, + start, + end, + }); + last_by_key.insert(key, record_index); + Ok(()) +} + +fn metadata_extent_region_ids(properties: &AtomProperties) -> Vec { + let mut region_ids = properties.owners.clone(); + if let Some(region_id) = properties.owner { + if !region_ids.contains(®ion_id) { + region_ids.push(region_id); + } + } + if let Some(region_id) = properties.content { + if !region_ids.contains(®ion_id) { + region_ids.push(region_id); + } + } + for role in &properties.roles { + if !region_ids.contains(&role.region_id) { + region_ids.push(role.region_id); + } + } + region_ids +} + +#[derive(Debug)] +struct MetadataExtentRange { + start: u64, + end: u64, + last_event: u64, + last_safe_event: u64, + interleaved: bool, +} + +fn push_box_extent_metadata_records( + text: &str, + property_spans: &[TapePropertySpan], + records: &mut Vec, + last_by_key: &mut BTreeMap<(u8, i64, u32), usize>, +) -> Result<(), String> { + let mut non_newline_prefix = Vec::with_capacity(text.chars().count() + 1); + non_newline_prefix.push(0_u64); + for character in text.chars() { + let previous = *non_newline_prefix.last().unwrap_or(&0); + non_newline_prefix.push(previous + u64::from(character != '\n')); + } + let mut ranges: BTreeMap = BTreeMap::new(); + let mut safe_events: BTreeMap = BTreeMap::new(); + let mut event = 0_u64; + for span in property_spans { + let region_ids = metadata_extent_region_ids(&span.properties); + for region_id in ®ion_ids { + let region_id = *region_id; + if region_id <= 0 { + return Err("Native layout tape metadata has an invalid extent id".to_owned()); + } + if let Some(range) = ranges.get_mut(®ion_id) { + let safe_event = safe_events.get(®ion_id).copied().unwrap_or(0); + if span.start > range.end + && event - range.last_event > safe_event - range.last_safe_event + { + range.interleaved = true; + } + range.start = range.start.min(span.start); + range.end = range.end.max(span.end); + } else { + ranges.insert( + region_id, + MetadataExtentRange { + start: span.start, + end: span.end, + last_event: event, + last_safe_event: safe_events.get(®ion_id).copied().unwrap_or(0), + interleaved: false, + }, + ); + } + } + let start = usize::try_from(span.start) + .map_err(|_| "Native layout tape metadata span is too large".to_owned())?; + let end = usize::try_from(span.end) + .map_err(|_| "Native layout tape metadata span is too large".to_owned())?; + let contains_non_newline = non_newline_prefix + .get(end) + .zip(non_newline_prefix.get(start)) + .is_some_and(|(end_count, start_count)| end_count > start_count); + if contains_non_newline && !region_ids.is_empty() { + event += 1; + let mut safe_region_ids = span.properties.owners.clone(); + if region_ids.len() == 1 && !safe_region_ids.contains(®ion_ids[0]) { + safe_region_ids.push(region_ids[0]); + } + for region_id in safe_region_ids { + *safe_events.entry(region_id).or_insert(0) += 1; + } + } + for region_id in region_ids { + let range = ranges + .get_mut(®ion_id) + .ok_or_else(|| "Native layout tape extent state disappeared".to_owned())?; + range.last_event = event; + range.last_safe_event = safe_events.get(®ion_id).copied().unwrap_or(0); + } + } + for (region_id, range) in ranges { + if !range.interleaved { + push_metadata_span( + records, + last_by_key, + METADATA_BOX_EXTENT, + region_id, + 0, + range.start, + range.end, + )?; + } + } + Ok(()) +} + +fn build_root_metadata_records( + text: &str, + property_spans: &[TapePropertySpan], +) -> Result, String> { + let mut records = Vec::new(); + let mut last_by_key = BTreeMap::new(); + + push_box_extent_metadata_records(text, property_spans, &mut records, &mut last_by_key)?; + + // Root replacement role templates use one-based buffer points. Keep the + // tape zero-based and translate only after the sidecar has been validated + // by Emacs. + for span in property_spans { + if let Some(region_id) = span.properties.content { + push_metadata_span( + &mut records, + &mut last_by_key, + METADATA_ROLE_CONTENT, + region_id, + 0, + span.start, + span.end, + )?; + } + if let Some(region_id) = span.properties.owner { + push_metadata_span( + &mut records, + &mut last_by_key, + METADATA_ROLE_CONTENT_OWNER, + region_id, + 0, + span.start, + span.end, + )?; + } + for role in REGION_ROLES { + if let Some(entry) = span + .properties + .roles + .iter() + .rev() + .find(|entry| entry.role == role) + { + push_metadata_span( + &mut records, + &mut last_by_key, + role.metadata_kind(), + entry.region_id, + 0, + span.start, + span.end, + )?; + } + } + } + + // Direct scroll content spans precede wrapper-owner line spans, matching + // `ebox--build-scroll-content-span-template'. Extra non-scroll region ids + // are harmless: installation filters against the live scroll-state table. + for span in property_spans { + if let (Some(region_id), Some(index)) = + (span.properties.content, span.properties.content_idx) + { + let index = u32::try_from(index) + .map_err(|_| "Native layout tape has an invalid content index".to_owned())?; + push_metadata_span( + &mut records, + &mut last_by_key, + METADATA_SCROLL_CONTENT, + region_id, + index, + span.start, + span.end, + )?; + } + } + + let mut line_ranges = Vec::new(); + let mut line_start = 0_u64; + let mut position = 0_u64; + for character in text.chars() { + if character == '\n' { + if line_start < position { + line_ranges.push((line_start, position)); + } + line_start = position + 1; + } + position += 1; + } + if line_start < position { + line_ranges.push((line_start, position)); + } + + let mut owner_line_indexes: BTreeMap = BTreeMap::new(); + let mut first_span = 0_usize; + for (line_start, line_end) in line_ranges { + while first_span < property_spans.len() && property_spans[first_span].end <= line_start { + first_span += 1; + } + let mut line_owners: BTreeMap = BTreeMap::new(); + let mut span_index = first_span; + while span_index < property_spans.len() && property_spans[span_index].start < line_end { + let span = &property_spans[span_index]; + let start = span.start.max(line_start); + let end = span.end.min(line_end); + if start < end { + let mut owner_ids = span.properties.owners.clone(); + if let Some(owner) = span.properties.owner { + if !owner_ids.contains(&owner) { + owner_ids.push(owner); + } + } + for region_id in owner_ids { + if region_id <= 0 { + return Err( + "Native layout tape metadata has an invalid owner id".to_owned() + ); + } + line_owners + .entry(region_id) + .and_modify(|record| record.1 = record.1.max(end)) + .or_insert((start, end)); + } + } + span_index += 1; + } + for (region_id, (start, end)) in line_owners { + let index = owner_line_indexes.entry(region_id).or_insert(0); + push_metadata_span( + &mut records, + &mut last_by_key, + METADATA_SCROLL_OWNER, + region_id, + *index, + start, + end, + )?; + *index = index + .checked_add(1) + .ok_or_else(|| "Native layout tape owner index overflow".to_owned())?; + } + } + + if let Some((region_id, start, end)) = property_spans.iter().find_map(|span| { + span.properties + .scroll_window + .map(|region_id| (region_id, span.start, span.end)) + }) { + push_metadata_span( + &mut records, + &mut last_by_key, + METADATA_SCROLL_WINDOW, + region_id, + 0, + start, + end, + )?; + } + Ok(records) +} + +struct RootMetadataPayload { + literal: String, + record_count: usize, +} + +fn metadata_role_symbol(kind: u8) -> Option<&'static str> { + match kind { + METADATA_ROLE_CONTENT => Some("content"), + METADATA_ROLE_CONTENT_OWNER => Some("content-owner"), + METADATA_ROLE_PADDING_TOP => Some("pt"), + METADATA_ROLE_PADDING_BOTTOM => Some("pb"), + METADATA_ROLE_PADDING_LEFT => Some("pl"), + METADATA_ROLE_PADDING_RIGHT => Some("pr"), + METADATA_ROLE_MARGIN_TOP => Some("mt"), + METADATA_ROLE_MARGIN_BOTTOM => Some("mb"), + METADATA_ROLE_MARGIN_LEFT => Some("ml"), + METADATA_ROLE_MARGIN_RIGHT => Some("mr"), + METADATA_ROLE_BORDER_TOP => Some("bt"), + METADATA_ROLE_BORDER_BOTTOM => Some("bb"), + METADATA_ROLE_BORDER_LEFT => Some("bl"), + METADATA_ROLE_BORDER_RIGHT => Some("br"), + _ => None, + } +} + +fn push_hash_table(output: &mut String, entries: Vec) { + output.push_str("#s(hash-table test equal data ("); + output.push_str(&entries.join(" ")); + output.push_str("))"); +} + +fn metadata_literal_from_records(records: &[TapeMetadataRecord]) -> Result { + let mut role_table: BTreeMap<(i64, &'static str), Vec<(u64, u64)>> = BTreeMap::new(); + let mut extent_table: BTreeMap = BTreeMap::new(); + let mut scroll_table: BTreeMap> = BTreeMap::new(); + let mut scroll_window_p = false; + + for record in records { + if let Some(role) = metadata_role_symbol(record.kind) { + if record.index != 0 { + return Err("Native layout tape role metadata has a line index".to_owned()); + } + role_table + .entry((record.region_id, role)) + .or_default() + .push((record.start + 1, record.end + 1)); + } else if record.kind == METADATA_BOX_EXTENT { + if record.index != 0 { + return Err("Native layout tape box extent metadata has a line index".to_owned()); + } + extent_table + .entry(record.region_id) + .and_modify(|extent| { + extent.0 = extent.0.min(record.start + 1); + extent.1 = extent.1.max(record.end + 1); + }) + .or_insert((record.start + 1, record.end + 1)); + } else if record.kind == METADATA_SCROLL_CONTENT || record.kind == METADATA_SCROLL_OWNER { + scroll_table.entry(record.region_id).or_default().push(( + record.index, + record.start, + record.end, + )); + } else if record.kind == METADATA_SCROLL_WINDOW { + if record.index != 0 { + return Err("Native layout tape scroll-window metadata has a line index".to_owned()); + } + scroll_window_p = true; + } else { + return Err("Native layout tape has unknown metadata kind".to_owned()); + } + } + + let role_entries = role_table + .into_iter() + .map(|((region_id, role), spans)| { + let spans = spans + .into_iter() + .map(|(start, end)| format!("({start} . {end})")) + .collect::>() + .join(" "); + format!("({region_id} {role}) ({spans})") + }) + .collect::>(); + let extent_entries = extent_table + .into_iter() + .map(|(region_id, (start, end))| format!("{region_id} ({start} . {end})")) + .collect::>(); + let scroll_entries = scroll_table + .into_iter() + .map(|(region_id, spans)| { + let spans = spans + .into_iter() + .map(|(index, start, end)| format!("({index} {start} . {end})")) + .collect::>() + .join(" "); + format!("{region_id} ({spans})") + }) + .collect::>(); + + let mut literal = String::from("(:prepared-p t :role-span-template "); + push_hash_table(&mut literal, role_entries); + literal.push_str(" :box-extent-template "); + push_hash_table(&mut literal, extent_entries); + literal.push_str(" :scroll-content-span-template "); + push_hash_table(&mut literal, scroll_entries); + literal.push_str(" :scroll-window-p "); + literal.push_str(if scroll_window_p { "t" } else { "nil" }); + literal.push(')'); + Ok(literal) +} + +fn root_metadata_payload( + text: &str, + property_spans: &[TapePropertySpan], + complete: bool, + root_metadata: bool, + max_bytes: usize, +) -> Result, String> { + if !complete || !root_metadata { + return Ok(None); + } + let records = build_root_metadata_records(text, property_spans)?; + if records.is_empty() { + return Ok(None); + } + let literal = metadata_literal_from_records(&records)?; + if literal.len() > max_bytes { + return Err("Native layout tape metadata exceeds its byte limit".to_owned()); + } + Ok(Some(RootMetadataPayload { + literal, + record_count: records.len(), + })) +} + +fn flatten_layout_tape(tape: LayoutTape, complete: bool) -> Result { + if tape.lines.is_empty() { + return Err("Native layout tape has no lines".to_owned()); + } + let line_count = tape.lines.len(); + let mut characters = Vec::new(); + for (line_index, line) in tape.lines.into_iter().enumerate() { + let line_width = tape_width(line.width, "line width")?; + let expected_break = line_index + 1 < line_count; + if line.break_after.is_some() != expected_break { + return Err("Native layout tape break invariant failed".to_owned()); + } + let mut encoded_width = 0_u64; + for atom in line.atoms { + match atom { + TapeAtom::Text { + text, + width, + properties, + } => { + let width = tape_width(width, "text width")?; + if text.is_empty() || text.contains('\n') { + return Err("Native layout tape has invalid text atom".to_owned()); + } + let properties = tape_properties(properties, complete); + characters.extend(text.chars().map(|value| TapeCharacter { + value, + pixel_width: None, + properties: properties.clone(), + })); + encoded_width = encoded_width + .checked_add(width) + .ok_or_else(|| "Native layout tape width overflow".to_owned())?; + } + TapeAtom::Space { width, properties } => { + let width = tape_width(width, "space width")?; + if width == 0 { + return Err("Native layout tape has empty pixel space".to_owned()); + } + characters.push(TapeCharacter { + value: ' ', + pixel_width: Some(width), + properties: tape_properties(properties, complete), + }); + encoded_width = encoded_width + .checked_add(width) + .ok_or_else(|| "Native layout tape width overflow".to_owned())?; + } + } + } + if encoded_width != line_width { + return Err("Native layout tape line width invariant failed".to_owned()); + } + if let Some(properties) = line.break_after { + characters.push(TapeCharacter { + value: '\n', + pixel_width: None, + properties: tape_properties(properties, complete), + }); + } + } + Ok(FlatLayoutTape { + style_count: tape.style_count, + line_count: count_u32(line_count, "lines")?, + characters, + }) +} + +fn myers_trace_value(trace: &[isize], distance: usize, diagonal: isize) -> isize { + let distance = distance as isize; + if diagonal < -distance || diagonal > distance { + -1 + } else { + trace[(diagonal + distance) as usize] + } +} + +const MYERS_MAX_SEARCH_DISTANCE: usize = 1024; + +fn myers_text_matches(old: &[TapeCharacter], new: &[TapeCharacter]) -> Vec<(usize, usize, usize)> { + let prefix = old + .iter() + .zip(new.iter()) + .take_while(|(left, right)| left.value == right.value) + .count(); + let old_rest = &old[prefix..]; + let new_rest = &new[prefix..]; + let suffix = old_rest + .iter() + .rev() + .zip(new_rest.iter().rev()) + .take_while(|(left, right)| left.value == right.value) + .count(); + let old_core = &old_rest[..old_rest.len() - suffix]; + let new_core = &new_rest[..new_rest.len() - suffix]; + let mut matches = Vec::new(); + if prefix > 0 { + matches.push((0, 0, prefix)); + } + // The trace history retains O(distance^2) memory, so an unbounded + // search over two dissimilar full frames can exhaust the process. + // A capped core simply degrades into one replacement patch. + if let Some(core) = myers_core_matches(old_core, new_core, MYERS_MAX_SEARCH_DISTANCE) { + for (old_start, new_start, length) in core { + matches.push((old_start + prefix, new_start + prefix, length)); + } + } + if suffix > 0 { + matches.push((old.len() - suffix, new.len() - suffix, suffix)); + } + matches +} + +fn myers_core_matches( + old: &[TapeCharacter], + new: &[TapeCharacter], + max_distance: usize, +) -> Option> { + let old_length = old.len(); + let new_length = new.len(); + let maximum = old_length.saturating_add(new_length); + let bounded = maximum.min(max_distance); + let mut traces: Vec> = Vec::with_capacity(bounded.saturating_add(1)); + let mut final_distance = maximum; + let mut found = false; + + 'search: for distance in 0..=bounded { + let previous = distance.checked_sub(1).and_then(|index| traces.get(index)); + let mut current = vec![-1; 1 + 2 * distance]; + let mut diagonal = -(distance as isize); + while diagonal <= distance as isize { + let down = previous.map(|trace| myers_trace_value(trace, distance - 1, diagonal + 1)); + let right = previous.map(|trace| myers_trace_value(trace, distance - 1, diagonal - 1)); + let mut old_position = if distance == 0 { + 0 + } else if diagonal == -(distance as isize) { + down.unwrap_or(-1) + } else if diagonal == distance as isize { + right.unwrap_or(-1) + 1 + } else if right.unwrap_or(-1) < down.unwrap_or(-1) { + down.unwrap_or(-1) + } else { + right.unwrap_or(-1) + 1 + }; + let mut new_position = old_position - diagonal; + while old_position >= 0 + && new_position >= 0 + && (old_position as usize) < old_length + && (new_position as usize) < new_length + && old[old_position as usize].value == new[new_position as usize].value + { + old_position += 1; + new_position += 1; + } + current[(diagonal + distance as isize) as usize] = old_position; + if old_position >= old_length as isize && new_position >= new_length as isize { + traces.push(current); + final_distance = distance; + found = true; + break 'search; + } + diagonal += 2; + } + traces.push(current); + } + if !found { + return None; + } + + let mut old_position = old_length as isize; + let mut new_position = new_length as isize; + let mut matches = Vec::new(); + for distance in (1..=final_distance).rev() { + let previous_distance = distance - 1; + let previous = &traces[previous_distance]; + let diagonal = old_position - new_position; + let previous_diagonal = if diagonal == -(distance as isize) + || (diagonal != distance as isize + && myers_trace_value(previous, previous_distance, diagonal - 1) + < myers_trace_value(previous, previous_distance, diagonal + 1)) + { + diagonal + 1 + } else { + diagonal - 1 + }; + let previous_old = myers_trace_value(previous, previous_distance, previous_diagonal); + let previous_new = previous_old - previous_diagonal; + if old_position > previous_old && new_position > previous_new { + let length = (old_position - previous_old).min(new_position - previous_new) as usize; + matches.push(( + (old_position as usize) - length, + (new_position as usize) - length, + length, + )); + } + old_position = previous_old; + new_position = previous_new; + } + if old_position > 0 && new_position > 0 { + let length = old_position.min(new_position) as usize; + matches.push(( + old_position as usize - length, + new_position as usize - length, + length, + )); + } + matches.reverse(); + Some(matches) +} + +fn push_tape_patch( + patches: &mut Vec, + old_start: usize, + old_end: usize, + new_start: usize, + new_end: usize, +) { + if old_start == old_end && new_start == new_end { + return; + } + if let Some(previous) = patches.last_mut() { + if previous.old_end == old_start && previous.new_end == new_start { + previous.old_end = old_end; + previous.new_end = new_end; + return; + } + } + patches.push(TapePatch { + old_start, + old_end, + new_start, + new_end, + }); +} + +fn push_semantic_run_patches( + old: &[TapeCharacter], + new: &[TapeCharacter], + old_start: usize, + new_start: usize, + length: usize, + patches: &mut Vec, +) { + let mut run_start = None; + for offset in 0..length { + let old_character = &old[old_start + offset]; + let new_character = &new[new_start + offset]; + let equal = old_character.pixel_width == new_character.pixel_width + && old_character.properties == new_character.properties; + match (run_start, equal) { + (None, false) => run_start = Some(offset), + (Some(start), true) => { + push_tape_patch( + patches, + old_start + start, + old_start + offset, + new_start + start, + new_start + offset, + ); + run_start = None; + } + _ => {} + } + } + if let Some(start) = run_start { + push_tape_patch( + patches, + old_start + start, + old_start + length, + new_start + start, + new_start + length, + ); + } +} + +fn minimal_tape_patches(old: &[TapeCharacter], new: &[TapeCharacter]) -> Vec { + let text_equal = old.len() == new.len() + && old + .iter() + .zip(new) + .all(|(left, right)| left.value == right.value); + let matches = if text_equal { + vec![(0, 0, old.len())] + } else { + myers_text_matches(old, new) + }; + let mut old_position = 0; + let mut new_position = 0; + let mut patches = Vec::new(); + for (matched_old, matched_new, length) in matches { + if old_position < matched_old || new_position < matched_new { + push_tape_patch( + &mut patches, + old_position, + matched_old, + new_position, + matched_new, + ); + } + push_semantic_run_patches(old, new, matched_old, matched_new, length, &mut patches); + old_position = matched_old + length; + new_position = matched_new + length; + } + push_tape_patch( + &mut patches, + old_position, + old.len(), + new_position, + new.len(), + ); + patches +} + +fn tape_character_encoding_parts( + characters: &[TapeCharacter], +) -> Result<(String, Vec, Vec), String> { + let mut text = String::new(); + let mut spaces = Vec::new(); + let mut property_spans = Vec::new(); + for (position, character) in characters.iter().enumerate() { + let start = u64::try_from(position) + .map_err(|_| "Native layout tape character count overflow".to_owned())?; + text.push(character.value); + if let Some(width) = character.pixel_width { + spaces.push(TapeSpaceSpan { start, width }); + } + push_property_span( + &mut property_spans, + start, + start + 1, + character.properties.clone(), + true, + ); + } + Ok((text, spaces, property_spans)) +} + +fn encode_patch_replacement_body( + target: &[TapeCharacter], + patches: &[TapePatch], + styles: &[CompiledStyle], + max_bytes: usize, +) -> Result, String> { + let replacement_count = patches.iter().try_fold(0_usize, |count, patch| { + count + .checked_add(patch.new_end - patch.new_start) + .ok_or_else(|| "Native layout patch replacement count overflow".to_owned()) + })?; + let mut replacement_characters = Vec::with_capacity(replacement_count); + for patch in patches { + replacement_characters.extend_from_slice(&target[patch.new_start..patch.new_end]); + } + let (text, spaces, property_spans) = tape_character_encoding_parts(&replacement_characters)?; + let character_count = u64::try_from(replacement_count) + .map_err(|_| "Native layout patch replacement count overflow".to_owned())?; + encode_lisp_literal_inner( + &text, + &spaces, + &property_spans, + styles, + character_count, + max_bytes, + true, + ) + .map(String::into_bytes) +} + +fn encode_patch_combined_payload( + replacement: &[u8], + metadata: Option<&RootMetadataPayload>, + max_bytes: usize, +) -> Result, String> { + let metadata_literal = metadata.map_or("nil", |payload| payload.literal.as_str()); + let payload_len = 1_usize + .checked_add(replacement.len()) + .and_then(|size| size.checked_add(1)) + .and_then(|size| size.checked_add(metadata_literal.len())) + .and_then(|size| size.checked_add(1)) + .ok_or_else(|| "Native layout patch payload size overflow".to_owned())?; + if payload_len > max_bytes { + return Err("Native layout patch exceeds its byte limit".to_owned()); + } + let mut payload = Vec::with_capacity(payload_len); + payload.push(b'['); + payload.extend_from_slice(replacement); + payload.push(b' '); + payload.extend_from_slice(metadata_literal.as_bytes()); + payload.push(b']'); + Ok(payload) +} + +pub(crate) fn encode_layout_patch_tape( + old_tape: LayoutTape, + target_tape: LayoutTape, + styles: &[StyleTemplate], + identity: TapeIdentity, + root_metadata: bool, + max_bytes: usize, +) -> Result, String> { + let old = flatten_layout_tape(old_tape, identity.complete)?; + let target = flatten_layout_tape(target_tape, identity.complete)?; + if old.style_count != target.style_count || target.style_count as usize != styles.len() { + return Err("Native layout patch style table mismatch".to_owned()); + } + let compiled_styles = styles + .iter() + .map(StyleTemplate::compile) + .collect::, _>>()?; + let patches = minimal_tape_patches(&old.characters, &target.characters); + let patch_count = count_u32(patches.len(), "patches")?; + let target_character_count = u64::try_from(target.characters.len()) + .map_err(|_| "Native layout patch character count overflow".to_owned())?; + let base_character_count = u64::try_from(old.characters.len()) + .map_err(|_| "Native layout patch base character count overflow".to_owned())?; + let descriptor_bytes = patches + .len() + .checked_mul(32) + .ok_or_else(|| "Native layout patch size overflow".to_owned())?; + let fixed_body_bytes = 32_usize + .checked_add(descriptor_bytes) + .ok_or_else(|| "Native layout patch size overflow".to_owned())?; + let payload_limit = max_bytes + .checked_sub(TAPE_HEADER_LEN) + .and_then(|remaining| remaining.checked_sub(fixed_body_bytes)) + .ok_or_else(|| "Native layout patch exceeds its byte limit".to_owned())?; + let (target_text, _, target_property_spans) = + tape_character_encoding_parts(&target.characters)?; + let metadata_payload = root_metadata_payload( + &target_text, + &target_property_spans, + identity.complete, + root_metadata, + payload_limit, + )?; + let metadata_records = metadata_payload + .as_ref() + .map_or(0, |payload| payload.record_count); + let replacements = encode_patch_replacement_body( + &target.characters, + &patches, + &compiled_styles, + payload_limit, + )?; + let combined_payload = + encode_patch_combined_payload(&replacements, metadata_payload.as_ref(), payload_limit)?; + + let mut writer = TapeWriter::new(max_bytes)?; + writer.push_u64(base_character_count)?; + writer.push_u32(patch_count)?; + // Publication remains a minimal text/property patch. The compact target + // role sidecar lets Emacs swap exact runtime indexes without synchronously + // rescanning the page or rebuilding thousands of markers per frame. + writer.push_u32(0)?; + writer.push_u64( + u64::try_from(metadata_records) + .map_err(|_| "Native layout patch has too many metadata records".to_owned())?, + )?; + writer.push_u64( + u64::try_from(combined_payload.len()) + .map_err(|_| "Native layout patch body is too large".to_owned())?, + )?; + for patch in &patches { + writer.push_u64(patch.old_start as u64)?; + writer.push_u64(patch.old_end as u64)?; + writer.push_u64(patch.new_start as u64)?; + writer.push_u64(patch.new_end as u64)?; + } + writer.push_bytes(&combined_payload)?; + Ok(writer.finish_header( + identity, + true, + true, + target.style_count, + target.line_count, + target_character_count, + )) +} + +pub(crate) fn encode_layout_tape( + tape: LayoutTape, + styles: &[StyleTemplate], + identity: TapeIdentity, + root_metadata: bool, + max_bytes: usize, +) -> Result, String> { + if tape.lines.is_empty() { + return Err("Native layout tape has no lines".to_owned()); + } + let line_count = count_u32(tape.lines.len(), "lines")?; + if tape.style_count as usize != styles.len() { + return Err("Native layout tape style table mismatch".to_owned()); + } + let compiled_styles = styles + .iter() + .map(StyleTemplate::compile) + .collect::, _>>()?; + let mut text = String::new(); + let mut line_widths = Vec::with_capacity(tape.lines.len()); + let mut spaces = Vec::new(); + let mut property_spans = Vec::new(); + let mut character_count = 0_u64; + + for (line_index, line) in tape.lines.into_iter().enumerate() { + let line_width = tape_width(line.width, "line width")?; + line_widths.push(line_width); + let expected_break = line_index + 1 < line_count as usize; + if line.break_after.is_some() != expected_break { + return Err("Native layout tape break invariant failed".to_owned()); + } + let mut encoded_width = 0_u64; + for atom in line.atoms { + match atom { + TapeAtom::Text { + text: atom_text, + width, + properties, + } => { + let width = tape_width(width, "text width")?; + if atom_text.is_empty() || atom_text.contains('\n') { + return Err("Native layout tape has invalid text atom".to_owned()); + } + let start = character_count; + text.push_str(&atom_text); + character_count = character_count + .checked_add(u64::try_from(atom_text.chars().count()).map_err(|_| { + "Native layout tape character count overflow".to_owned() + })?) + .ok_or_else(|| "Native layout tape character overflow".to_owned())?; + push_property_span( + &mut property_spans, + start, + character_count, + properties, + identity.complete, + ); + encoded_width = encoded_width + .checked_add(width) + .ok_or_else(|| "Native layout tape width overflow".to_owned())?; + } + TapeAtom::Space { width, properties } => { + let width = tape_width(width, "space width")?; + if width == 0 { + return Err("Native layout tape has empty pixel space".to_owned()); + } + let start = character_count; + text.push(' '); + spaces.push(TapeSpaceSpan { start, width }); + character_count = character_count + .checked_add(1) + .ok_or_else(|| "Native layout tape character overflow".to_owned())?; + push_property_span( + &mut property_spans, + start, + character_count, + properties, + identity.complete, + ); + encoded_width = encoded_width + .checked_add(width) + .ok_or_else(|| "Native layout tape width overflow".to_owned())?; + } + } + } + if encoded_width != line_width { + return Err("Native layout tape line width invariant failed".to_owned()); + } + if let Some(properties) = line.break_after { + let start = character_count; + text.push('\n'); + character_count = character_count + .checked_add(1) + .ok_or_else(|| "Native layout tape character overflow".to_owned())?; + push_property_span( + &mut property_spans, + start, + character_count, + properties, + identity.complete, + ); + } + } + + let fixed_body_bytes = 24_usize + .checked_add( + (line_count as usize) + .checked_mul(8) + .ok_or_else(|| "Native layout tape size overflow".to_owned())?, + ) + .ok_or_else(|| "Native layout tape size overflow".to_owned())?; + let metadata_limit = max_bytes + .checked_sub(TAPE_HEADER_LEN) + .and_then(|remaining| remaining.checked_sub(fixed_body_bytes)) + .ok_or_else(|| "Native layout tape exceeds its byte limit".to_owned())?; + let metadata_payload = root_metadata_payload( + &text, + &property_spans, + identity.complete, + root_metadata, + metadata_limit, + )?; + let metadata_bytes = metadata_payload + .as_ref() + .map_or(0, |payload| payload.literal.len()); + let metadata_records = metadata_payload + .as_ref() + .map_or(0, |payload| payload.record_count); + let literal_limit = max_bytes + .checked_sub(TAPE_HEADER_LEN) + .and_then(|remaining| remaining.checked_sub(fixed_body_bytes)) + .and_then(|remaining| remaining.checked_sub(metadata_bytes)) + .ok_or_else(|| "Native layout tape exceeds its byte limit".to_owned())?; + let literal = encode_lisp_literal( + &text, + &spaces, + &property_spans, + &compiled_styles, + character_count, + literal_limit, + )?; + + let mut writer = TapeWriter::new(max_bytes)?; + writer.push_u64( + u64::try_from(literal.len()) + .map_err(|_| "Native layout tape literal is too large".to_owned())?, + )?; + writer.push_u32(line_count)?; + writer.push_u32( + u32::try_from(metadata_bytes) + .map_err(|_| "Native layout tape metadata is too large".to_owned())?, + )?; + writer.push_u64( + u64::try_from(metadata_records) + .map_err(|_| "Native layout tape has too many metadata records".to_owned())?, + )?; + for width in line_widths { + writer.push_u64(width)?; + } + writer.push_bytes(literal.as_bytes())?; + if let Some(payload) = metadata_payload { + writer.push_bytes(payload.literal.as_bytes())?; + } + + Ok(writer.finish_header( + identity, + true, + false, + tape.style_count, + line_count, + character_count, + )) +} + +pub fn encode_error_tape(identity: TapeIdentity, message: &str, max_bytes: usize) -> Vec { + let limit = max_bytes.max(MIN_TAPE_BYTES); + let mut writer = TapeWriter::new(limit).expect("minimum tape limit"); + let capacity = limit - TAPE_HEADER_LEN - 4; + let mut length = message.len().min(capacity); + while !message.is_char_boundary(length) { + length -= 1; + } + let message = &message.as_bytes()[..length]; + writer + .push_u32(u32::try_from(message.len()).unwrap_or(u32::MAX)) + .expect("bounded error length"); + writer.push_bytes(message).expect("bounded error body"); + writer.finish_header(identity, false, false, 0, 0, 0) +} + +impl LayoutDocument { + pub fn validate(&self) -> Result<(), String> { + if self.version != LAYOUT_VERSION { + return Err(format!( + "Unsupported native layout IR version {}", + self.version + )); + } + if self.space_width <= 0 { + return Err("Native layout space width must be positive".to_owned()); + } + if self.styles.len() != self.style_count as usize { + return Err("Native layout style table length mismatch".to_owned()); + } + if self.styles.len() > MAX_TAPE_PROPERTY_ENTRIES { + return Err("Native layout style table exceeds its entry limit".to_owned()); + } + if self.property_template_count > MAX_PROPERTY_TEMPLATE_COUNT { + return Err("Native layout property template table exceeds its entry limit".to_owned()); + } + for style in &self.styles { + style.face.validate()?; + } + let mut nodes = 0; + let mut work_units = 0; + validate_node( + &self.root, + 0, + &mut nodes, + &mut work_units, + self.style_count, + self.property_template_count, + ) + } + + pub fn validate_context(&self, context: LayoutContext) -> Result<(), String> { + let mut work_units = 0; + add_context_work(&self.root, context, &mut work_units) + } + + pub(crate) fn layout_tape( + &self, + context: LayoutContext, + root_width_override: Option, + ) -> Result { + if root_width_override.is_some() && !matches!(self.root, LayoutNode::Box { .. }) { + Err("Native root width override requires a box root".to_owned()) + } else { + render_node_with_override( + &self.root, + context, + false, + root_width_override.map(|declared_width| BoxOverride { + declared_width: Some(declared_width), + ..BoxOverride::default() + }), + ) + .map(|rendered| rendered.into_tape(self.style_count)) + } + } +} + +fn validate_nonnegative(name: &str, value: i64) -> Result<(), String> { + if value < 0 { + Err(format!("Native layout {name} cannot be negative")) + } else { + Ok(()) + } +} + +fn validate_dimension(name: &str, value: i64) -> Result<(), String> { + validate_nonnegative(name, value)?; + if value > MAX_LAYOUT_DIMENSION { + Err(format!("Native layout {name} exceeds the dimension limit")) + } else { + Ok(()) + } +} + +fn add_work_units(work_units: &mut usize, amount: usize) -> Result<(), String> { + *work_units = work_units + .checked_add(amount) + .ok_or_else(|| "Native layout work estimate overflowed".to_owned())?; + if *work_units > MAX_LAYOUT_WORK_UNITS { + Err("Native layout exceeds the work-unit limit".to_owned()) + } else { + Ok(()) + } +} + +fn add_vertical_size_work(size: &Size, work_units: &mut usize) -> Result<(), String> { + match size { + Size::Lines { value } => { + add_work_units(work_units, usize::try_from(*value).unwrap_or(usize::MAX)) + } + Size::FitContent { limit: Some(limit) } => add_vertical_size_work(limit, work_units), + Size::Add { values } | Size::Subtract { values } => { + for value in &values.0 { + add_vertical_size_work(value, work_units)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +fn add_context_size_work( + size: &Size, + context: LayoutContext, + work_units: &mut usize, +) -> Result<(), String> { + match size { + Size::ViewportHeight => add_work_units( + work_units, + usize::try_from(context.viewport_height).unwrap_or(usize::MAX), + ), + Size::FitContent { limit: Some(limit) } => { + add_context_size_work(limit, context, work_units) + } + Size::Add { values } | Size::Subtract { values } => { + for value in &values.0 { + add_context_size_work(value, context, work_units)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +fn add_context_work( + node: &LayoutNode, + context: LayoutContext, + work_units: &mut usize, +) -> Result<(), String> { + match node { + LayoutNode::Box { + child, + height, + min_height, + max_height, + .. + } => { + for size in [height, min_height, max_height] { + add_context_size_work(size, context, work_units)?; + } + if let Some(child) = child { + add_context_work(child, context, work_units)?; + } + } + LayoutNode::Row { children } | LayoutNode::Column { children } => { + for child in children { + add_context_work(child, context, work_units)?; + } + } + LayoutNode::Flex { height, items, .. } => { + add_context_size_work(height, context, work_units)?; + for item in items { + add_context_work(&item.node, context, work_units)?; + } + } + } + Ok(()) +} + +fn validate_size_at_depth(name: &str, size: &Size, depth: usize) -> Result<(), String> { + if depth > MAX_LAYOUT_DEPTH { + return Err(format!( + "Native layout {name} expression exceeds the depth limit" + )); + } + match size { + Size::Pixels { value } | Size::Lines { value } => validate_dimension(name, *value), + Size::FitContent { limit: Some(limit) } => validate_size_at_depth(name, limit, depth + 1), + Size::Add { values } | Size::Subtract { values } => { + if values.0.is_empty() { + return Err(format!("Native layout {name} expression cannot be empty")); + } + for value in &values.0 { + validate_size_at_depth(name, value, depth + 1)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +fn validate_size(name: &str, size: &Size) -> Result<(), String> { + validate_size_at_depth(name, size, 0) +} + +fn validate_node( + node: &LayoutNode, + depth: usize, + nodes: &mut usize, + work_units: &mut usize, + style_count: u32, + property_template_count: u32, +) -> Result<(), String> { + if depth > MAX_LAYOUT_DEPTH { + return Err("Native layout tree exceeds the depth limit".to_owned()); + } + *nodes += 1; + if *nodes > MAX_LAYOUT_NODES { + return Err("Native layout tree exceeds the node limit".to_owned()); + } + add_work_units(work_units, 1)?; + match node { + LayoutNode::Box { + region_id, + content, + child, + content_width_exact: _, + content_min_width, + width, + min_width, + max_width, + height, + min_height, + max_height, + padding_left, + padding_right, + padding_top, + padding_bottom, + margin_left, + margin_right, + margin_top, + margin_bottom, + border_left, + border_right, + foreground_style, + background_style, + border_left_style, + border_right_style, + border_top_style, + border_bottom_style, + surface_template_id, + scroll_offset, + .. + } => { + if *region_id <= 0 { + return Err("Native layout region id must be positive".to_owned()); + } + if let Some(content_min_width) = content_min_width { + validate_dimension("content-min-width", *content_min_width)?; + } + if content.is_some() == child.is_some() { + return Err( + "Native layout box must contain exactly one text or child value".to_owned(), + ); + } + for (name, size) in [ + ("width", width), + ("min-width", min_width), + ("max-width", max_width), + ("height", height), + ("min-height", min_height), + ("max-height", max_height), + ] { + validate_size(name, size)?; + } + for size in [height, min_height, max_height] { + add_vertical_size_work(size, work_units)?; + } + for (name, value) in [ + ("padding-left", *padding_left), + ("padding-right", *padding_right), + ("padding-top", *padding_top), + ("padding-bottom", *padding_bottom), + ("margin-left", *margin_left), + ("margin-right", *margin_right), + ("margin-top", *margin_top), + ("margin-bottom", *margin_bottom), + ("border-left", *border_left), + ("border-right", *border_right), + ("scroll-offset", *scroll_offset), + ] { + validate_dimension(name, value)?; + } + add_work_units( + work_units, + usize::try_from(padding_top + padding_bottom + margin_top + margin_bottom) + .unwrap_or(usize::MAX), + )?; + for style_id in [ + foreground_style, + background_style, + border_left_style, + border_right_style, + border_top_style, + border_bottom_style, + ] + .into_iter() + .flatten() + { + if *style_id >= style_count { + return Err("Native layout style id exceeds the style table".to_owned()); + } + } + if let Some(template_id) = surface_template_id { + if *template_id >= property_template_count { + return Err( + "Native layout property template id exceeds the property template table" + .to_owned(), + ); + } + } + if let Some(text) = content { + if text.lines.is_empty() { + return Err("Native layout measured text must contain one line".to_owned()); + } + add_work_units(work_units, text.lines.len())?; + for line in &text.lines { + add_work_units(work_units, line.clusters.len())?; + for cluster in &line.clusters { + if cluster.text.is_empty() { + return Err("Native layout cluster text cannot be empty".to_owned()); + } + validate_dimension("cluster width", cluster.width)?; + if let Some(template_id) = cluster.source_template_id { + if template_id >= property_template_count { + return Err( + "Native layout property template id exceeds the property template table" + .to_owned(), + ); + } + } + } + } + } + if let Some(child) = child { + validate_node( + child, + depth + 1, + nodes, + work_units, + style_count, + property_template_count, + )?; + } + } + LayoutNode::Row { children } | LayoutNode::Column { children } => { + if children.is_empty() { + return Err("Native layout container must contain a child".to_owned()); + } + for child in children { + validate_node( + child, + depth + 1, + nodes, + work_units, + style_count, + property_template_count, + )?; + } + } + LayoutNode::Flex { + width, + height, + row_gap, + column_gap, + items, + .. + } => { + validate_size("flex width", width)?; + validate_size("flex height", height)?; + add_vertical_size_work(height, work_units)?; + validate_dimension("flex row gap", *row_gap)?; + validate_dimension("flex column gap", *column_gap)?; + if items.is_empty() { + return Err("Native flex container must contain an item".to_owned()); + } + add_work_units( + work_units, + usize::try_from(*row_gap) + .unwrap_or(usize::MAX) + .saturating_mul(items.len()), + )?; + for item in items { + if !item.grow.is_finite() + || item.grow < 0.0 + || !item.shrink.is_finite() + || item.shrink < 0.0 + { + return Err("Native flex factors must be finite and nonnegative".to_owned()); + } + validate_size("flex basis", &item.basis)?; + validate_node( + &item.node, + depth + 1, + nodes, + work_units, + style_count, + property_template_count, + )?; + } + } + } + Ok(()) +} + +fn box_sizing_content_width( + value: i64, + sizing: BoxSizing, + padding_left: i64, + padding_right: i64, + border_left: i64, + border_right: i64, +) -> i64 { + if sizing == BoxSizing::BorderBox { + (value - padding_left - padding_right - border_left - border_right).max(0) + } else { + value + } +} + +fn box_sizing_content_height( + value: i64, + sizing: BoxSizing, + padding_top: i64, + padding_bottom: i64, +) -> i64 { + if sizing == BoxSizing::BorderBox { + (value - padding_top - padding_bottom).max(0) + } else { + value + } +} + +#[allow(clippy::too_many_arguments)] +fn resolve_width( + size: &Size, + fallback: Option, + context: LayoutContext, + sizing: BoxSizing, + padding_left: i64, + padding_right: i64, + border_left: i64, + border_right: i64, + stretch: Option, + min_content: i64, + max_content: i64, +) -> Option { + let convert = |value| { + box_sizing_content_width( + value, + sizing, + padding_left, + padding_right, + border_left, + border_right, + ) + }; + match size { + Size::Auto => fallback, + Size::Content => Some(max_content), + Size::None => None, + Size::Pixels { value } => Some(convert(*value)), + Size::Viewport => context + .viewport_width_known + .then(|| convert(context.viewport_width)) + .or(fallback), + Size::MinContent => Some(min_content), + Size::MaxContent => Some(max_content), + Size::FitContent { limit } => { + let available = limit + .as_deref() + .and_then(|limit| { + resolve_width( + limit, + None, + context, + sizing, + padding_left, + padding_right, + border_left, + border_right, + stretch, + min_content, + max_content, + ) + }) + .or(stretch) + .unwrap_or(max_content); + Some(min_content.max(max_content.min(available))) + } + Size::Stretch | Size::Contain => stretch.or(fallback), + Size::Lines { .. } | Size::ViewportHeight | Size::Add { .. } | Size::Subtract { .. } => { + fallback + } + } +} + +#[allow(clippy::too_many_arguments)] +fn resolve_definite_width( + size: &Size, + context: LayoutContext, + sizing: BoxSizing, + padding_left: i64, + padding_right: i64, + border_left: i64, + border_right: i64, + stretch: Option, +) -> Option { + let convert = |value| { + box_sizing_content_width( + value, + sizing, + padding_left, + padding_right, + border_left, + border_right, + ) + }; + match size { + Size::Pixels { value } => Some(convert(*value)), + Size::Viewport => context + .viewport_width_known + .then(|| convert(context.viewport_width)), + Size::Stretch | Size::Contain | Size::FitContent { limit: None } => stretch, + Size::FitContent { limit: Some(limit) } => resolve_definite_width( + limit, + context, + sizing, + padding_left, + padding_right, + border_left, + border_right, + stretch, + ), + _ => None, + } +} + +#[allow(clippy::too_many_arguments)] +fn resolve_child_viewport_width( + width: &Size, + min_width: &Size, + max_width: &Size, + context: LayoutContext, + sizing: BoxSizing, + padding_left: i64, + padding_right: i64, + border_left: i64, + border_right: i64, + stretch: Option, +) -> Option { + let definite = |size| { + resolve_definite_width( + size, + context, + sizing, + padding_left, + padding_right, + border_left, + border_right, + stretch, + ) + }; + let minimum = definite(min_width).unwrap_or(0); + let maximum = definite(max_width); + let auto_stretch = if matches!(width, Size::Auto) { + stretch + } else { + None + }; + let preferred = definite(width).or(auto_stretch).or(maximum); + preferred.map(|preferred| minimum.max(preferred.max(0).min(maximum.unwrap_or(i64::MAX)))) +} + +fn resolve_raw_height(size: &Size, context: LayoutContext) -> Option { + fn resolve(size: &Size, context: LayoutContext) -> Option { + match size { + Size::Lines { value } | Size::Pixels { value } => Some(i128::from(*value)), + Size::ViewportHeight => { + (context.viewport_height > 0).then_some(i128::from(context.viewport_height)) + } + Size::Add { values } => values.0.iter().try_fold(0_i128, |total, value| { + resolve(value, context).and_then(|value| total.checked_add(value)) + }), + Size::Subtract { values } => { + let mut values = values.0.iter(); + let first = resolve(values.next()?, context)?; + if values.len() == 0 { + first.checked_neg() + } else { + values.try_fold(first, |total, value| { + resolve(value, context).and_then(|value| total.checked_sub(value)) + }) + } + } + _ => None, + } + } + + resolve(size, context).map(|value| value.clamp(i64::MIN as i128, i64::MAX as i128) as i64) +} + +fn resolve_height( + size: &Size, + fallback: Option, + context: LayoutContext, + sizing: BoxSizing, + padding_top: i64, + padding_bottom: i64, +) -> Option { + match size { + Size::Auto | Size::None | Size::Content => fallback, + Size::Lines { .. } + | Size::Pixels { .. } + | Size::ViewportHeight + | Size::Add { .. } + | Size::Subtract { .. } => resolve_raw_height(size, context).map(|value| { + box_sizing_content_height(value.max(0), sizing, padding_top, padding_bottom) + }), + _ => fallback, + } +} + +#[allow(clippy::too_many_arguments)] +fn resolve_definite_box_content_width( + width: &Size, + min_width: &Size, + max_width: &Size, + context: LayoutContext, + sizing: BoxSizing, + padding_left: i64, + padding_right: i64, + border_left: i64, + border_right: i64, + stretch: Option, + override_width: Option, +) -> Option { + let intrinsic_width = |size: &Size| { + matches!( + size, + Size::Content | Size::MinContent | Size::MaxContent | Size::Auto + ) + }; + if intrinsic_width(min_width) || intrinsic_width(max_width) { + return None; + } + let definite = |size| { + resolve_definite_width( + size, + context, + sizing, + padding_left, + padding_right, + border_left, + border_right, + stretch, + ) + }; + let minimum = definite(min_width).unwrap_or(0); + let maximum = definite(max_width); + let preferred = override_width + .or_else(|| definite(width)) + .or_else(|| matches!(width, Size::Auto).then_some(stretch).flatten()) + .or(maximum); + preferred.map(|preferred| minimum.max(preferred.max(0).min(maximum.unwrap_or(i64::MAX)))) +} + +#[allow(clippy::too_many_arguments)] +fn resolve_definite_box_content_height( + height: &Size, + min_height: &Size, + max_height: &Size, + context: LayoutContext, + sizing: BoxSizing, + padding_top: i64, + padding_bottom: i64, + override_height: Option, +) -> Option { + let minimum = resolve_height( + min_height, + Some(0), + context, + sizing, + padding_top, + padding_bottom, + ) + .unwrap_or(0); + let maximum = resolve_height( + max_height, + None, + context, + sizing, + padding_top, + padding_bottom, + ) + .unwrap_or(i64::MAX); + let preferred = override_height + .or_else(|| resolve_height(height, None, context, sizing, padding_top, padding_bottom))?; + Some(minimum.max(1).max(preferred.max(1).min(maximum))) +} + +fn measured_max_width(text: &MeasuredText) -> i64 { + text.lines + .iter() + .map(|line| line.clusters.iter().map(|cluster| cluster.width).sum()) + .max() + .unwrap_or(0) +} + +fn measured_min_width(text: &MeasuredText, wrap_mode: WrapMode) -> i64 { + if wrap_mode == WrapMode::None { + return measured_max_width(text); + } + text.lines + .iter() + .map(|line| { + let mut maximum = 0; + let mut run = 0; + for cluster in &line.clusters { + if cluster.space { + maximum = maximum.max(run); + run = 0; + } else if cluster.cjk { + maximum = maximum.max(run).max(cluster.width); + run = 0; + } else { + run += cluster.width; + } + } + maximum.max(run) + }) + .max() + .unwrap_or(0) +} + +fn wrap_line(clusters: &[MeasuredCluster], max_width: i64, mode: WrapMode) -> Vec { + if clusters.is_empty() { + return vec![Line::default()]; + } + let total: i64 = clusters.iter().map(|cluster| cluster.width).sum(); + if mode == WrapMode::None || total <= max_width { + return vec![Line::from_clusters(clusters)]; + } + + let count = clusters.len(); + let mut ranges = Vec::new(); + let mut line_start = 0; + let mut current_width = 0; + let mut index = 0; + let flush_before = |end: usize, + ranges: &mut Vec<(usize, usize)>, + line_start: &mut usize, + current_width: &mut i64| { + if *line_start < end { + ranges.push((*line_start, end)); + } + *line_start = end; + *current_width = 0; + }; + + while index < count { + let cluster_width = clusters[index].width; + if clusters[index].cjk { + if current_width + cluster_width > max_width { + flush_before(index, &mut ranges, &mut line_start, &mut current_width); + } + current_width += cluster_width; + index += 1; + } else if mode == WrapMode::Word { + let mut word_end = index; + let mut word_width = 0; + while word_end < count && !clusters[word_end].space && !clusters[word_end].cjk { + word_width += clusters[word_end].width; + word_end += 1; + } + if word_width > max_width { + while index < word_end { + let width = clusters[index].width; + if current_width + width > max_width { + flush_before(index, &mut ranges, &mut line_start, &mut current_width); + } + current_width += width; + index += 1; + } + } else if current_width + word_width <= max_width { + current_width += word_width; + index = word_end; + } else { + flush_before(index, &mut ranges, &mut line_start, &mut current_width); + current_width = word_width; + index = word_end; + } + if index < count && clusters[index].space { + let width = clusters[index].width; + if current_width + width <= max_width { + current_width += width; + index += 1; + } else { + flush_before(index, &mut ranges, &mut line_start, &mut current_width); + index += 1; + line_start = index; + } + } + } else { + if current_width + cluster_width > max_width { + flush_before(index, &mut ranges, &mut line_start, &mut current_width); + } + current_width += cluster_width; + index += 1; + } + } + if line_start < count { + ranges.push((line_start, count)); + } + ranges + .into_iter() + .map(|(start, end)| Line::from_clusters(&clusters[start..end])) + .collect() +} + +fn measured_lines(text: &MeasuredText, width: i64, mode: WrapMode) -> Vec { + text.lines + .iter() + .flat_map(|line| wrap_line(&line.clusters, width, mode)) + .collect() +} + +fn wrap_rendered_line(line: &Line, max_width: i64, mode: WrapMode) -> Vec { + if line.atoms.is_empty() { + return vec![Line::default()]; + } + if mode == WrapMode::None || line.width <= max_width { + return vec![line.clone()]; + } + + let atoms = &line.atoms; + let count = atoms.len(); + let mut ranges = Vec::new(); + let mut line_start = 0; + let mut current_width = 0; + let mut index = 0; + let flush_before = |end: usize, + ranges: &mut Vec<(usize, usize)>, + line_start: &mut usize, + current_width: &mut i64| { + if *line_start < end { + ranges.push((*line_start, end)); + } + *line_start = end; + *current_width = 0; + }; + + while index < count { + let atom_width = atoms[index].width(); + if atoms[index].wrap_cjk() { + if current_width + atom_width > max_width { + flush_before(index, &mut ranges, &mut line_start, &mut current_width); + } + current_width += atom_width; + index += 1; + } else if mode == WrapMode::Word { + let mut word_end = index; + let mut word_width = 0; + while word_end < count && !atoms[word_end].wrap_space() && !atoms[word_end].wrap_cjk() { + word_width += atoms[word_end].width(); + word_end += 1; + } + if word_width > max_width { + while index < word_end { + let width = atoms[index].width(); + if current_width + width > max_width { + flush_before(index, &mut ranges, &mut line_start, &mut current_width); + } + current_width += width; + index += 1; + } + } else if current_width + word_width <= max_width { + current_width += word_width; + index = word_end; + } else { + flush_before(index, &mut ranges, &mut line_start, &mut current_width); + current_width = word_width; + index = word_end; + } + if index < count && atoms[index].wrap_space() { + let width = atoms[index].width(); + if current_width + width <= max_width { + current_width += width; + index += 1; + } else { + flush_before(index, &mut ranges, &mut line_start, &mut current_width); + index += 1; + line_start = index; + } + } + } else { + if current_width + atom_width > max_width { + flush_before(index, &mut ranges, &mut line_start, &mut current_width); + } + current_width += atom_width; + index += 1; + } + } + if line_start < count { + ranges.push((line_start, count)); + } + ranges + .into_iter() + .map(|(start, end)| Line::from_atoms(&atoms[start..end])) + .collect() +} + +fn wrap_rendered(rendered: Rendered, max_width: i64, mode: WrapMode) -> Rendered { + if mode == WrapMode::None { + return rendered; + } + let original_breaks = rendered.breaks; + let mut lines = Vec::new(); + let mut breaks = Vec::new(); + for (line_index, line) in rendered.lines.into_iter().enumerate() { + for (piece_index, piece) in wrap_rendered_line(&line, max_width, mode) + .into_iter() + .enumerate() + { + if !lines.is_empty() { + breaks.push(if piece_index == 0 { + original_breaks[line_index - 1].clone() + } else { + AtomProperties::default() + }); + } + lines.push(piece); + } + } + Rendered { lines, breaks } +} + +fn vertical_align( + mut lines: Vec, + height: usize, + align: VerticalAlign, + width: i64, +) -> Vec { + lines.truncate(height); + if lines.len() >= height { + return lines; + } + let rest = height - lines.len(); + let top = match align { + VerticalAlign::Top => 0, + VerticalAlign::Bottom => rest, + VerticalAlign::Center => rest / 2, + }; + let mut output = Vec::with_capacity(height); + output.extend((0..top).map(|_| Line::blank(width))); + output.append(&mut lines); + output.extend((0..(rest - top)).map(|_| Line::blank(width))); + output +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FlexAxis { + Row, + Column, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FlexMode { + Grow, + Shrink, +} + +#[derive(Debug, Clone)] +struct FlexRuntimeItem<'a> { + source: &'a LayoutNode, + grow: f64, + shrink: f64, + align_self: FlexAlign, + min_main: i64, + max_main: Option, + base: i64, + hypothetical: i64, + target: i64, +} + +#[derive(Debug)] +struct FlexSizedEntry { + rendered: Rendered, + cross: i64, +} + +#[derive(Debug)] +struct FlexCrossLayout { + crosses: Vec, + leading: i64, + between: i64, +} + +fn flex_axis(direction: FlexDirection) -> FlexAxis { + match direction { + FlexDirection::Row | FlexDirection::RowReverse => FlexAxis::Row, + FlexDirection::Column | FlexDirection::ColumnReverse => FlexAxis::Column, + } +} + +fn flex_direction_reversed(direction: FlexDirection) -> bool { + matches!( + direction, + FlexDirection::RowReverse | FlexDirection::ColumnReverse + ) +} + +fn flex_horizontal_size(size: &Size, context: LayoutContext) -> Option { + match size { + Size::Auto | Size::Stretch | Size::Contain => context + .viewport_width_known + .then_some(context.viewport_width.max(0)), + Size::Pixels { value } | Size::Lines { value } => Some(*value), + Size::Viewport => context + .viewport_width_known + .then_some(context.viewport_width.max(0)), + Size::None | Size::Content => None, + Size::MinContent | Size::MaxContent | Size::FitContent { .. } => context + .viewport_width_known + .then_some(context.viewport_width.max(0)), + Size::ViewportHeight | Size::Add { .. } | Size::Subtract { .. } => None, + } +} + +fn flex_vertical_size(size: &Size, context: LayoutContext) -> Option { + match size { + Size::Lines { .. } + | Size::Pixels { .. } + | Size::ViewportHeight + | Size::Add { .. } + | Size::Subtract { .. } => resolve_raw_height(size, context).map(|value| value.max(0)), + _ => None, + } +} + +fn flex_inline_viewport( + axis: FlexAxis, + main_size: Option, + cross_size: Option, +) -> Option { + match axis { + FlexAxis::Row => main_size, + FlexAxis::Column => cross_size, + } +} + +fn box_horizontal_side(node: &LayoutNode) -> Option { + if let LayoutNode::Box { + padding_left, + padding_right, + margin_left, + margin_right, + border_left, + border_right, + .. + } = node + { + Some(padding_left + padding_right + margin_left + margin_right + border_left + border_right) + } else { + None + } +} + +fn box_vertical_side(node: &LayoutNode) -> Option { + if let LayoutNode::Box { + padding_top, + padding_bottom, + margin_top, + margin_bottom, + .. + } = node + { + Some(padding_top + padding_bottom + margin_top + margin_bottom) + } else { + None + } +} + +fn box_content_intrinsics( + node: &LayoutNode, + context: LayoutContext, +) -> Result, String> { + let LayoutNode::Box { + content, + child, + content_min_width, + wrap_mode, + .. + } = node + else { + return Ok(None); + }; + if let Some(text) = content { + Ok(Some(( + content_min_width.unwrap_or_else(|| measured_min_width(text, *wrap_mode)), + measured_max_width(text), + ))) + } else if let Some(child) = child { + // Ebox measures a composite box's child under the flex container's + // current inline viewport. Treating that width as unknown makes + // responsive descendants collapse to their narrow intrinsic form and + // produces a different automatic minimum from the visible renderer. + let rendered = render_node(child, context, true)?; + Ok(Some(( + content_min_width.unwrap_or_else(|| rendered.min_content_width(*wrap_mode)), + rendered.max_width(), + ))) + } else { + Ok(Some((0, 0))) + } +} + +fn flex_box_resolve_width( + node: &LayoutNode, + size: &Size, + fallback: Option, + context: LayoutContext, +) -> Result, String> { + let LayoutNode::Box { + box_sizing, + padding_left, + padding_right, + border_left, + border_right, + .. + } = node + else { + return Ok(fallback); + }; + let (min_content, max_content) = box_content_intrinsics(node, context)?.unwrap_or((0, 0)); + let side = box_horizontal_side(node).unwrap_or(0); + let stretch = context + .viewport_width_known + .then_some((context.viewport_width - side).max(0)); + Ok(resolve_width( + size, + fallback, + context, + *box_sizing, + *padding_left, + *padding_right, + *border_left, + *border_right, + stretch, + min_content, + max_content, + )) +} + +fn flex_box_resolve_height( + node: &LayoutNode, + size: &Size, + fallback: Option, + context: LayoutContext, +) -> Option { + let LayoutNode::Box { + box_sizing, + padding_top, + padding_bottom, + .. + } = node + else { + return fallback; + }; + resolve_height( + size, + fallback, + context, + *box_sizing, + *padding_top, + *padding_bottom, + ) +} + +fn flex_min_main( + source: &LayoutNode, + rendered: &Rendered, + axis: FlexAxis, + context: LayoutContext, +) -> Result { + let LayoutNode::Box { + min_width, + min_height, + wrap_mode, + content_min_width, + .. + } = source + else { + return Ok(match axis { + FlexAxis::Row => rendered.max_width(), + FlexAxis::Column => rendered.height(), + }); + }; + Ok(match axis { + FlexAxis::Row => { + let side = box_horizontal_side(source).unwrap_or(0); + let declared = + flex_box_resolve_width(source, min_width, Some(0), context)?.unwrap_or(0); + if *wrap_mode == WrapMode::None { + rendered.max_width().max(side + declared) + } else { + let content_min = match content_min_width { + Some(content_min_width) => *content_min_width, + None => box_content_intrinsics(source, context)?.unwrap_or((0, 0)).0, + }; + side + declared.max(content_min) + } + } + FlexAxis::Column => { + box_vertical_side(source).unwrap_or(0) + + flex_box_resolve_height(source, min_height, Some(0), context) + .unwrap_or(0) + .max(1) + } + }) +} + +fn flex_max_main( + source: &LayoutNode, + axis: FlexAxis, + context: LayoutContext, +) -> Result, String> { + let LayoutNode::Box { + max_width, + max_height, + .. + } = source + else { + return Ok(None); + }; + Ok(match axis { + FlexAxis::Row => flex_box_resolve_width(source, max_width, None, context)? + .map(|value| value + box_horizontal_side(source).unwrap_or(0)), + FlexAxis::Column => flex_box_resolve_height(source, max_height, None, context) + .map(|value| value + box_vertical_side(source).unwrap_or(0)), + }) +} + +fn flex_horizontal_basis_value(size: &Size, context: LayoutContext) -> i64 { + match size { + Size::Pixels { value } | Size::Lines { value } => *value, + Size::Viewport => context.viewport_width.max(0), + Size::Stretch | Size::Contain => context.viewport_width.max(0), + _ => 0, + } +} + +fn flex_vertical_basis_value(size: &Size) -> i64 { + match size { + Size::Pixels { value } | Size::Lines { value } => *value, + _ => 0, + } +} + +fn flex_basis_main( + source: &LayoutNode, + rendered: &Rendered, + axis: FlexAxis, + basis: &Size, + context: LayoutContext, +) -> Result { + let rendered_main = match axis { + FlexAxis::Row => rendered.max_width(), + FlexAxis::Column => rendered.height(), + }; + if matches!(basis, Size::Auto) { + return Ok(rendered_main); + } + if matches!(basis, Size::Content) { + if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { + let (_, content_max) = box_content_intrinsics(source, context)?.unwrap_or((0, 0)); + return Ok(box_horizontal_side(source).unwrap_or(0) + content_max); + } + return Ok(rendered_main); + } + if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { + let (_, content_max) = box_content_intrinsics(source, context)?.unwrap_or((0, 0)); + let content = flex_box_resolve_width(source, basis, Some(content_max), context)? + .unwrap_or(content_max); + return Ok(box_horizontal_side(source).unwrap_or(0) + content); + } + Ok(match axis { + FlexAxis::Row => flex_horizontal_basis_value(basis, context), + FlexAxis::Column => flex_vertical_basis_value(basis), + }) +} + +fn flex_clamp_main(value: i64, minimum: i64, maximum: Option) -> i64 { + minimum.max(value.max(0).min(maximum.unwrap_or(999_999_999))) +} + +// Plain boxes receive their flex target through BoxOverride. Composite +// layouts, including the exact wrapper emitted for a visual flex container, +// must instead resolve viewport sizes against the item's inline allocation. +fn flex_item_uses_inline_viewport(node: &LayoutNode) -> bool { + match node { + LayoutNode::Box { + content, + child: Some(child), + content_width_exact: true, + .. + } if content.is_none() => matches!(child.as_ref(), LayoutNode::Flex { .. }), + LayoutNode::Box { .. } => false, + _ => true, + } +} + +fn measure_flex_item<'a>( + item: &'a FlexItem, + axis: FlexAxis, + inline_viewport: Option, + context: LayoutContext, +) -> Result, String> { + let uses_inline_viewport = flex_item_uses_inline_viewport(&item.node); + let measurement_context = LayoutContext { + viewport_width: if uses_inline_viewport { + inline_viewport.unwrap_or(0) + } else { + 0 + }, + viewport_width_known: uses_inline_viewport && inline_viewport.is_some(), + viewport_height: context.viewport_height, + }; + let rendered = render_node(&item.node, measurement_context, uses_inline_viewport)?; + let min_main = flex_min_main(&item.node, &rendered, axis, context)?; + let max_main = flex_max_main(&item.node, axis, context)?; + let base = flex_basis_main(&item.node, &rendered, axis, &item.basis, context)?.max(0); + let hypothetical = flex_clamp_main(base, min_main, max_main); + Ok(FlexRuntimeItem { + source: &item.node, + grow: item.grow, + shrink: item.shrink, + align_self: item.align_self, + min_main, + max_main, + base, + hypothetical, + target: hypothetical, + }) +} + +fn flex_break_lines<'a>( + items: Vec>, + main_limit: Option, + main_gap: i64, + wrap: FlexWrap, +) -> Vec>> { + if wrap == FlexWrap::Nowrap || main_limit.is_none() { + return vec![items]; + } + let limit = main_limit.unwrap_or(0); + let mut lines = Vec::new(); + let mut current = Vec::new(); + let mut current_size = 0; + for item in items { + let next_size = + current_size + if current.is_empty() { 0 } else { main_gap } + item.hypothetical; + if !current.is_empty() && next_size > limit { + lines.push(current); + current = vec![item]; + current_size = current[0].hypothetical; + } else { + current_size = next_size; + current.push(item); + } + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +fn flex_factor(item: &FlexRuntimeItem<'_>, mode: FlexMode) -> f64 { + match mode { + FlexMode::Grow => item.grow, + FlexMode::Shrink => item.shrink, + } +} + +fn flex_distribution_weight(item: &FlexRuntimeItem<'_>, mode: FlexMode) -> f64 { + match mode { + FlexMode::Grow => item.grow, + FlexMode::Shrink => item.base as f64 * item.shrink, + } +} + +fn flex_line_free_space( + line: &[FlexRuntimeItem<'_>], + targets: &[i64], + frozen: &[bool], + available: i64, +) -> i64 { + available + - line + .iter() + .enumerate() + .map(|(index, item)| { + if frozen[index] { + targets[index] + } else { + item.base + } + }) + .sum::() +} + +fn flex_effective_free_space(initial: i64, free: i64, factor_total: f64) -> f64 { + if factor_total > 0.0 && factor_total < 1.0 { + let partial = initial as f64 * factor_total; + if partial.abs() < (free as f64).abs() { + partial + } else { + free as f64 + } + } else { + free as f64 + } +} + +fn flex_distribute(amount: f64, weights: &[f64]) -> Vec { + let amount = amount.max(0.0).floor() as i64; + let total = weights + .iter() + .copied() + .filter(|weight| *weight > 0.0) + .sum::(); + if amount <= 0 || total <= 0.0 { + return vec![0; weights.len()]; + } + let mut shares = weights + .iter() + .map(|weight| { + if *weight > 0.0 { + ((amount as f64 * *weight) / total).floor() as i64 + } else { + 0 + } + }) + .collect::>(); + let mut remaining = amount - shares.iter().sum::(); + while remaining > 0 { + for (share, weight) in shares.iter_mut().zip(weights) { + if remaining == 0 { + break; + } + if *weight > 0.0 { + *share += 1; + remaining -= 1; + } + } + } + shares +} + +fn flex_size_line(line: &mut [FlexRuntimeItem<'_>], main_limit: Option, main_gap: i64) { + let count = line.len(); + let Some(limit) = main_limit else { + return; + }; + let available = limit - main_gap * count.saturating_sub(1) as i64; + let hypothetical_total = line.iter().map(|item| item.hypothetical).sum::(); + let mode = if hypothetical_total < available { + FlexMode::Grow + } else { + FlexMode::Shrink + }; + let mut targets = line.iter().map(|item| item.base).collect::>(); + let mut frozen = vec![false; count]; + for (index, item) in line.iter().enumerate() { + let factor = flex_factor(item, mode); + if factor <= 0.0 + || (mode == FlexMode::Grow && item.base > item.hypothetical) + || (mode == FlexMode::Shrink && item.base < item.hypothetical) + { + targets[index] = item.hypothetical; + frozen[index] = true; + } + } + let initial_free = flex_line_free_space(line, &targets, &frozen, available); + loop { + let free = flex_line_free_space(line, &targets, &frozen, available); + let active = (0..count) + .filter(|index| !frozen[*index]) + .collect::>(); + let weights = active + .iter() + .map(|index| flex_distribution_weight(&line[*index], mode)) + .collect::>(); + let weight_total = weights.iter().sum::(); + let factor_total = active + .iter() + .map(|index| flex_factor(&line[*index], mode)) + .sum::(); + let effective = flex_effective_free_space(initial_free, free, factor_total); + if active.is_empty() + || weight_total <= 0.0 + || (mode == FlexMode::Grow && effective < 0.0) + || (mode == FlexMode::Shrink && effective > 0.0) + { + break; + } + let deltas = flex_distribute(effective.abs(), &weights); + let mut min_violations = Vec::new(); + let mut max_violations = Vec::new(); + let mut total_violation = 0; + for ((index, delta), _) in active.iter().zip(deltas).zip(&weights) { + let item = &line[*index]; + let candidate = match mode { + FlexMode::Grow => item.base + delta, + FlexMode::Shrink => item.base - delta, + }; + let clamped = flex_clamp_main(candidate, item.min_main, item.max_main); + let adjustment = clamped - candidate; + targets[*index] = clamped; + if adjustment > 0 { + min_violations.push(*index); + } else if adjustment < 0 { + max_violations.push(*index); + } + total_violation += adjustment; + } + let to_freeze = if total_violation == 0 { + active + } else if total_violation > 0 { + if min_violations.is_empty() { + active + } else { + min_violations + } + } else if max_violations.is_empty() { + active + } else { + max_violations + }; + for index in to_freeze { + frozen[index] = true; + } + } + for (item, target) in line.iter_mut().zip(targets) { + item.target = target; + } +} + +fn flex_line_main_size(line: &[FlexRuntimeItem<'_>], main_gap: i64) -> i64 { + line.iter().map(|item| item.target).sum::() + + main_gap * line.len().saturating_sub(1) as i64 +} + +fn flex_spacing(mode: FlexAlign, leftover: i64, count: usize, base_gap: i64) -> (i64, i64, i64) { + let leftover = leftover.max(0); + match mode { + FlexAlign::FlexEnd | FlexAlign::End | FlexAlign::Right | FlexAlign::Bottom => { + (leftover, base_gap, 0) + } + FlexAlign::Center => { + let leading = leftover / 2; + (leading, base_gap, leftover - leading) + } + FlexAlign::SpaceBetween if count > 1 => (0, base_gap + leftover / (count as i64 - 1), 0), + FlexAlign::SpaceBetween => (0, base_gap, leftover), + FlexAlign::SpaceAround => { + let unit = if count > 0 { + leftover / count as i64 + } else { + 0 + }; + let leading = unit / 2; + ( + leading, + base_gap + unit, + leftover - leading - unit * count.saturating_sub(1) as i64, + ) + } + FlexAlign::SpaceEvenly => { + let unit = if count > 0 { + leftover / (count as i64 + 1) + } else { + 0 + }; + (unit, base_gap + unit, unit) + } + _ => (0, base_gap, leftover), + } +} + +fn flex_cross_offset(align: FlexAlign, extra: i64) -> i64 { + match align { + FlexAlign::FlexEnd + | FlexAlign::End + | FlexAlign::SelfEnd + | FlexAlign::Right + | FlexAlign::Bottom => extra, + FlexAlign::Center => extra / 2, + _ => 0, + } +} + +fn flex_resolved_align(item: &FlexRuntimeItem<'_>, container: FlexAlign) -> FlexAlign { + if item.align_self == FlexAlign::Auto { + container + } else { + item.align_self + } +} + +fn pad_rendered_width(mut rendered: Rendered, width: i64, align: FlexAlign) -> Rendered { + for line in &mut rendered.lines { + let extra = (width - line.width).max(0); + let left = match align { + FlexAlign::FlexEnd | FlexAlign::End | FlexAlign::Right | FlexAlign::SelfEnd => extra, + FlexAlign::Center => extra / 2, + _ => 0, + }; + line.prepend_space(left); + line.push_space(extra - left); + } + rendered +} + +fn pad_rendered_height(rendered: Rendered, height: i64, offset: i64, width: i64) -> Rendered { + let extra = (height - rendered.height()).max(0); + let top = extra.min(offset.max(0)); + let bottom = extra - top; + let mut parts = Vec::with_capacity(3); + if top > 0 { + parts.push(Rendered::from_lines( + (0..top).map(|_| Line::blank(width)).collect(), + )); + } + parts.push(rendered); + if bottom > 0 { + parts.push(Rendered::from_lines( + (0..bottom).map(|_| Line::blank(width)).collect(), + )); + } + stack_vertical(parts) +} + +fn box_override_for_flex( + source: &LayoutNode, + axis: FlexAxis, + main: i64, + cross: Option, + stretch: bool, +) -> Option { + if !matches!(source, LayoutNode::Box { .. }) { + return None; + } + let horizontal_side = box_horizontal_side(source).unwrap_or(0); + let vertical_side = box_vertical_side(source).unwrap_or(0); + Some(match axis { + FlexAxis::Row => BoxOverride { + content_width: Some((main - horizontal_side).max(0)), + content_height: (stretch && cross.is_some()) + .then_some((cross.unwrap_or(0) - vertical_side).max(0)), + declared_width: None, + }, + FlexAxis::Column => BoxOverride { + content_width: (stretch && cross.is_some()) + .then_some((cross.unwrap_or(0) - horizontal_side).max(0)), + content_height: Some((main - vertical_side).max(0)), + declared_width: None, + }, + }) +} + +fn render_flex_sized_entry( + item: &FlexRuntimeItem<'_>, + axis: FlexAxis, + main: i64, + cross: Option, + container_align: FlexAlign, + context: LayoutContext, + intrinsic: bool, +) -> Result { + let align = flex_resolved_align(item, container_align); + let stretch = matches!(align, FlexAlign::Stretch | FlexAlign::Normal); + let source_box = !flex_item_uses_inline_viewport(item.source); + let item_viewport = flex_inline_viewport(axis, Some(main), cross); + let render_context = LayoutContext { + viewport_width: if source_box { + context.viewport_width + } else { + item_viewport.unwrap_or(context.viewport_width) + }, + viewport_width_known: if source_box { + context.viewport_width_known + } else { + item_viewport.is_some() || context.viewport_width_known + }, + viewport_height: context.viewport_height, + }; + let override_size = box_override_for_flex(item.source, axis, main, cross, stretch); + let mut rendered = + render_node_with_override(item.source, render_context, intrinsic, override_size)?; + match axis { + FlexAxis::Row => { + rendered = pad_rendered_width(rendered, main, FlexAlign::FlexStart); + } + FlexAxis::Column => { + let width = rendered.max_width(); + rendered = pad_rendered_height(rendered, main, 0, width); + } + } + if let Some(cross) = cross { + match axis { + FlexAxis::Row => { + let width = main.max(rendered.max_width()); + let extra = (cross - rendered.height()).max(0); + rendered = + pad_rendered_height(rendered, cross, flex_cross_offset(align, extra), width); + } + FlexAxis::Column => { + rendered = pad_rendered_width(rendered, cross, align); + } + } + } + let rendered_cross = cross.unwrap_or_else(|| match axis { + FlexAxis::Row => rendered.height(), + FlexAxis::Column => rendered.max_width(), + }); + Ok(FlexSizedEntry { + rendered, + cross: rendered_cross, + }) +} + +fn concat_horizontal_sized(parts: Vec<(Rendered, i64)>, target_height: i64) -> Rendered { + let height = parts + .iter() + .map(|(rendered, _)| rendered.height()) + .max() + .unwrap_or(1) + .max(target_height); + let mut lines = Vec::with_capacity(height as usize); + for index in 0..height as usize { + let mut line = Line::default(); + for (rendered, width) in &parts { + if let Some(part) = rendered.lines.get(index) { + line.append(part); + } else { + line.push_space(*width); + } + } + lines.push(line); + } + Rendered::from_lines(lines) +} + +fn stack_vertical(parts: Vec) -> Rendered { + let mut lines = Vec::new(); + let mut breaks = Vec::new(); + for mut part in parts { + if part.lines.is_empty() { + continue; + } + if !lines.is_empty() { + breaks.push(AtomProperties::default()); + } + lines.append(&mut part.lines); + breaks.append(&mut part.breaks); + } + if lines.is_empty() { + lines.push(Line::default()); + } + Rendered { lines, breaks } +} + +fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered { + let start = usize::try_from(start.max(0)).unwrap_or(usize::MAX); + let height = usize::try_from(height.max(0)).unwrap_or(0); + let end = start.saturating_add(height).min(rendered.lines.len()); + if start >= end { + return Rendered { + lines: Vec::new(), + breaks: Vec::new(), + }; + } + let line_count = end - start; + let lines = rendered + .lines + .into_iter() + .skip(start) + .take(line_count) + .collect::>(); + let breaks = rendered + .breaks + .into_iter() + .skip(start) + .take(line_count.saturating_sub(1)) + .collect::>(); + Rendered { lines, breaks } +} + +fn exact_rendered_height(node: &LayoutNode, context: LayoutContext) -> Option { + match node { + LayoutNode::Box { + height, + min_height, + max_height, + box_sizing, + padding_top, + padding_bottom, + margin_top, + margin_bottom, + overflow, + .. + } => { + if *overflow == Overflow::Visible { + return None; + } + let content_height = resolve_definite_box_content_height( + height, + min_height, + max_height, + context, + *box_sizing, + *padding_top, + *padding_bottom, + None, + )?; + Some(content_height + padding_top + padding_bottom + margin_top + margin_bottom) + } + LayoutNode::Row { children } => children + .iter() + .map(|child| exact_rendered_height(child, context)) + .try_fold(1_i64, |maximum, height| { + height.map(|height| maximum.max(height)) + }), + LayoutNode::Column { children } => children + .iter() + .map(|child| exact_rendered_height(child, context)) + .try_fold(0_i64, |total, height| { + height.and_then(|height| total.checked_add(height)) + }), + LayoutNode::Flex { .. } => None, + } +} + +fn render_node_window( + node: &LayoutNode, + context: LayoutContext, + intrinsic: bool, + start: i64, + height: i64, +) -> Option> { + let total_height = exact_rendered_height(node, context)?; + if start <= 0 && height >= total_height { + return Some(render_node(node, context, intrinsic)); + } + match node { + LayoutNode::Column { children } if !intrinsic && context.viewport_width_known => { + Some(render_column_window(children, context, start, height)) + } + _ => Some( + render_node(node, context, intrinsic) + .map(|rendered| slice_rendered(rendered, start, height)), + ), + } +} + +fn render_column_window( + children: &[LayoutNode], + context: LayoutContext, + start: i64, + height: i64, +) -> Result { + let mut leaves = Vec::new(); + collect_column_leaves(children, &mut leaves); + let end = start.saturating_add(height).max(start); + let target = context.viewport_width.max(0); + let mut offset = 0_i64; + let mut parts = Vec::new(); + + for child in leaves { + let child_height = exact_rendered_height(child, context) + .ok_or_else(|| "Native layout column window has an unbounded child".to_owned())?; + let child_end = offset.saturating_add(child_height); + if child_end <= start { + offset = child_end; + continue; + } + if offset >= end { + break; + } + + let child_start = start.saturating_sub(offset); + let child_window_height = (child_end.min(end) - (offset + child_start)).max(0); + let mut rendered = if child_start == 0 && child_window_height >= child_height { + render_node(child, context, false)? + } else { + render_node_window(child, context, false, child_start, child_window_height) + .unwrap_or_else(|| { + render_node(child, context, false) + .map(|rendered| slice_rendered(rendered, child_start, child_window_height)) + })? + }; + + let extra = (target - rendered.first_width()).max(0); + for line in &mut rendered.lines { + line.push_space(extra); + } + if extra > 0 { + rendered.breaks = + vec![AtomProperties::default(); rendered.lines.len().saturating_sub(1)]; + } + if !rendered.lines.is_empty() { + parts.push(rendered); + } + offset = child_end; + } + + Ok(stack_vertical(parts)) +} + +fn flex_line_cross( + line: &[FlexRuntimeItem<'_>], + axis: FlexAxis, + container_align: FlexAlign, + context: LayoutContext, + intrinsic: bool, +) -> Result { + let mut maximum = 1; + for item in line { + maximum = maximum.max( + render_flex_sized_entry( + item, + axis, + item.target, + None, + container_align, + context, + intrinsic, + )? + .cross, + ); + } + Ok(maximum) +} + +fn flex_layout_cross( + crosses: Vec, + container_cross: Option, + cross_gap: i64, + align: FlexAlign, + single_line: bool, +) -> FlexCrossLayout { + let count = crosses.len(); + let natural = crosses.iter().sum::() + cross_gap * count.saturating_sub(1) as i64; + let leftover = container_cross.map_or(0, |cross| cross - natural); + if let Some(cross) = container_cross { + if single_line && count == 1 { + return FlexCrossLayout { + crosses: vec![cross], + leading: 0, + between: cross_gap, + }; + } + if leftover > 0 && matches!(align, FlexAlign::Stretch | FlexAlign::Normal) { + let extras = flex_distribute(leftover as f64, &vec![1.0; count]); + return FlexCrossLayout { + crosses: crosses + .into_iter() + .zip(extras) + .map(|(value, extra)| value + extra) + .collect(), + leading: 0, + between: cross_gap, + }; + } + } + let (leading, between, _) = flex_spacing(align, leftover, count, cross_gap); + FlexCrossLayout { + crosses, + leading, + between, + } +} + +#[allow(clippy::too_many_arguments)] +fn render_flex_row_line( + line: &[FlexRuntimeItem<'_>], + line_cross: i64, + main_size: i64, + main_gap: i64, + justify: FlexAlign, + align: FlexAlign, + context: LayoutContext, + intrinsic: bool, +) -> Result { + let line_main = flex_line_main_size(line, main_gap); + let (leading, between, trailing) = + flex_spacing(justify, main_size - line_main, line.len(), main_gap); + let mut parts = vec![(Rendered::from_lines(vec![Line::blank(leading)]), leading)]; + for (index, item) in line.iter().enumerate() { + let entry = render_flex_sized_entry( + item, + FlexAxis::Row, + item.target, + Some(line_cross), + align, + context, + intrinsic, + )?; + parts.push((entry.rendered, item.target)); + if index + 1 < line.len() { + parts.push((Rendered::from_lines(vec![Line::blank(between)]), between)); + } + } + if trailing > 0 { + parts.push((Rendered::from_lines(vec![Line::blank(trailing)]), trailing)); + } + Ok(concat_horizontal_sized(parts, line_cross)) +} + +#[allow(clippy::too_many_arguments)] +fn render_flex_column_line( + line: &[FlexRuntimeItem<'_>], + line_cross: i64, + main_size: Option, + main_gap: i64, + justify: FlexAlign, + align: FlexAlign, + context: LayoutContext, + intrinsic: bool, +) -> Result { + let line_main = flex_line_main_size(line, main_gap); + let target_main = main_size.unwrap_or(line_main); + let (leading, between, _) = + flex_spacing(justify, target_main - line_main, line.len(), main_gap); + let mut parts = Vec::new(); + if leading > 0 { + parts.push(Rendered::from_lines( + (0..leading).map(|_| Line::blank(line_cross)).collect(), + )); + } + for (index, item) in line.iter().enumerate() { + parts.push( + render_flex_sized_entry( + item, + FlexAxis::Column, + item.target, + Some(line_cross), + align, + context, + intrinsic, + )? + .rendered, + ); + if index + 1 < line.len() && between > 0 { + parts.push(Rendered::from_lines( + (0..between).map(|_| Line::blank(line_cross)).collect(), + )); + } + } + let rendered = stack_vertical(parts); + Ok(pad_rendered_height(rendered, target_main, 0, line_cross)) +} + +#[allow(clippy::too_many_arguments)] +fn render_flex_row( + mut lines: Vec>>, + main_size: Option, + cross_size: Option, + main_gap: i64, + cross_gap: i64, + justify: FlexAlign, + align: FlexAlign, + align_content: FlexAlign, + single_line: bool, + context: LayoutContext, + intrinsic: bool, +) -> Result { + for line in &mut lines { + flex_size_line(line, main_size, main_gap); + } + let container_main = main_size.unwrap_or_else(|| { + lines + .iter() + .map(|line| flex_line_main_size(line, main_gap)) + .max() + .unwrap_or(0) + }); + let crosses = if cross_size.is_some() && single_line { + vec![cross_size.unwrap_or(0)] + } else { + lines + .iter() + .map(|line| flex_line_cross(line, FlexAxis::Row, align, context, intrinsic)) + .collect::, _>>()? + }; + let cross_layout = + flex_layout_cross(crosses, cross_size, cross_gap, align_content, single_line); + let mut parts = Vec::new(); + if cross_layout.leading > 0 { + parts.push(Rendered::from_lines( + (0..cross_layout.leading) + .map(|_| Line::blank(container_main)) + .collect(), + )); + } + let line_count = lines.len(); + for (index, (line, line_cross)) in lines + .iter() + .zip(cross_layout.crosses.iter().copied()) + .enumerate() + { + parts.push(render_flex_row_line( + line, + line_cross, + container_main, + main_gap, + justify, + align, + context, + intrinsic, + )?); + if index + 1 < line_count && cross_layout.between > 0 { + parts.push(Rendered::from_lines( + (0..cross_layout.between) + .map(|_| Line::blank(container_main)) + .collect(), + )); + } + } + let rendered = stack_vertical(parts); + Ok(if let Some(cross) = cross_size { + pad_rendered_height(rendered, cross, 0, container_main) + } else { + rendered + }) +} + +#[allow(clippy::too_many_arguments)] +fn render_flex_column( + mut lines: Vec>>, + main_size: Option, + cross_size: Option, + main_gap: i64, + cross_gap: i64, + justify: FlexAlign, + align: FlexAlign, + align_content: FlexAlign, + single_line: bool, + context: LayoutContext, + intrinsic: bool, +) -> Result { + for line in &mut lines { + flex_size_line(line, main_size, main_gap); + } + let crosses = if cross_size.is_some() && single_line { + vec![cross_size.unwrap_or(0)] + } else { + lines + .iter() + .map(|line| flex_line_cross(line, FlexAxis::Column, align, context, intrinsic)) + .collect::, _>>()? + }; + let cross_layout = + flex_layout_cross(crosses, cross_size, cross_gap, align_content, single_line); + let mut parts = vec![( + Rendered::from_lines(vec![Line::blank(cross_layout.leading)]), + cross_layout.leading, + )]; + let line_count = lines.len(); + for (index, (line, line_cross)) in lines + .iter() + .zip(cross_layout.crosses.iter().copied()) + .enumerate() + { + let rendered = render_flex_column_line( + line, line_cross, main_size, main_gap, justify, align, context, intrinsic, + )?; + parts.push((rendered, line_cross)); + if index + 1 < line_count { + parts.push(( + Rendered::from_lines(vec![Line::blank(cross_layout.between)]), + cross_layout.between, + )); + } + } + let mut rendered = concat_horizontal_sized(parts, 1); + if let Some(cross) = cross_size { + rendered = pad_rendered_width(rendered, cross, FlexAlign::FlexStart); + } + Ok(rendered) +} + +#[allow(clippy::too_many_arguments)] +fn render_flex( + direction: FlexDirection, + wrap: FlexWrap, + justify: FlexAlign, + align_items: FlexAlign, + align_content: FlexAlign, + width: &Size, + height: &Size, + row_gap: i64, + column_gap: i64, + source_items: &[FlexItem], + context: LayoutContext, + intrinsic: bool, +) -> Result { + let axis = flex_axis(direction); + let width = flex_horizontal_size(width, context); + let height = flex_vertical_size(height, context); + let (main_size, cross_size) = match axis { + FlexAxis::Row => (width, height), + FlexAxis::Column => (height, width), + }; + let inline_viewport = flex_inline_viewport(axis, main_size, cross_size); + let mut indices = (0..source_items.len()).collect::>(); + indices.sort_by_key(|index| (source_items[*index].order, *index)); + let mut items = indices + .into_iter() + .map(|index| measure_flex_item(&source_items[index], axis, inline_viewport, context)) + .collect::, _>>()?; + if flex_direction_reversed(direction) { + items.reverse(); + } + let (main_gap, cross_gap) = match axis { + FlexAxis::Row => (column_gap, row_gap), + FlexAxis::Column => (row_gap, column_gap), + }; + let mut lines = flex_break_lines(items, main_size, main_gap, wrap); + if wrap == FlexWrap::WrapReverse { + lines.reverse(); + } + let single_line = wrap == FlexWrap::Nowrap; + match axis { + FlexAxis::Row => render_flex_row( + lines, + main_size, + cross_size, + main_gap, + cross_gap, + justify, + align_items, + align_content, + single_line, + context, + intrinsic, + ), + FlexAxis::Column => render_flex_column( + lines, + main_size, + cross_size, + main_gap, + cross_gap, + justify, + align_items, + align_content, + single_line, + context, + intrinsic, + ), + } +} + +#[derive(Debug, Clone, Copy, Default)] +struct BoxOverride { + content_width: Option, + content_height: Option, + declared_width: Option, +} + +fn render_node( + node: &LayoutNode, + context: LayoutContext, + intrinsic: bool, +) -> Result { + render_node_with_override(node, context, intrinsic, None) +} + +fn render_node_with_override( + node: &LayoutNode, + context: LayoutContext, + intrinsic: bool, + size_override: Option, +) -> Result { + #[cfg(test)] + TEST_RENDER_NODE_COUNT.with(|count| { + if let Some(value) = count.get() { + count.set(Some(value + 1)); + } + }); + match node { + LayoutNode::Box { + region_id, + content, + child, + content_width_exact, + content_min_width: _, + width, + min_width, + max_width, + height, + min_height, + max_height, + box_sizing, + padding_left, + padding_right, + padding_top, + padding_bottom, + margin_left, + margin_right, + margin_top, + margin_bottom, + border_left, + border_right, + foreground_style, + background_style, + border_left_style, + border_right_style, + border_top_style, + border_bottom_style, + surface_template_id, + text_align, + vertical_align: vertical, + overflow, + wrap_mode, + scroll_offset, + } => { + let declared_width_override = + size_override.and_then(|override_size| override_size.declared_width); + let declared_width_size = declared_width_override.map(|value| Size::Pixels { value }); + let effective_width = declared_width_size.as_ref().unwrap_or(width); + let side_width = padding_left + + padding_right + + margin_left + + margin_right + + border_left + + border_right; + let stretch = context + .viewport_width_known + .then_some((context.viewport_width - side_width).max(0)); + + let preliminary_child_width = size_override + .and_then(|override_size| override_size.content_width) + .or_else(|| { + resolve_child_viewport_width( + effective_width, + min_width, + max_width, + context, + *box_sizing, + *padding_left, + *padding_right, + *border_left, + *border_right, + stretch, + ) + }); + let preliminary_child_height = size_override + .and_then(|override_size| override_size.content_height) + .or_else(|| { + resolve_height( + height, + None, + context, + *box_sizing, + *padding_top, + *padding_bottom, + ) + }); + let intrinsic_child = matches!(effective_width, Size::MaxContent); + let definite_content_width = resolve_definite_box_content_width( + effective_width, + min_width, + max_width, + context, + *box_sizing, + *padding_left, + *padding_right, + *border_left, + *border_right, + stretch, + size_override.and_then(|override_size| override_size.content_width), + ); + let definite_content_height = resolve_definite_box_content_height( + height, + min_height, + max_height, + context, + *box_sizing, + *padding_top, + *padding_bottom, + size_override.and_then(|override_size| override_size.content_height), + ); + let window_rendering_disabled = { + #[cfg(test)] + { + TEST_DISABLE_WINDOW_RENDER.with(Cell::get) + } + #[cfg(not(test))] + { + false + } + }; + let simple_scroll_window = *overflow == Overflow::Scroll + && child.is_some() + && definite_content_width.is_some() + && definite_content_height.is_some() + && !intrinsic + && !intrinsic_child + && !window_rendering_disabled + && *padding_left == 0 + && *padding_right == 0 + && *padding_top == 0 + && *padding_bottom == 0 + && *margin_left == 0 + && *margin_right == 0 + && *margin_top == 0 + && *margin_bottom == 0 + && *border_left == 0 + && *border_right == 0 + && border_top_style.is_none() + && border_bottom_style.is_none() + && *vertical == VerticalAlign::Top; + let mut windowed_child_start = None; + let mut windowed_child_height = None; + let mut child_rendered = if let Some(child) = child { + let child_context = LayoutContext { + viewport_width: if intrinsic_child { + 0 + } else { + preliminary_child_width.unwrap_or(context.viewport_width) + }, + viewport_width_known: !intrinsic_child + && (preliminary_child_width.is_some() || context.viewport_width_known), + viewport_height: preliminary_child_height.unwrap_or(context.viewport_height), + }; + if simple_scroll_window { + if let Some(total_height) = exact_rendered_height(child, child_context) { + let content_height = definite_content_height.expect("checked above"); + let max_offset = (total_height - content_height).max(0); + let start = (*scroll_offset).max(0).min(max_offset); + windowed_child_start = Some(start); + windowed_child_height = Some(total_height); + Some( + render_node_window( + child, + child_context, + intrinsic || intrinsic_child, + start, + content_height, + ) + .expect("exact height checked above")?, + ) + } else { + Some(render_node( + child, + child_context, + intrinsic || intrinsic_child, + )?) + } + } else { + Some(render_node( + child, + child_context, + intrinsic || intrinsic_child, + )?) + } + } else { + None + }; + + let (min_content, max_content) = if let Some(text) = content { + ( + measured_min_width(text, *wrap_mode), + measured_max_width(text), + ) + } else { + let rendered = child_rendered.as_ref().expect("validated child"); + (rendered.min_content_width(*wrap_mode), rendered.max_width()) + }; + let auto_width = stretch.unwrap_or(max_content); + let minimum = resolve_width( + min_width, + Some(0), + context, + *box_sizing, + *padding_left, + *padding_right, + *border_left, + *border_right, + stretch, + min_content, + max_content, + ) + .unwrap_or(0); + let maximum = resolve_width( + max_width, + None, + context, + *box_sizing, + *padding_left, + *padding_right, + *border_left, + *border_right, + stretch, + min_content, + max_content, + ) + .unwrap_or(i64::MAX); + let preferred = size_override + .and_then(|override_size| override_size.content_width) + .unwrap_or_else(|| { + resolve_width( + effective_width, + Some(auto_width), + context, + *box_sizing, + *padding_left, + *padding_right, + *border_left, + *border_right, + stretch, + min_content, + max_content, + ) + .unwrap_or(auto_width) + }); + let content_width = minimum.max(preferred.max(0).min(maximum)); + let transparent_preformatted = child.is_some() + && *content_width_exact + && *wrap_mode == WrapMode::None + && *text_align == HorizontalAlign::Left + && *vertical == VerticalAlign::Top + && matches!(height, Size::Auto) + && matches!(min_height, Size::Lines { value: 0 }) + && matches!(max_height, Size::None) + && *overflow == Overflow::Scroll + && *padding_left == 0 + && *padding_right == 0 + && *padding_top == 0 + && *padding_bottom == 0 + && *margin_left == 0 + && *margin_right == 0 + && *margin_top == 0 + && *margin_bottom == 0 + && *border_left == 0 + && *border_right == 0 + && foreground_style.is_none() + && background_style.is_none() + && border_left_style.is_none() + && border_right_style.is_none() + && border_top_style.is_none() + && border_bottom_style.is_none() + && child_rendered.as_ref().is_some_and(|rendered| { + rendered + .lines + .iter() + .all(|line| !line.atoms.is_empty() && line.width == content_width) + }); + if transparent_preformatted { + let mut rendered = child_rendered.take().expect("validated child"); + for (index, line) in rendered.lines.iter_mut().enumerate() { + line.own_content(*region_id, index as i64); + line.apply_property_template(*surface_template_id); + } + return Ok(rendered); + } + + let mut formatted = if let Some(text) = content { + measured_lines(text, content_width, *wrap_mode) + .into_iter() + .map(|line| line.padded(content_width, *text_align)) + .collect::>() + } else { + let rendered = child_rendered.expect("validated child"); + let uniform_width = rendered + .lines + .iter() + .all(|line| line.width == content_width); + let preserve_exact_width = *content_width_exact && *wrap_mode == WrapMode::None; + let rendered = if *wrap_mode != WrapMode::None && !uniform_width { + wrap_rendered(rendered, content_width, *wrap_mode) + } else { + rendered + }; + rendered + .lines + .into_iter() + .map(|line| { + if preserve_exact_width { + line + } else { + line.padded(content_width, *text_align) + } + }) + .collect::>() + }; + let text_height = windowed_child_height.unwrap_or(formatted.len() as i64); + let minimum_height = resolve_height( + min_height, + Some(0), + context, + *box_sizing, + *padding_top, + *padding_bottom, + ) + .unwrap_or(0); + let maximum_height = resolve_height( + max_height, + None, + context, + *box_sizing, + *padding_top, + *padding_bottom, + ) + .unwrap_or(i64::MAX); + let preferred_height = size_override + .and_then(|override_size| override_size.content_height) + .unwrap_or_else(|| { + resolve_height( + height, + None, + context, + *box_sizing, + *padding_top, + *padding_bottom, + ) + .unwrap_or(text_height) + }); + let content_height = minimum_height + .max(1) + .max(preferred_height.max(1).min(maximum_height)); + let simple_scroll_rendered = *overflow == Overflow::Scroll + && text_height >= content_height + && *padding_left == 0 + && *padding_right == 0 + && *padding_top == 0 + && *padding_bottom == 0 + && *margin_left == 0 + && *margin_right == 0 + && *margin_top == 0 + && *margin_bottom == 0 + && *border_left == 0 + && *border_right == 0 + && border_top_style.is_none() + && border_bottom_style.is_none() + && *vertical == VerticalAlign::Top; + + let mut content_index_start = 0_i64; + let mut overflow_lines = Vec::new(); + if let Some(start) = windowed_child_start { + content_index_start = start; + } else if formatted.len() > content_height as usize { + match overflow { + Overflow::Scroll => { + let max_offset = formatted.len() - content_height as usize; + let start = (*scroll_offset as usize).min(max_offset); + if simple_scroll_rendered { + content_index_start = start as i64; + } + formatted = formatted[start..start + content_height as usize].to_vec(); + } + Overflow::Hidden => { + formatted.truncate(content_height as usize); + } + Overflow::Visible => { + overflow_lines = formatted.split_off(content_height as usize); + } + } + } + for (index, line) in formatted.iter_mut().enumerate() { + line.own_content(*region_id, content_index_start + index as i64); + } + let mut lines = + vertical_align(formatted, content_height as usize, *vertical, content_width) + .into_iter() + .map(|line| { + if simple_scroll_rendered { + line + } else { + line.collapse_whitespace_content(content_width, *region_id) + } + }) + .collect::>(); + + let mut padded = + Vec::with_capacity(lines.len() + *padding_top as usize + *padding_bottom as usize); + padded.extend((0..*padding_top).map(|_| { + Line::blank_with_properties( + content_width, + region_properties(RegionRole::PaddingTop, *region_id, None), + ) + })); + padded.append(&mut lines); + padded.extend((0..*padding_bottom).map(|_| { + Line::blank_with_properties( + content_width, + region_properties(RegionRole::PaddingBottom, *region_id, None), + ) + })); + + for line in &mut padded { + line.prepend_space_with_properties( + *padding_left, + region_properties(RegionRole::PaddingLeft, *region_id, None), + ); + line.push_space_with_properties( + *padding_right, + region_properties(RegionRole::PaddingRight, *region_id, None), + ); + line.apply_style(*foreground_style); + line.apply_style(*background_style); + line.prepend_space_with_properties( + *border_left, + region_properties(RegionRole::BorderLeft, *region_id, *border_left_style), + ); + line.push_space_with_properties( + *border_right, + region_properties(RegionRole::BorderRight, *region_id, *border_right_style), + ); + } + if border_top_style.is_some() { + if let Some(first) = padded.first_mut() { + first.apply_style(*border_top_style); + first.apply_role(RegionRole::BorderTop, *region_id); + } + } + if border_bottom_style.is_some() { + if let Some(last) = padded.last_mut() { + last.apply_style(*border_bottom_style); + last.apply_role(RegionRole::BorderBottom, *region_id); + } + } + for line in &mut padded { + line.apply_property_template(*surface_template_id); + } + for line in &mut padded { + line.prepend_space_with_properties( + *margin_left, + region_properties(RegionRole::MarginLeft, *region_id, None), + ); + line.push_space_with_properties( + *margin_right, + region_properties(RegionRole::MarginRight, *region_id, None), + ); + } + let total_width = content_width + side_width; + let mut output = + Vec::with_capacity(padded.len() + *margin_top as usize + *margin_bottom as usize); + output.extend((0..*margin_top).map(|_| { + Line::blank_with_properties( + total_width, + region_properties(RegionRole::MarginTop, *region_id, None), + ) + })); + output.append(&mut padded); + output.extend((0..*margin_bottom).map(|_| { + Line::blank_with_properties( + total_width, + region_properties(RegionRole::MarginBottom, *region_id, None), + ) + })); + let mut rendered = Rendered::from_lines(output); + if !overflow_lines.is_empty() { + let left_space = margin_left + border_left + padding_left; + let right_space = padding_right + border_right + margin_right; + for line in &mut overflow_lines { + line.apply_style(*foreground_style); + line.prepend_space(left_space); + line.push_space(right_space); + } + rendered = stack_vertical(vec![rendered, Rendered::from_lines(overflow_lines)]); + } + if *overflow == Overflow::Scroll && text_height > content_height { + rendered.apply_scroll_window(*region_id); + } + Ok(rendered) + } + LayoutNode::Row { children } => { + let rendered = children + .iter() + .map(|child| render_node(child, context, intrinsic)) + .collect::, _>>()?; + let height = rendered + .iter() + .map(|item| item.lines.len()) + .max() + .unwrap_or(1); + let mut lines = Vec::with_capacity(height); + for index in 0..height { + let mut line = Line::default(); + for item in &rendered { + if let Some(child_line) = item.lines.get(index) { + line.append(child_line); + } else { + line.push_space(item.first_width()); + } + } + lines.push(line); + } + Ok(Rendered::from_lines(lines)) + } + LayoutNode::Column { children } => { + let mut leaves = Vec::new(); + collect_column_leaves(children, &mut leaves); + let rendered = leaves + .into_iter() + .map(|child| render_node(child, context, intrinsic)) + .collect::, _>>()?; + let maximum = rendered + .iter() + .map(Rendered::first_width) + .max() + .unwrap_or(0); + let target = if intrinsic || !context.viewport_width_known { + maximum + } else { + context.viewport_width.max(0) + }; + let mut parts = Vec::with_capacity(rendered.len()); + for mut item in rendered { + let extra = (target - item.first_width()).max(0); + for line in &mut item.lines { + line.push_space(extra); + } + if extra > 0 { + item.breaks = + vec![AtomProperties::default(); item.lines.len().saturating_sub(1)]; + } + parts.push(item); + } + Ok(stack_vertical(parts)) + } + LayoutNode::Flex { + direction, + wrap, + justify, + align_items, + align_content, + width, + height, + row_gap, + column_gap, + items, + } => render_flex( + *direction, + *wrap, + *justify, + *align_items, + *align_content, + width, + height, + *row_gap, + *column_gap, + items, + context, + intrinsic, + ), + } +} + +fn collect_column_leaves<'a>(children: &'a [LayoutNode], output: &mut Vec<&'a LayoutNode>) { + for child in children { + if let LayoutNode::Column { children } = child { + collect_column_leaves(children, output); + } else { + output.push(child); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn counted_layout_tape( + document: &LayoutDocument, + context: LayoutContext, + ) -> (LayoutTape, usize) { + TEST_RENDER_NODE_COUNT.with(|count| count.set(Some(0))); + let tape = document.layout_tape(context, None).unwrap(); + let count = TEST_RENDER_NODE_COUNT.with(|count| count.replace(None).unwrap()); + (tape, count) + } + + fn eager_layout_tape(document: &LayoutDocument, context: LayoutContext) -> LayoutTape { + TEST_DISABLE_WINDOW_RENDER.with(|disabled| disabled.set(true)); + let tape = document.layout_tape(context, None).unwrap(); + TEST_DISABLE_WINDOW_RENDER.with(|disabled| disabled.set(false)); + tape + } + + fn document(json: &str) -> LayoutDocument { + serde_json::from_str(json).unwrap() + } + + fn cluster(text: &str, width: i64, source_template_id: Option) -> MeasuredCluster { + MeasuredCluster { + text: text.to_owned(), + width, + cjk: false, + space: text == " ", + pixel_space: false, + source_template_id, + } + } + + fn measured_text(lines: Vec>) -> MeasuredText { + MeasuredText { + lines: lines + .into_iter() + .map(|clusters| MeasuredLine { clusters }) + .collect(), + } + } + + fn text_box( + region_id: i64, + content: MeasuredText, + surface_template_id: Option, + ) -> LayoutNode { + LayoutNode::Box { + region_id, + content: Some(Box::new(content)), + child: None, + content_width_exact: false, + content_min_width: None, + width: Size::Content, + min_width: Size::Pixels { value: 0 }, + max_width: Size::None, + height: Size::Auto, + min_height: Size::Lines { value: 0 }, + max_height: Size::None, + box_sizing: BoxSizing::BorderBox, + padding_left: 0, + padding_right: 0, + padding_top: 0, + padding_bottom: 0, + margin_left: 0, + margin_right: 0, + margin_top: 0, + margin_bottom: 0, + border_left: 0, + border_right: 0, + foreground_style: None, + background_style: None, + border_left_style: None, + border_right_style: None, + border_top_style: None, + border_bottom_style: None, + surface_template_id, + text_align: HorizontalAlign::Left, + vertical_align: VerticalAlign::Top, + overflow: Overflow::Scroll, + wrap_mode: WrapMode::None, + scroll_offset: 0, + } + } + + fn child_box( + region_id: i64, + child: LayoutNode, + surface_template_id: Option, + ) -> LayoutNode { + LayoutNode::Box { + region_id, + content: None, + child: Some(Box::new(child)), + content_width_exact: true, + content_min_width: None, + width: Size::Content, + min_width: Size::Pixels { value: 0 }, + max_width: Size::None, + height: Size::Auto, + min_height: Size::Lines { value: 0 }, + max_height: Size::None, + box_sizing: BoxSizing::BorderBox, + padding_left: 0, + padding_right: 0, + padding_top: 0, + padding_bottom: 0, + margin_left: 0, + margin_right: 0, + margin_top: 0, + margin_bottom: 0, + border_left: 0, + border_right: 0, + foreground_style: None, + background_style: None, + border_left_style: None, + border_right_style: None, + border_top_style: None, + border_bottom_style: None, + surface_template_id, + text_align: HorizontalAlign::Left, + vertical_align: VerticalAlign::Top, + overflow: Overflow::Scroll, + wrap_mode: WrapMode::None, + scroll_offset: 0, + } + } + + fn fixed_scroll_column_document(scroll_offset: i64) -> LayoutDocument { + let children = (0..20) + .map(|index| { + let mut node = text_box( + index + 2, + measured_text(vec![vec![cluster(&format!("line-{index:03}"), 8, None)]]), + Some((index % 2) as u32), + ); + let LayoutNode::Box { width, height, .. } = &mut node else { + unreachable!(); + }; + *width = Size::Viewport; + *height = Size::Lines { value: 1 }; + node + }) + .collect(); + let mut root = child_box(1, LayoutNode::Column { children }, Some(2)); + let LayoutNode::Box { + width, + height, + content_width_exact, + scroll_offset: offset, + .. + } = &mut root + else { + unreachable!(); + }; + *width = Size::Viewport; + *height = Size::Lines { value: 5 }; + *content_width_exact = false; + *offset = scroll_offset; + LayoutDocument { + version: LAYOUT_VERSION, + space_width: 1, + style_count: 0, + property_template_count: 3, + styles: Vec::new(), + root, + } + } + + fn test_context() -> LayoutContext { + LayoutContext { + viewport_width: 80, + viewport_width_known: true, + viewport_height: 24, + } + } + + fn complete_identity() -> TapeIdentity { + TapeIdentity { + session_id: 1, + generation: 2, + key: 3, + runtime_revision: 4, + context_hash: 5, + viewport_width: 80, + viewport_height: 24, + root_width: 80, + complete: true, + } + } + + fn literal_from_full_tape(tape: &[u8]) -> &str { + let literal_length = u64::from_le_bytes(tape[112..120].try_into().unwrap()) as usize; + let line_count = u32::from_le_bytes(tape[120..124].try_into().unwrap()) as usize; + let literal_start = 136 + line_count * 8; + std::str::from_utf8(&tape[literal_start..literal_start + literal_length]).unwrap() + } + + fn metadata_from_full_tape(tape: &[u8]) -> &str { + let literal_length = u64::from_le_bytes(tape[112..120].try_into().unwrap()) as usize; + let line_count = u32::from_le_bytes(tape[120..124].try_into().unwrap()) as usize; + let metadata_length = u32::from_le_bytes(tape[124..128].try_into().unwrap()) as usize; + let literal_start = 136 + line_count * 8; + let metadata_start = literal_start + literal_length; + std::str::from_utf8(&tape[metadata_start..metadata_start + metadata_length]).unwrap() + } + + #[test] + fn rejects_invalid_layout_versions_and_negative_geometry() { + let invalid_version = document( + r#"{"version":3,"space-width":8,"style-count":0,"root":{"type":"row","children":[{"type":"column","children":[]}]}}"#, + ); + assert!(invalid_version.validate().is_err()); + + let negative = document( + r#"{"version":2,"space-width":8,"style-count":0,"root":{"type":"box","region-id":1,"content":{"lines":[{"clusters":[{"text":"x","width":8,"cjk":false,"space":false}]}]},"child":null,"content-width-exact":false,"width":{"kind":"pixels","value":-1},"min-width":{"kind":"pixels","value":0},"max-width":{"kind":"none"},"height":{"kind":"auto"},"min-height":{"kind":"lines","value":0},"max-height":{"kind":"none"},"box-sizing":"border-box","padding-left":0,"padding-right":0,"padding-top":0,"padding-bottom":0,"margin-left":0,"margin-right":0,"margin-top":0,"margin-bottom":0,"border-left":0,"border-right":0,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}"#, + ); + assert!(negative.validate().is_err()); + assert!(validate_dimension("width", MAX_LAYOUT_DIMENSION + 1).is_err()); + let mut work_units = MAX_LAYOUT_WORK_UNITS; + assert!(add_work_units(&mut work_units, 1).is_err()); + let mut context_work = 0; + assert!(add_context_size_work( + &Size::ViewportHeight, + LayoutContext { + viewport_width: 80, + viewport_width_known: true, + viewport_height: MAX_LAYOUT_WORK_UNITS as i64 + 1, + }, + &mut context_work, + ) + .is_err()); + } + + #[test] + fn word_and_character_wrapping_preserve_measured_clusters() { + let clusters = "alpha beta" + .chars() + .map(|character| MeasuredCluster { + text: character.to_string(), + width: 1, + cjk: false, + space: character == ' ', + pixel_space: false, + source_template_id: None, + }) + .collect::>(); + let word_lines = wrap_line(&clusters, 5, WrapMode::Word); + let texts = word_lines + .iter() + .map(|line| { + line.atoms + .iter() + .map(|atom| match atom { + Atom::Text { text, .. } => text.as_str(), + Atom::Space { .. } => " ", + }) + .collect::() + }) + .collect::>(); + assert_eq!(texts, ["alpha", "beta"]); + + let character_lines = wrap_line(&clusters, 3, WrapMode::Char); + assert_eq!(character_lines.len(), 4); + assert!(character_lines.iter().all(|line| line.width <= 3)); + } + + #[test] + fn source_template_ids_follow_measured_clusters() { + let root = text_box( + 1, + measured_text(vec![vec![ + cluster("a", 1, Some(0)), + cluster("b", 1, None), + cluster("c", 1, Some(1)), + ]]), + None, + ); + let document = LayoutDocument { + version: LAYOUT_VERSION, + space_width: 1, + style_count: 0, + property_template_count: 2, + styles: Vec::new(), + root, + }; + document.validate().unwrap(); + let tape = document.layout_tape(test_context(), None).unwrap(); + let flat = flatten_layout_tape(tape, true).unwrap(); + + assert_eq!( + flat.characters + .iter() + .map(|character| character.properties.property_template_ids.as_slice()) + .collect::>(), + vec![&[0][..], &[][..], &[1][..]] + ); + } + + #[test] + fn whitespace_only_source_template_survives_content_collapse() { + let mut root = text_box(1, measured_text(vec![vec![cluster(" ", 1, Some(0))]]), None); + let LayoutNode::Box { overflow, .. } = &mut root else { + unreachable!(); + }; + *overflow = Overflow::Hidden; + + let document = LayoutDocument { + version: LAYOUT_VERSION, + space_width: 1, + style_count: 0, + property_template_count: 1, + styles: Vec::new(), + root, + }; + document.validate().unwrap(); + let tape = document.layout_tape(test_context(), None).unwrap(); + let flat = flatten_layout_tape(tape, true).unwrap(); + + assert_eq!(flat.characters.len(), 1); + assert_eq!(flat.characters[0].properties.property_template_ids, vec![0]); + } + + #[test] + fn surface_template_ids_cover_border_box_but_not_margins_or_newlines() { + let mut root = text_box( + 1, + measured_text(vec![ + vec![cluster("x", 1, None)], + vec![cluster("y", 1, None)], + ]), + Some(0), + ); + let LayoutNode::Box { + padding_left, + padding_right, + padding_top, + padding_bottom, + margin_left, + margin_right, + margin_top, + margin_bottom, + .. + } = &mut root + else { + unreachable!(); + }; + *padding_left = 1; + *padding_right = 1; + *padding_top = 1; + *padding_bottom = 1; + *margin_left = 1; + *margin_right = 1; + *margin_top = 1; + *margin_bottom = 1; + + let document = LayoutDocument { + version: LAYOUT_VERSION, + space_width: 1, + style_count: 0, + property_template_count: 1, + styles: Vec::new(), + root, + }; + document.validate().unwrap(); + let tape = document.layout_tape(test_context(), None).unwrap(); + let flat = flatten_layout_tape(tape, true).unwrap(); + let rows = flat + .characters + .split(|character| character.value == '\n') + .collect::>(); + + assert_eq!(rows.len(), 6); + assert!(rows[0] + .iter() + .all(|character| character.properties.property_template_ids.is_empty())); + assert!(rows[5] + .iter() + .all(|character| character.properties.property_template_ids.is_empty())); + for row in &rows[1..5] { + assert_eq!(row.len(), 5); + assert!(row[0].properties.property_template_ids.is_empty()); + assert_eq!(row[1].properties.property_template_ids, vec![0]); + assert_eq!(row[2].properties.property_template_ids, vec![0]); + assert_eq!(row[3].properties.property_template_ids, vec![0]); + assert!(row[4].properties.property_template_ids.is_empty()); + } + assert!(flat + .characters + .iter() + .filter(|character| character.value == '\n') + .all(|character| character.properties.property_template_ids.is_empty())); + } + + #[test] + fn nested_template_ids_are_ordered_inner_to_outer() { + let inner = text_box( + 1, + measured_text(vec![vec![cluster("x", 1, Some(0))]]), + Some(1), + ); + let outer = child_box(2, inner, Some(2)); + let document = LayoutDocument { + version: LAYOUT_VERSION, + space_width: 1, + style_count: 0, + property_template_count: 3, + styles: Vec::new(), + root: outer, + }; + document.validate().unwrap(); + let tape = document.layout_tape(test_context(), None).unwrap(); + let flat = flatten_layout_tape(tape, true).unwrap(); + + assert_eq!(flat.characters.len(), 1); + assert_eq!( + flat.characters[0].properties.property_template_ids, + vec![0, 1, 2] + ); + } + + #[test] + fn property_template_ids_are_validated_against_the_declared_table() { + let source = LayoutDocument { + version: LAYOUT_VERSION, + space_width: 1, + style_count: 0, + property_template_count: 1, + styles: Vec::new(), + root: text_box(1, measured_text(vec![vec![cluster("x", 1, Some(1))]]), None), + }; + assert!(source.validate().is_err()); + + let surface = LayoutDocument { + version: LAYOUT_VERSION, + space_width: 1, + style_count: 0, + property_template_count: 1, + styles: Vec::new(), + root: text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), Some(1)), + }; + assert!(surface.validate().is_err()); + + let oversized_table = LayoutDocument { + property_template_count: MAX_PROPERTY_TEMPLATE_COUNT + 1, + ..surface + }; + assert!(oversized_table.validate().is_err()); + + let larger_than_atom_depth = LayoutDocument { + version: LAYOUT_VERSION, + space_width: 1, + style_count: 0, + property_template_count: MAX_TAPE_PROPERTY_ENTRIES as u32 + 2, + styles: Vec::new(), + root: text_box( + 1, + measured_text(vec![vec![cluster( + "x", + 1, + Some(MAX_TAPE_PROPERTY_ENTRIES as u32 + 1), + )]]), + Some(MAX_TAPE_PROPERTY_ENTRIES as u32), + ), + }; + assert!(larger_than_atom_depth.validate().is_ok()); + } + + #[test] + fn rendered_min_content_matches_elisp_separators_and_cjk_runs() { + let clusters = vec![ + MeasuredCluster { + text: "a".to_owned(), + width: 2, + cjk: false, + space: false, + pixel_space: false, + source_template_id: None, + }, + MeasuredCluster { + text: "b".to_owned(), + width: 3, + cjk: false, + space: false, + pixel_space: false, + source_template_id: None, + }, + MeasuredCluster { + text: " ".to_owned(), + width: 4, + cjk: false, + space: true, + pixel_space: false, + source_template_id: None, + }, + MeasuredCluster { + text: "界".to_owned(), + width: 9, + cjk: true, + space: false, + pixel_space: false, + source_template_id: None, + }, + MeasuredCluster { + text: " ".to_owned(), + width: 40, + cjk: false, + space: true, + pixel_space: true, + source_template_id: None, + }, + MeasuredCluster { + text: "c".to_owned(), + width: 6, + cjk: false, + space: false, + pixel_space: false, + source_template_id: None, + }, + MeasuredCluster { + text: "d".to_owned(), + width: 7, + cjk: false, + space: false, + pixel_space: false, + source_template_id: None, + }, + ]; + let rendered = Rendered::from_lines(vec![Line::from_clusters(&clusters)]); + + assert_eq!(rendered.min_content_width(WrapMode::Word), 13); + assert_eq!(rendered.min_content_width(WrapMode::Char), 13); + assert_eq!(rendered.min_content_width(WrapMode::None), 71); + } + + #[test] + fn border_box_resolution_subtracts_only_box_chrome() { + assert_eq!( + box_sizing_content_width(100, BoxSizing::BorderBox, 8, 7, 2, 3), + 80 + ); + assert_eq!( + box_sizing_content_width(100, BoxSizing::ContentBox, 8, 7, 2, 3), + 100 + ); + assert_eq!(box_sizing_content_height(9, BoxSizing::BorderBox, 2, 1), 6); + } + + #[test] + fn bounded_intrinsic_widths_and_height_expressions_resolve_exactly() { + let context = LayoutContext { + viewport_width: 320, + viewport_width_known: true, + viewport_height: 10, + }; + let fit_content = Size::FitContent { + limit: Some(Box::new(Size::Pixels { value: 180 })), + }; + assert_eq!( + resolve_width( + &fit_content, + None, + context, + BoxSizing::ContentBox, + 0, + 0, + 0, + 0, + Some(320), + 40, + 300, + ), + Some(180) + ); + + let height = Size::Subtract { + values: Box::new(SizeValues( + vec![Size::ViewportHeight, Size::Lines { value: 2 }].into_boxed_slice(), + )), + }; + assert_eq!( + resolve_height(&height, None, context, BoxSizing::BorderBox, 1, 1), + Some(6) + ); + + let empty = Size::Add { + values: Box::new(SizeValues(Vec::new().into_boxed_slice())), + }; + assert!(validate_size("height", &empty).is_err()); + + let unavailable = LayoutContext { + viewport_width: 0, + viewport_width_known: false, + viewport_height: 0, + }; + assert_eq!( + resolve_width( + &Size::Viewport, + None, + unavailable, + BoxSizing::ContentBox, + 0, + 0, + 0, + 0, + None, + 40, + 300, + ), + None + ); + assert_eq!(resolve_raw_height(&Size::ViewportHeight, unavailable), None); + + assert_eq!( + resolve_child_viewport_width( + &Size::Pixels { value: 300 }, + &Size::Pixels { value: 0 }, + &Size::Viewport, + LayoutContext { + viewport_width: 298, + viewport_width_known: true, + viewport_height: 20, + }, + BoxSizing::BorderBox, + 0, + 0, + 0, + 0, + Some(298), + ), + Some(298) + ); + } + + #[test] + fn zero_width_remains_a_known_nested_flex_constraint() { + let known_zero = LayoutContext { + viewport_width: 0, + viewport_width_known: true, + viewport_height: 10, + }; + let unavailable = LayoutContext { + viewport_width: 0, + viewport_width_known: false, + viewport_height: 10, + }; + + assert_eq!(flex_horizontal_size(&Size::Auto, known_zero), Some(0)); + assert_eq!(flex_horizontal_size(&Size::Viewport, known_zero), Some(0)); + assert_eq!(flex_horizontal_size(&Size::Auto, unavailable), None); + assert_eq!(flex_horizontal_size(&Size::Viewport, unavailable), None); + } + + #[test] + fn flex_integer_distribution_and_spacing_preserve_elisp_rounding() { + assert_eq!(flex_distribute(7.0, &[1.0, 2.0, 1.0]), [2, 4, 1]); + assert_eq!(flex_distribute(3.9, &[1.0, 1.0]), [2, 1]); + assert_eq!(flex_spacing(FlexAlign::SpaceAround, 11, 3, 2), (1, 5, 4)); + assert_eq!(flex_spacing(FlexAlign::SpaceEvenly, 11, 3, 2), (2, 4, 2)); + } + + #[test] + fn flex_partial_fill_and_min_wins_clamping_match_the_oracle() { + assert_eq!(flex_effective_free_space(200, 200, 0.5), 100.0); + assert_eq!(flex_effective_free_space(-50, -50, 0.5), -25.0); + assert_eq!(flex_clamp_main(40, 80, Some(60)), 80); + assert_eq!(flex_clamp_main(-5, 0, None), 0); + } + + #[test] + fn vertical_stack_and_scroll_preserve_break_metadata() { + let mut first = Rendered::from_lines(vec![Line::blank(1), Line::blank(1)]); + first.breaks[0].owner = Some(11); + let second = Rendered::from_lines(vec![Line::blank(1)]); + let mut stacked = stack_vertical(vec![first, second]); + + assert_eq!(stacked.breaks.len(), 2); + assert_eq!(stacked.breaks[0].owner, Some(11)); + assert_eq!(stacked.breaks[1], AtomProperties::default()); + + stacked.apply_scroll_window(9); + assert!(stacked + .breaks + .iter() + .all(|properties| { properties.scroll_window == Some(9) })); + assert!(stacked.lines.iter().all(|line| { + line.atoms + .iter() + .all(|atom| atom.properties().scroll_window == Some(9)) + })); + + let tape = stacked.into_tape(0); + assert_eq!(tape.lines[0].break_after.as_ref().unwrap().owner, Some(11)); + assert_eq!( + tape.lines[1].break_after.as_ref().unwrap().scroll_window, + Some(9) + ); + assert!(tape.lines[2].break_after.is_none()); + } + + #[test] + fn binary_tapes_are_versioned_bounded_and_support_light_frames() { + let properties = AtomProperties { + style_ids: vec![2], + content: Some(11), + content_idx: Some(0), + owner: Some(11), + owners: vec![11], + roles: vec![RegionRoleEntry { + role: RegionRole::PaddingLeft, + region_id: 11, + }], + scroll_window: Some(11), + property_template_ids: vec![4], + }; + let mut rendered = Rendered::from_lines(vec![ + Line::blank_with_properties(8, properties.clone()), + Line::blank_with_properties(8, properties.clone()), + ]); + rendered.breaks[0] = properties; + let tape = rendered.into_tape(3); + let styles = vec![ + StyleTemplate { + mode: StyleMode::Add, + face: FaceTemplate { + inverse_video: None, + foreground: Some("red".to_owned()), + background: None, + overline: None, + underline: None, + }, + }, + StyleTemplate { + mode: StyleMode::Add, + face: FaceTemplate { + inverse_video: None, + foreground: None, + background: Some("blue".to_owned()), + overline: None, + underline: None, + }, + }, + StyleTemplate { + mode: StyleMode::Set, + face: FaceTemplate { + inverse_video: Some(true), + foreground: Some("white".to_owned()), + background: None, + overline: None, + underline: None, + }, + }, + ]; + let identity = TapeIdentity { + session_id: 7, + generation: 9, + key: 13, + runtime_revision: 17, + context_hash: -19, + viewport_width: 320, + viewport_height: 40, + root_width: 300, + complete: true, + }; + + let full = encode_layout_tape(tape.clone(), &styles, identity, true, 4096).unwrap(); + assert_eq!(&full[..4], TAPE_MAGIC); + assert_eq!( + u16::from_le_bytes(full[4..6].try_into().unwrap()), + TAPE_VERSION + ); + assert_eq!(u16::from_le_bytes(full[6..8].try_into().unwrap()), 3); + assert_eq!( + u64::from_le_bytes(full[12..20].try_into().unwrap()) as usize, + full.len() + ); + assert_eq!(u64::from_le_bytes(full[20..28].try_into().unwrap()), 7); + assert_eq!(u32::from_le_bytes(full[84..88].try_into().unwrap()), 3); + assert_eq!(u32::from_le_bytes(full[88..92].try_into().unwrap()), 2); + assert_eq!(u64::from_le_bytes(full[92..100].try_into().unwrap()), 3); + let literal_length = u64::from_le_bytes(full[112..120].try_into().unwrap()) as usize; + assert_eq!(u32::from_le_bytes(full[120..124].try_into().unwrap()), 2); + let metadata_length = u32::from_le_bytes(full[124..128].try_into().unwrap()) as usize; + let metadata_records = u64::from_le_bytes(full[128..136].try_into().unwrap()) as usize; + assert!(metadata_length > 0); + assert!(metadata_records > 0); + assert_eq!(u64::from_le_bytes(full[136..144].try_into().unwrap()), 8); + assert_eq!(u64::from_le_bytes(full[144..152].try_into().unwrap()), 8); + let literal_end = 152 + literal_length; + let literal = std::str::from_utf8(&full[152..literal_end]).unwrap(); + assert_eq!(literal.len(), literal_length); + assert!(literal.starts_with("#(\" \\n \"")); + assert!(literal.contains("face #3=(:inverse-video t :foreground \"white\")")); + assert!(literal.contains("face #3#")); + assert!(literal.contains("ebox-content 11")); + assert!(literal.contains("ebox-native-property-template-ids (4)")); + assert!(literal.contains("display (space :width (8))")); + let metadata = &full[literal_end..]; + assert_eq!(metadata.len(), metadata_length); + let metadata_literal = metadata_from_full_tape(&full); + assert!(metadata_literal.starts_with("(:prepared-p t")); + assert!(metadata_literal.contains("#s(hash-table test equal data")); + assert!(metadata_literal.contains("(11 content) ((1 . 4))")); + assert!(metadata_literal.contains("(11 pl) ((1 . 4))")); + assert!(metadata_literal.contains("data (11 (1 . 4))")); + assert!(metadata_literal.contains("data (11 ((0 0 . 3)")); + assert!(metadata_literal.ends_with(":scroll-window-p t)")); + + let full_without_root_metadata = + encode_layout_tape(tape.clone(), &styles, identity, false, 4096).unwrap(); + assert_eq!( + u16::from_le_bytes(full_without_root_metadata[6..8].try_into().unwrap()), + 3 + ); + assert_eq!( + u32::from_le_bytes(full_without_root_metadata[124..128].try_into().unwrap()), + 0 + ); + assert_eq!( + u64::from_le_bytes(full_without_root_metadata[128..136].try_into().unwrap()), + 0 + ); + let full_without_root_metadata_literal = + literal_from_full_tape(&full_without_root_metadata); + assert!(full_without_root_metadata_literal.contains("ebox-content 11")); + assert!( + full_without_root_metadata_literal.contains("ebox-native-property-template-ids (4)") + ); + + let light = encode_layout_tape( + tape.clone(), + &styles, + TapeIdentity { + complete: false, + ..identity + }, + true, + 4096, + ) + .unwrap(); + assert_eq!(u16::from_le_bytes(light[6..8].try_into().unwrap()), 1); + assert_eq!(u32::from_le_bytes(light[124..128].try_into().unwrap()), 0); + assert_eq!(u64::from_le_bytes(light[128..136].try_into().unwrap()), 0); + assert!(light.len() < full.len()); + assert!(encode_layout_tape(tape, &styles, identity, true, TAPE_HEADER_LEN).is_err()); + + let error = encode_error_tape(identity, &"x".repeat(1024), 160); + assert!(error.len() <= 160); + assert_eq!(u16::from_le_bytes(error[6..8].try_into().unwrap()), 2); + assert_eq!(u32::from_le_bytes(error[88..92].try_into().unwrap()), 0); + } + + #[test] + fn root_metadata_emits_only_safe_box_extents() { + let text = "abbc"; + let property_spans = vec![ + TapePropertySpan { + start: 0, + end: 1, + properties: AtomProperties { + content: Some(2), + owner: Some(2), + owners: vec![1, 2], + ..AtomProperties::default() + }, + }, + TapePropertySpan { + start: 1, + end: 3, + properties: AtomProperties { + content: Some(3), + owner: Some(3), + owners: vec![1, 3], + ..AtomProperties::default() + }, + }, + TapePropertySpan { + start: 3, + end: 4, + properties: AtomProperties { + content: Some(2), + owner: Some(2), + owners: vec![1, 2], + roles: vec![RegionRoleEntry { + role: RegionRole::PaddingRight, + region_id: 2, + }], + ..AtomProperties::default() + }, + }, + ]; + + let records = build_root_metadata_records(text, &property_spans).unwrap(); + assert!(records.iter().any(|record| { + record.kind == METADATA_BOX_EXTENT + && record.region_id == 1 + && record.start == 0 + && record.end == 4 + })); + assert!(records.iter().any(|record| { + record.kind == METADATA_BOX_EXTENT + && record.region_id == 3 + && record.start == 1 + && record.end == 3 + })); + assert!(!records + .iter() + .any(|record| record.kind == METADATA_BOX_EXTENT && record.region_id == 2)); + assert!(records + .iter() + .any(|record| record.kind == METADATA_ROLE_CONTENT && record.region_id == 2)); + } + + #[test] + fn root_metadata_literal_preserves_zero_metadata_and_byte_limit() { + let empty_payload = root_metadata_payload("x", &[], true, true, 4096).unwrap(); + assert!(empty_payload.is_none()); + + let property_spans = vec![TapePropertySpan { + start: 0, + end: 1, + properties: AtomProperties { + content: Some(42), + owner: Some(42), + ..AtomProperties::default() + }, + }]; + let payload = root_metadata_payload("x", &property_spans, true, true, 4096) + .unwrap() + .unwrap(); + assert_eq!(payload.record_count, 4); + assert!(payload.literal.contains("(42 content) ((1 . 2))")); + assert!(payload.literal.contains("data (42 (1 . 2))")); + assert!(payload.literal.contains("data (42 ((0 0 . 1)))")); + assert!( + root_metadata_payload("x", &property_spans, true, true, payload.literal.len() - 1) + .is_err() + ); + } + + #[test] + fn complete_tape_emits_property_template_ids_and_light_tape_strips_them() { + let tape = LayoutTape { + style_count: 0, + lines: vec![TapeLine { + width: 1, + atoms: vec![TapeAtom::Text { + text: "x".to_owned(), + width: 1, + properties: AtomProperties { + property_template_ids: vec![2, 4], + ..AtomProperties::default() + }, + }], + break_after: None, + }], + }; + let identity = complete_identity(); + + let full = encode_layout_tape(tape.clone(), &[], identity, true, 4096).unwrap(); + let full_literal = literal_from_full_tape(&full); + assert!(full_literal.contains("ebox-native-property-template-ids (2 4)")); + + let light = encode_layout_tape( + tape, + &[], + TapeIdentity { + complete: false, + ..identity + }, + true, + 4096, + ) + .unwrap(); + let light_literal = literal_from_full_tape(&light); + assert!(!light_literal.contains("ebox-native-property-template-ids")); + assert!(flatten_layout_tape( + LayoutTape { + style_count: 0, + lines: vec![TapeLine { + width: 1, + atoms: vec![TapeAtom::Text { + text: "x".to_owned(), + width: 1, + properties: AtomProperties { + property_template_ids: vec![2, 4], + ..AtomProperties::default() + }, + }], + break_after: None, + }], + }, + false, + ) + .unwrap() + .characters[0] + .properties + .property_template_ids + .is_empty()); + } + + #[test] + fn repeated_literal_property_plists_are_shared_with_read_circle_labels() { + let properties = AtomProperties { + content: Some(42), + property_template_ids: vec![3, 7], + ..AtomProperties::default() + }; + let literal = encode_lisp_literal_inner( + "abcd", + &[], + &[ + TapePropertySpan { + start: 0, + end: 1, + properties: properties.clone(), + }, + TapePropertySpan { + start: 2, + end: 3, + properties, + }, + ], + &[], + 4, + 4096, + true, + ) + .unwrap(); + assert_eq!( + literal, + "#(\"abcd\" 0 1 #1=(ebox-content 42 ebox-native-property-template-ids (3 7)) 2 3 #1#)" + ); + } + + #[test] + fn myers_diff_bounds_pathological_search_memory() { + let character = |value: char| TapeCharacter { + value, + pixel_width: None, + properties: AtomProperties::default(), + }; + let old: Vec = (0..30_000).map(|_| character('a')).collect(); + let new: Vec = (0..30_000).map(|_| character('b')).collect(); + let patches = minimal_tape_patches(&old, &new); + assert_eq!( + patches, + vec![TapePatch { + old_start: 0, + old_end: 30_000, + new_start: 0, + new_end: 30_000, + }] + ); + } + + #[test] + fn myers_diff_trims_common_affixes_before_search() { + let character = |value: char| TapeCharacter { + value, + pixel_width: None, + properties: AtomProperties::default(), + }; + let old: Vec = "prefixXsuffix".chars().map(character).collect(); + let new: Vec = "prefixYsuffix".chars().map(character).collect(); + let patches = minimal_tape_patches(&old, &new); + assert_eq!( + patches, + vec![TapePatch { + old_start: 6, + old_end: 7, + new_start: 6, + new_end: 7, + }] + ); + } + + #[test] + fn fixed_height_scroll_layout_does_not_render_offscreen_column_suffix() { + let children = (0..200) + .map(|index| { + let mut node = text_box( + index + 2, + measured_text(vec![vec![cluster(&format!("line-{index:03}"), 8, None)]]), + None, + ); + let LayoutNode::Box { width, height, .. } = &mut node else { + unreachable!(); + }; + *width = Size::Viewport; + *height = Size::Lines { value: 1 }; + node + }) + .collect(); + let mut root = child_box(1, LayoutNode::Column { children }, None); + let LayoutNode::Box { + width, + height, + content_width_exact, + .. + } = &mut root + else { + unreachable!(); + }; + *width = Size::Viewport; + *height = Size::Lines { value: 5 }; + *content_width_exact = false; + let document = LayoutDocument { + version: LAYOUT_VERSION, + space_width: 1, + style_count: 0, + property_template_count: 0, + styles: Vec::new(), + root, + }; + document.validate().unwrap(); + + let (tape, rendered_nodes) = counted_layout_tape(&document, test_context()); + + assert_eq!(tape.lines.len(), 5); + assert!( + rendered_nodes <= 8, + "fixed scroll rendered {rendered_nodes} nodes for five visible lines" + ); + } + + #[test] + fn fixed_height_scroll_layout_uses_nonzero_offset_window() { + let document = fixed_scroll_column_document(7); + document.validate().unwrap(); + + let (tape, rendered_nodes) = counted_layout_tape(&document, test_context()); + let flat = flatten_layout_tape(tape, true).unwrap(); + let text = flat + .characters + .iter() + .map(|character| character.value) + .collect::(); + + assert_eq!( + text, + "line-007 \nline-008 \nline-009 \nline-010 \nline-011 " + ); + assert!( + rendered_nodes <= 8, + "fixed scroll rendered {rendered_nodes} nodes for five visible lines" + ); + } + + #[test] + fn fixed_height_scroll_window_matches_eager_tape_exactly() { + let document = fixed_scroll_column_document(13); + document.validate().unwrap(); + + let eager = eager_layout_tape(&document, test_context()); + let (windowed, rendered_nodes) = counted_layout_tape(&document, test_context()); + + assert_eq!(windowed, eager); + assert!( + rendered_nodes <= 8, + "fixed scroll rendered {rendered_nodes} nodes for five visible lines" + ); + } + + #[test] + fn native_patch_solver_returns_only_disjoint_semantic_changes() { + let old = LayoutTape { + style_count: 0, + lines: vec![TapeLine { + width: 6, + atoms: vec![TapeAtom::Text { + text: "abcdef".to_owned(), + width: 6, + properties: AtomProperties::default(), + }], + break_after: None, + }], + }; + let target = LayoutTape { + style_count: 0, + lines: vec![TapeLine { + width: 13, + atoms: vec![ + TapeAtom::Text { + text: "abX".to_owned(), + width: 3, + properties: AtomProperties::default(), + }, + TapeAtom::Text { + text: "d".to_owned(), + width: 1, + properties: AtomProperties::default(), + }, + TapeAtom::Text { + text: "e".to_owned(), + width: 1, + properties: AtomProperties { + content: Some(9), + ..AtomProperties::default() + }, + }, + TapeAtom::Text { + text: "f".to_owned(), + width: 1, + properties: AtomProperties::default(), + }, + TapeAtom::Space { + width: 7, + properties: AtomProperties::default(), + }, + ], + break_after: None, + }], + }; + + let old = flatten_layout_tape(old, true).unwrap(); + let target = flatten_layout_tape(target, true).unwrap(); + let patches = minimal_tape_patches(&old.characters, &target.characters); + + assert_eq!( + patches, + vec![ + TapePatch { + old_start: 2, + old_end: 3, + new_start: 2, + new_end: 3, + }, + TapePatch { + old_start: 4, + old_end: 5, + new_start: 4, + new_end: 5, + }, + TapePatch { + old_start: 6, + old_end: 6, + new_start: 6, + new_end: 7, + }, + ] + ); + + let identity = TapeIdentity { + session_id: 1, + generation: 2, + key: 3, + runtime_revision: 4, + context_hash: 5, + viewport_width: 140, + viewport_height: 20, + root_width: 140, + complete: true, + }; + let encoded = encode_layout_patch_tape( + LayoutTape { + style_count: old.style_count, + lines: vec![TapeLine { + width: 6, + atoms: vec![TapeAtom::Text { + text: "abcdef".to_owned(), + width: 6, + properties: AtomProperties::default(), + }], + break_after: None, + }], + }, + LayoutTape { + style_count: target.style_count, + lines: vec![TapeLine { + width: 13, + atoms: vec![ + TapeAtom::Text { + text: "abXd".to_owned(), + width: 4, + properties: AtomProperties::default(), + }, + TapeAtom::Text { + text: "e".to_owned(), + width: 1, + properties: AtomProperties { + content: Some(9), + property_template_ids: vec![4], + ..AtomProperties::default() + }, + }, + TapeAtom::Text { + text: "f".to_owned(), + width: 1, + properties: AtomProperties::default(), + }, + TapeAtom::Space { + width: 7, + properties: AtomProperties::default(), + }, + ], + break_after: None, + }], + }, + &[], + identity, + true, + 4096, + ) + .unwrap(); + assert_eq!(u16::from_le_bytes(encoded[6..8].try_into().unwrap()), 7); + assert_eq!(u16::from_le_bytes(encoded[4..6].try_into().unwrap()), 9); + assert_eq!(u64::from_le_bytes(encoded[92..100].try_into().unwrap()), 7); + assert_eq!(u64::from_le_bytes(encoded[112..120].try_into().unwrap()), 6); + let patch_count = u32::from_le_bytes(encoded[120..124].try_into().unwrap()) as usize; + assert_eq!(patch_count, 3); + let reserved = u32::from_le_bytes(encoded[124..128].try_into().unwrap()); + let metadata_records = u64::from_le_bytes(encoded[128..136].try_into().unwrap()); + let payload_length = u64::from_le_bytes(encoded[136..144].try_into().unwrap()) as usize; + let payload_start = 144 + patch_count * 32; + let payload_end = payload_start + payload_length; + let payload = std::str::from_utf8(&encoded[payload_start..payload_end]).unwrap(); + assert_eq!(reserved, 0); + assert!(metadata_records > 0); + assert_eq!(encoded.len(), payload_end); + assert!(payload.starts_with("[#(\"Xe \"")); + assert!(payload.contains("1 2 (ebox-content 9 ebox-native-property-template-ids (4))")); + assert!(payload.contains("2 3 (display (space :width (7)))")); + assert!(payload.contains("(:prepared-p t")); + assert!(payload.contains("(9 content) ((5 . 6))")); + assert!(payload.contains("data (9 (5 . 6))")); + assert!(payload.ends_with(":scroll-window-p nil)]")); + } +} diff --git a/native/src/lib.rs b/native/src/lib.rs new file mode 100644 index 0000000..17f824a --- /dev/null +++ b/native/src/lib.rs @@ -0,0 +1,1676 @@ +mod layout; + +use layout::{ + encode_error_tape, LayoutContext, LayoutDocument, LayoutTape, TapeIdentity, TapeOutputOptions, + MAX_LAYOUT_DIMENSION, MIN_TAPE_BYTES, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::ffi::c_void; +use std::os::raw::c_int; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::ptr; +use std::slice; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +const CONTROL_VERSION: u32 = 1; +const MAX_CONTROL_BYTES: usize = 64 * 1024 * 1024; +const SYNC_RENDER_MAX_RESULT_BYTES: usize = 64 * 1024 * 1024; +const SYNC_RENDER_SESSION_ID: u64 = u64::MAX; +static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1); + +fn default_true() -> bool { + true +} + +#[repr(C)] +pub struct NativeBytes { + data: *mut u8, + len: usize, +} + +impl NativeBytes { + fn empty() -> Self { + Self { + data: ptr::null_mut(), + len: 0, + } + } + + fn from_vec(bytes: Vec) -> Self { + if bytes.is_empty() { + return Self::empty(); + } + let boxed = bytes.into_boxed_slice(); + let len = boxed.len(); + let data = Box::into_raw(boxed) as *mut u8; + Self { data, len } + } + + fn from_error(message: impl Into) -> Self { + Self::from_vec(message.into().into_bytes()) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ControlBatch { + version: u32, + #[serde(default)] + document: Option, + frames: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct ControlFrame { + key: i64, + #[serde(default)] + payload: Option, + #[serde(default)] + viewport_width: Option, + #[serde(default = "default_true")] + viewport_width_known: bool, + #[serde(default)] + viewport_height: Option, + #[serde(default)] + root_width: Option, + #[serde(default)] + root_width_override: bool, + #[serde(default)] + patch: bool, + #[serde(default)] + base_viewport_width: Option, + #[serde(default = "default_true")] + base_viewport_width_known: bool, + #[serde(default)] + base_viewport_height: Option, + #[serde(default)] + base_root_width: Option, + #[serde(default)] + base_root_width_override: bool, + #[serde(default)] + runtime_revision: u64, + #[serde(default)] + context_hash: i64, + #[serde(default = "default_true")] + complete: bool, + #[serde(default = "default_true")] + root_metadata: bool, + #[serde(default)] + delay_ms: u64, +} + +#[derive(Debug)] +enum JobPayload { + Echo(Vec), + Layout { + document: Arc, + context: LayoutContext, + root_width: i64, + root_width_override: bool, + base_context: Option, + base_root_width: i64, + base_root_width_override: bool, + runtime_revision: u64, + context_hash: i64, + complete: bool, + root_metadata: bool, + }, +} + +#[derive(Clone, Debug)] +struct BaselineIdentity { + context: LayoutContext, + root_width: i64, + root_width_override: bool, + runtime_revision: u64, + context_hash: i64, + complete: bool, +} + +#[derive(Clone, Debug)] +struct ConfirmedBaseline { + identity: BaselineIdentity, + tape: LayoutTape, +} + +#[derive(Debug)] +struct PendingBaseline { + confirmed_identity: BaselineIdentity, + tape: LayoutTape, +} + +#[derive(Debug)] +struct ResultEntry { + bytes: Vec, +} + +#[derive(Debug)] +struct RenderedJob { + bytes: Vec, + pending: Option, + baseline_hit: bool, + base_renders: u64, + target_renders: u64, +} + +#[derive(Debug)] +struct Job { + generation: u64, + key: i64, + payload: JobPayload, + delay_ms: u64, +} + +#[derive(Debug)] +struct PreparedJob { + key: i64, + delay_ms: u64, + payload: JobPayload, +} + +#[derive(Debug)] +struct RuntimeState { + jobs: VecDeque, + results: HashMap<(u64, i64), ResultEntry>, + pending_baselines: HashMap<(u64, i64), PendingBaseline>, + result_bytes: usize, + stale_drops: u64, + completed_jobs: u64, + baseline_hits: u64, + baseline_misses: u64, + base_renders: u64, + target_renders: u64, + confirmed_baseline: Option, +} + +#[derive(Clone, Copy, Debug)] +struct ReadinessChannel { + fd: c_int, + signal: extern "C" fn(c_int) -> bool, + close: extern "C" fn(c_int), +} + +#[derive(Debug)] +struct Shared { + id: u64, + alive: AtomicBool, + generation: AtomicU64, + max_jobs: usize, + max_results: usize, + max_result_bytes: usize, + state: Mutex, + readiness_channel: Mutex>, + job_available: Condvar, + result_available: Condvar, +} + +#[derive(Debug)] +struct Session { + shared: Arc, + workers: Mutex>>>, + layout_document: Mutex>>, + worker_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +struct SessionStats { + session_id: u64, + workers: usize, + queued_jobs: usize, + ready_results: usize, + result_bytes: usize, + max_jobs: usize, + max_results: usize, + max_result_bytes: usize, + generation: u64, + stale_drops: u64, + completed_jobs: u64, + baseline_hits: u64, + baseline_misses: u64, + base_renders: u64, + target_renders: u64, + pending_baselines: usize, + confirmed_baseline: bool, + confirmed_baseline_bytes: usize, + layout_registered: bool, + alive: bool, +} + +impl Shared { + fn signal_readiness(&self) { + let mut readiness_channel = self + .readiness_channel + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if let Some(channel) = *readiness_channel { + if !(channel.signal)(channel.fd) { + (channel.close)(channel.fd); + *readiness_channel = None; + } + } + } +} + +impl Session { + fn new( + workers: usize, + max_jobs: usize, + max_results: usize, + max_result_bytes: usize, + ) -> Result, String> { + if workers == 0 || max_jobs == 0 || max_results == 0 || max_result_bytes == 0 { + return Err("Native reflow capacities must be positive".to_owned()); + } + let available = thread::available_parallelism().map_or(1, usize::from); + let worker_count = workers.min(available).min(max_jobs).max(1); + let shared = Arc::new(Shared { + id: NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed), + alive: AtomicBool::new(true), + generation: AtomicU64::new(0), + max_jobs, + max_results, + max_result_bytes, + state: Mutex::new(RuntimeState { + jobs: VecDeque::new(), + results: HashMap::new(), + pending_baselines: HashMap::new(), + result_bytes: 0, + stale_drops: 0, + completed_jobs: 0, + baseline_hits: 0, + baseline_misses: 0, + base_renders: 0, + target_renders: 0, + confirmed_baseline: None, + }), + readiness_channel: Mutex::new(None), + job_available: Condvar::new(), + result_available: Condvar::new(), + }); + let mut handles = Vec::with_capacity(worker_count); + for index in 0..worker_count { + let worker_shared = Arc::clone(&shared); + let spawn = thread::Builder::new() + .name(format!("ebox-native-reflow-{index}")) + .spawn(move || worker_loop(worker_shared)); + match spawn { + Ok(handle) => handles.push(handle), + Err(error) => { + shared.alive.store(false, Ordering::Release); + shared.job_available.notify_all(); + for handle in handles { + let _ = handle.join(); + } + return Err(format!("Native reflow worker spawn failed: {error}")); + } + } + } + Ok(Box::new(Self { + shared, + workers: Mutex::new(Some(handles)), + layout_document: Mutex::new(None), + worker_count, + })) + } + + fn submit(&self, generation: u64, payload: &[u8]) -> Result { + let batch = parse_control_batch(payload)?; + if !self.shared.alive.load(Ordering::Acquire) { + return Err("Native reflow session is closed".to_owned()); + } + let layout_requested = + batch.document.is_some() || batch.frames.iter().any(|frame| frame.payload.is_none()); + let register_document = batch.document.is_some(); + let document = match batch.document { + Some(document) => { + document.validate()?; + Some(Arc::new(document)) + } + None if layout_requested => Some( + self.layout_document + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .clone() + .ok_or_else(|| { + "Native layout frames require a registered document".to_owned() + })?, + ), + None => None, + }; + if document.is_some() && self.shared.max_result_bytes < MIN_TAPE_BYTES { + return Err(format!( + "Native layout results require at least {MIN_TAPE_BYTES} bytes" + )); + } + + let mut prepared = Vec::with_capacity(batch.frames.len()); + for frame in batch.frames { + let job = if let Some(document) = &document { + prepare_layout_job(document, frame)? + } else { + let payload = frame + .payload + .ok_or_else(|| format!("Native echo frame {} requires payload", frame.key))?; + if payload.len() > self.shared.max_result_bytes { + return Err(format!( + "Native reflow frame {} exceeds the result byte limit", + frame.key + )); + } + PreparedJob { + key: frame.key, + delay_ms: frame.delay_ms, + payload: JobPayload::Echo(payload.into_bytes()), + } + }; + prepared.push(job); + } + + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let current = self.shared.generation.load(Ordering::Acquire); + if generation < current { + return Err(format!( + "Native reflow generation {generation} is stale; current generation is {current}" + )); + } + if generation > current { + self.shared.generation.store(generation, Ordering::Release); + let jobs_before = state.jobs.len(); + state.jobs.retain(|job| job.generation >= generation); + let removed_jobs = jobs_before - state.jobs.len(); + let result_keys: Vec<_> = state + .results + .keys() + .copied() + .filter(|(result_generation, _)| *result_generation < generation) + .collect(); + let mut removed_results = 0; + for key in result_keys { + if let Some(entry) = state.results.remove(&key) { + state.result_bytes = state.result_bytes.saturating_sub(entry.bytes.len()); + removed_results += 1; + } + } + state + .pending_baselines + .retain(|(pending_generation, _), _| *pending_generation >= generation); + state.stale_drops += (removed_jobs + removed_results) as u64; + self.shared.result_available.notify_all(); + } + if state.jobs.len() + prepared.len() > self.shared.max_jobs { + return Err("Native reflow job queue is full".to_owned()); + } + let mut batch_keys = HashSet::with_capacity(prepared.len()); + for job in &prepared { + if !batch_keys.insert(job.key) { + return Err(format!( + "Duplicate native reflow frame key {} in generation {generation}", + job.key + )); + } + let duplicate_job = state + .jobs + .iter() + .any(|queued| queued.generation == generation && queued.key == job.key); + if duplicate_job || state.results.contains_key(&(generation, job.key)) { + return Err(format!( + "Duplicate native reflow frame key {} in generation {generation}", + job.key + )); + } + } + let accepted = prepared.len(); + for job in prepared { + state.jobs.push_back(Job { + generation, + key: job.key, + payload: job.payload, + delay_ms: job.delay_ms, + }); + } + drop(state); + if register_document { + *self + .layout_document + .lock() + .unwrap_or_else(|poison| poison.into_inner()) = document; + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + state.confirmed_baseline = None; + state.pending_baselines.clear(); + } + self.shared.job_available.notify_all(); + Ok(accepted) + } + + fn ready(&self, generation: u64, key: i64) -> bool { + let state = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + state.results.contains_key(&(generation, key)) + } + + fn take(&self, generation: u64, key: i64) -> Option> { + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let result = state.results.remove(&(generation, key)); + if let Some(entry) = &result { + state.result_bytes = state.result_bytes.saturating_sub(entry.bytes.len()); + self.shared.result_available.notify_all(); + } + result.map(|entry| entry.bytes) + } + + fn confirm(&self, generation: u64, key: i64, confirmed_revision: u64) -> Result { + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let Some(pending) = state.pending_baselines.remove(&(generation, key)) else { + return Ok(false); + }; + if confirmed_revision <= pending.confirmed_identity.runtime_revision { + return Err("Native confirmed frame revision mismatch".to_owned()); + } + let mut identity = pending.confirmed_identity; + identity.runtime_revision = confirmed_revision; + state.confirmed_baseline = Some(ConfirmedBaseline { + identity, + tape: pending.tape, + }); + Ok(true) + } + + fn cancel(&self, generation: u64) { + let next = generation.saturating_add(1); + self.shared.generation.fetch_max(next, Ordering::AcqRel); + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let jobs_before = state.jobs.len(); + state.jobs.retain(|job| job.generation > generation); + let removed_jobs = jobs_before - state.jobs.len(); + let result_keys: Vec<_> = state + .results + .keys() + .copied() + .filter(|(result_generation, _)| *result_generation <= generation) + .collect(); + let mut removed_results = 0; + for key in result_keys { + if let Some(entry) = state.results.remove(&key) { + state.result_bytes = state.result_bytes.saturating_sub(entry.bytes.len()); + removed_results += 1; + } + } + state.stale_drops += (removed_jobs + removed_results) as u64; + state + .pending_baselines + .retain(|(pending_generation, _), _| *pending_generation > generation); + drop(state); + self.shared.job_available.notify_all(); + self.shared.result_available.notify_all(); + } + + fn attach_readiness_channel( + &self, + fd: c_int, + signal: extern "C" fn(c_int) -> bool, + close: extern "C" fn(c_int), + ) -> bool { + if fd < 0 || !self.shared.alive.load(Ordering::Acquire) { + return false; + } + let mut channel = self + .shared + .readiness_channel + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if channel.is_some() { + return false; + } + *channel = Some(ReadinessChannel { fd, signal, close }); + true + } + + fn detach_readiness_channel(&self) { + let mut channel = self + .shared + .readiness_channel + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if let Some(channel) = channel.take() { + (channel.close)(channel.fd); + } + } + + fn stats(&self) -> SessionStats { + let state = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let pending_baselines = state.pending_baselines.len(); + let confirmed_baseline_bytes = state + .confirmed_baseline + .as_ref() + .map_or(0, |baseline| std::mem::size_of_val(&baseline.tape)); + SessionStats { + session_id: self.shared.id, + workers: self.worker_count, + queued_jobs: state.jobs.len(), + ready_results: state.results.len(), + result_bytes: state.result_bytes, + max_jobs: self.shared.max_jobs, + max_results: self.shared.max_results, + max_result_bytes: self.shared.max_result_bytes, + generation: self.shared.generation.load(Ordering::Acquire), + stale_drops: state.stale_drops, + completed_jobs: state.completed_jobs, + baseline_hits: state.baseline_hits, + baseline_misses: state.baseline_misses, + base_renders: state.base_renders, + target_renders: state.target_renders, + pending_baselines, + confirmed_baseline: state.confirmed_baseline.is_some(), + confirmed_baseline_bytes, + layout_registered: self + .layout_document + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .is_some(), + alive: self.shared.alive.load(Ordering::Acquire), + } + } + + fn stop(&self, join: bool) { + self.detach_readiness_channel(); + self.shared.alive.store(false, Ordering::Release); + self.shared.job_available.notify_all(); + self.shared.result_available.notify_all(); + if let Some(handles) = self + .workers + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .take() + { + if join { + for handle in handles { + let _ = handle.join(); + } + } + } + } +} + +fn parse_control_batch(payload: &[u8]) -> Result { + if payload.len() > MAX_CONTROL_BYTES { + return Err("Native reflow control payload exceeds the hard limit".to_owned()); + } + let batch: ControlBatch = serde_json::from_slice(payload) + .map_err(|error| format!("Invalid native reflow control JSON: {error}"))?; + if batch.version != CONTROL_VERSION { + return Err(format!( + "Unsupported native reflow control version {}", + batch.version + )); + } + if batch.frames.is_empty() { + return Err("Native reflow control batch has no frames".to_owned()); + } + Ok(batch) +} + +fn checked_layout_context( + document: &LayoutDocument, + frame_key: i64, + viewport_width: i64, + viewport_width_known: bool, + viewport_height: i64, + label: &str, +) -> Result { + if viewport_width <= 0 + || viewport_height <= 0 + || viewport_width > MAX_LAYOUT_DIMENSION + || viewport_height > MAX_LAYOUT_DIMENSION + { + return Err(format!( + "Native layout frame {frame_key} {label} viewport is outside the dimension limit" + )); + } + let context = LayoutContext { + viewport_width, + viewport_width_known, + viewport_height, + }; + document.validate_context(context)?; + Ok(context) +} + +fn checked_root_width(frame_key: i64, root_width: i64, label: &str) -> Result { + if root_width <= 0 || root_width > MAX_LAYOUT_DIMENSION { + return Err(format!( + "Native layout frame {frame_key} {label} root width is outside the dimension limit" + )); + } + Ok(root_width) +} + +fn confirmed_baseline_matches( + baseline: &ConfirmedBaseline, + context: LayoutContext, + root_width: i64, + root_width_override: bool, + runtime_revision: u64, + context_hash: i64, + complete: bool, +) -> bool { + baseline.identity.context == context + && baseline.identity.root_width == root_width + && baseline.identity.root_width_override == root_width_override + && baseline.identity.runtime_revision == runtime_revision + && baseline.identity.context_hash == context_hash + && baseline.identity.complete == complete +} + +fn prepare_layout_job( + document: &Arc, + frame: ControlFrame, +) -> Result { + if frame.payload.is_some() { + return Err(format!( + "Native layout frame {} cannot contain an echo payload", + frame.key + )); + } + let viewport_width = frame + .viewport_width + .ok_or_else(|| format!("Native layout frame {} requires viewport-width", frame.key))?; + let viewport_height = frame + .viewport_height + .ok_or_else(|| format!("Native layout frame {} requires viewport-height", frame.key))?; + let context = checked_layout_context( + document.as_ref(), + frame.key, + viewport_width, + frame.viewport_width_known, + viewport_height, + "target", + )?; + let root_width = checked_root_width( + frame.key, + frame.root_width.unwrap_or(viewport_width), + "target", + )?; + let (base_context, base_root_width) = if frame.patch { + let base_viewport_width = frame.base_viewport_width.ok_or_else(|| { + format!( + "Native layout patch frame {} requires base-viewport-width", + frame.key + ) + })?; + let base_viewport_height = frame.base_viewport_height.ok_or_else(|| { + format!( + "Native layout patch frame {} requires base-viewport-height", + frame.key + ) + })?; + let base_context = checked_layout_context( + document.as_ref(), + frame.key, + base_viewport_width, + frame.base_viewport_width_known, + base_viewport_height, + "base", + )?; + let base_root_width = checked_root_width( + frame.key, + frame.base_root_width.unwrap_or(base_viewport_width), + "base", + )?; + (Some(base_context), base_root_width) + } else { + if frame.base_viewport_width.is_some() + || frame.base_viewport_height.is_some() + || frame.base_root_width.is_some() + || !frame.base_viewport_width_known + || frame.base_root_width_override + { + return Err(format!( + "Native full layout frame {} cannot contain base geometry", + frame.key + )); + } + (None, root_width) + }; + Ok(PreparedJob { + key: frame.key, + delay_ms: frame.delay_ms, + payload: JobPayload::Layout { + document: Arc::clone(document), + context, + root_width, + root_width_override: frame.root_width_override, + base_context, + base_root_width, + base_root_width_override: frame.base_root_width_override, + runtime_revision: frame.runtime_revision, + context_hash: frame.context_hash, + complete: frame.complete, + root_metadata: frame.root_metadata, + }, + }) +} + +fn render_layout_payload( + payload: JobPayload, + session_id: u64, + generation: u64, + key: i64, + max_result_bytes: usize, + confirmed_baseline: Option, +) -> RenderedJob { + match payload { + JobPayload::Echo(bytes) => RenderedJob { + bytes, + pending: None, + baseline_hit: false, + base_renders: 0, + target_renders: 0, + }, + JobPayload::Layout { + document, + context, + root_width, + root_width_override, + base_context, + base_root_width, + base_root_width_override, + runtime_revision, + context_hash, + complete, + root_metadata, + } => { + let identity = TapeIdentity { + session_id, + generation, + key, + runtime_revision, + context_hash, + viewport_width: context.viewport_width, + viewport_height: context.viewport_height, + root_width, + complete, + }; + let output = TapeOutputOptions { + root_metadata, + max_bytes: max_result_bytes, + }; + let pending_identity = BaselineIdentity { + context, + root_width, + root_width_override, + runtime_revision, + context_hash, + complete, + }; + // Worker threads have no other panic boundary: an unwinding + // panic would silently kill the worker and leave the popped + // job with neither result nor error tape, so the Elisp ready + // watcher would poll forever. Convert panics into the same + // error-tape channel ordinary layout failures use. + type LayoutRenderOutcome = Result<(Vec, LayoutTape, bool, u64, u64), String>; + let result = catch_unwind(AssertUnwindSafe(|| -> LayoutRenderOutcome { + if let Some(base_context) = base_context { + let base_hit = confirmed_baseline + .as_ref() + .filter(|baseline| { + confirmed_baseline_matches( + baseline, + base_context, + base_root_width, + base_root_width_override, + runtime_revision, + context_hash, + complete, + ) + }) + .cloned(); + let (old, baseline_hit, base_renders) = if let Some(baseline) = base_hit { + (baseline.tape, true, 0) + } else { + ( + document.layout_tape( + base_context, + base_root_width_override.then_some(base_root_width), + )?, + false, + 1, + ) + }; + let target = + document.layout_tape(context, root_width_override.then_some(root_width))?; + let bytes = layout::encode_layout_patch_tape( + old, + target.clone(), + &document.styles, + identity, + output.root_metadata, + output.max_bytes, + )?; + Ok((bytes, target, baseline_hit, base_renders, 1)) + } else { + let target = + document.layout_tape(context, root_width_override.then_some(root_width))?; + let bytes = layout::encode_layout_tape( + target.clone(), + &document.styles, + identity, + output.root_metadata, + output.max_bytes, + )?; + Ok((bytes, target, false, 0, 1)) + } + })); + match result { + Ok(Ok((bytes, tape, baseline_hit, base_renders, target_renders))) => RenderedJob { + bytes, + pending: Some(PendingBaseline { + confirmed_identity: pending_identity, + tape, + }), + baseline_hit, + base_renders, + target_renders, + }, + Ok(Err(error)) => RenderedJob { + bytes: encode_error_tape(identity, &error, max_result_bytes), + pending: None, + baseline_hit: false, + base_renders: 0, + target_renders: 0, + }, + Err(_) => RenderedJob { + bytes: encode_error_tape(identity, "native layout panicked", max_result_bytes), + pending: None, + baseline_hit: false, + base_renders: 0, + target_renders: 0, + }, + } + } + } +} + +fn render_proof(payload: &[u8]) -> Result, String> { + let batch = parse_control_batch(payload)?; + let document = batch + .document + .ok_or_else(|| "Native proof render requires an inline document".to_owned())?; + document.validate()?; + if batch.frames.len() != 1 { + return Err("Native proof render requires exactly one layout frame".to_owned()); + } + if SYNC_RENDER_MAX_RESULT_BYTES < MIN_TAPE_BYTES { + return Err(format!( + "Native layout results require at least {MIN_TAPE_BYTES} bytes" + )); + } + let frame = batch + .frames + .into_iter() + .next() + .expect("checked single proof frame"); + if frame.patch { + return Err("Native proof render supports full layout frames only".to_owned()); + } + if !frame.complete { + return Err("Native proof render requires complete frames".to_owned()); + } + if frame.delay_ms != 0 { + return Err("Native proof render cannot contain delay-ms".to_owned()); + } + let prepared = prepare_layout_job(&Arc::new(document), frame)?; + let output = render_layout_payload( + prepared.payload, + SYNC_RENDER_SESSION_ID, + 0, + prepared.key, + SYNC_RENDER_MAX_RESULT_BYTES, + None, + ); + if output.bytes.len() > SYNC_RENDER_MAX_RESULT_BYTES { + return Err("Native proof render exceeds the result byte limit".to_owned()); + } + Ok(output.bytes) +} + +fn worker_loop(shared: Arc) { + loop { + let job = { + let mut state = shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + while shared.alive.load(Ordering::Acquire) && state.jobs.is_empty() { + state = shared + .job_available + .wait(state) + .unwrap_or_else(|poison| poison.into_inner()); + } + if !shared.alive.load(Ordering::Acquire) { + return; + } + state.jobs.pop_front() + }; + let Some(job) = job else { + continue; + }; + + let mut remaining_delay = job.delay_ms; + while remaining_delay > 0 { + let slice_ms = remaining_delay.min(2); + thread::sleep(Duration::from_millis(slice_ms)); + remaining_delay -= slice_ms; + if !shared.alive.load(Ordering::Acquire) + || shared.generation.load(Ordering::Acquire) != job.generation + { + break; + } + } + if !shared.alive.load(Ordering::Acquire) + || shared.generation.load(Ordering::Acquire) != job.generation + { + let mut state = shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + state.stale_drops += 1; + continue; + } + + let confirmed_baseline = { + let state = shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + state.confirmed_baseline.clone() + }; + let output = render_layout_payload( + job.payload, + shared.id, + job.generation, + job.key, + shared.max_result_bytes, + confirmed_baseline, + ); + if output.bytes.len() > shared.max_result_bytes { + let mut state = shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + state.stale_drops += 1; + continue; + } + let output_len = output.bytes.len(); + let mut state = shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + while shared.alive.load(Ordering::Acquire) + && shared.generation.load(Ordering::Acquire) == job.generation + && (state.results.len() >= shared.max_results + || state.result_bytes + output_len > shared.max_result_bytes) + { + state = shared + .result_available + .wait(state) + .unwrap_or_else(|poison| poison.into_inner()); + } + if !shared.alive.load(Ordering::Acquire) + || shared.generation.load(Ordering::Acquire) != job.generation + { + state.stale_drops += 1; + continue; + } + state.result_bytes += output_len; + if output.base_renders > 0 { + state.baseline_misses += 1; + } else if output.baseline_hit { + state.baseline_hits += 1; + } + state.base_renders += output.base_renders; + state.target_renders += output.target_renders; + state.results.insert( + (job.generation, job.key), + ResultEntry { + bytes: output.bytes, + }, + ); + if let Some(pending) = output.pending { + state + .pending_baselines + .insert((job.generation, job.key), pending); + } + state.completed_jobs += 1; + drop(state); + shared.signal_readiness(); + } +} + +fn session_ref<'a>(pointer: *mut c_void) -> Option<&'a Session> { + if pointer.is_null() { + None + } else { + Some(unsafe { &*(pointer as *mut Session) }) + } +} + +fn set_error(output: *mut NativeBytes, message: impl Into) { + if !output.is_null() { + unsafe { + *output = NativeBytes::from_error(message); + } + } +} + +#[no_mangle] +pub static plugin_is_GPL_compatible: i32 = 0; + +extern "C" { + fn ebox_module_init_impl(runtime: *mut c_void) -> i32; +} + +#[no_mangle] +/// Initialize the module through Emacs's official runtime pointer. +/// +/// # Safety +/// +/// `runtime` must be the live `emacs_runtime` pointer supplied by Emacs for +/// this module initialization call. +pub unsafe extern "C" fn emacs_module_init(runtime: *mut c_void) -> i32 { + catch_unwind(AssertUnwindSafe(|| unsafe { + ebox_module_init_impl(runtime) + })) + .unwrap_or(99) +} + +#[no_mangle] +pub extern "C" fn ebox_native_layout_ready() -> bool { + true +} + +#[no_mangle] +pub extern "C" fn ebox_native_session_create( + workers: u32, + max_jobs: u32, + max_results: u32, + max_result_bytes: u64, + error: *mut NativeBytes, +) -> *mut c_void { + match catch_unwind(AssertUnwindSafe(|| { + Session::new( + workers as usize, + max_jobs as usize, + max_results as usize, + usize::try_from(max_result_bytes) + .map_err(|_| "Native reflow byte limit exceeds this platform".to_owned())?, + ) + })) { + Ok(Ok(session)) => Box::into_raw(session) as *mut c_void, + Ok(Err(message)) => { + set_error(error, message); + ptr::null_mut() + } + Err(_) => { + set_error(error, "Native reflow session creation panicked"); + ptr::null_mut() + } + } +} + +#[no_mangle] +/// Copy and submit one versioned control payload to a live native session. +/// +/// # Safety +/// +/// `session` must come from `ebox_native_session_create`. When `payload_len` +/// is nonzero, `payload` must reference that many readable bytes for the +/// duration of this call. `error`, when non-null, must be writable. +pub unsafe extern "C" fn ebox_native_session_submit( + session: *mut c_void, + generation: u64, + payload: *const u8, + payload_len: usize, + error: *mut NativeBytes, +) -> i64 { + let outcome = catch_unwind(AssertUnwindSafe(|| { + let session = + session_ref(session).ok_or_else(|| "Native reflow session is null".to_owned())?; + if payload.is_null() && payload_len != 0 { + return Err("Native reflow payload pointer is null".to_owned()); + } + let bytes = if payload_len == 0 { + &[][..] + } else { + unsafe { slice::from_raw_parts(payload, payload_len) } + }; + session.submit(generation, bytes) + })); + match outcome { + Ok(Ok(accepted)) => accepted as i64, + Ok(Err(message)) => { + set_error(error, message); + -1 + } + Err(_) => { + set_error(error, "Native reflow submission panicked"); + -1 + } + } +} + +#[no_mangle] +/// Render one complete layout proof synchronously from a versioned payload. +/// +/// # Safety +/// +/// When `payload_len` is nonzero, `payload` must reference that many readable +/// bytes for the duration of this call. `output` must be writable. +pub unsafe extern "C" fn ebox_native_render_proof( + payload: *const u8, + payload_len: usize, + output: *mut NativeBytes, +) -> bool { + if output.is_null() { + return false; + } + let outcome = catch_unwind(AssertUnwindSafe(|| { + if payload.is_null() && payload_len != 0 { + return Err("Native proof render payload pointer is null".to_owned()); + } + let bytes = if payload_len == 0 { + &[][..] + } else { + unsafe { slice::from_raw_parts(payload, payload_len) } + }; + render_proof(bytes) + })); + let result = match outcome { + Ok(result) => result, + Err(_) => Err("Native proof render panicked".to_owned()), + }; + let success = result.is_ok(); + unsafe { + *output = match result { + Ok(bytes) => NativeBytes::from_vec(bytes), + Err(message) => NativeBytes::from_error(message), + }; + } + success +} + +#[no_mangle] +pub extern "C" fn ebox_native_session_ready( + session: *mut c_void, + generation: u64, + key: i64, +) -> bool { + catch_unwind(AssertUnwindSafe(|| { + session_ref(session).is_some_and(|session| session.ready(generation, key)) + })) + .unwrap_or(false) +} + +#[no_mangle] +pub extern "C" fn ebox_native_session_take( + session: *mut c_void, + generation: u64, + key: i64, +) -> NativeBytes { + catch_unwind(AssertUnwindSafe(|| { + session_ref(session) + .and_then(|session| session.take(generation, key)) + .map_or_else(NativeBytes::empty, NativeBytes::from_vec) + })) + .unwrap_or_else(|_| NativeBytes::empty()) +} + +#[no_mangle] +pub extern "C" fn ebox_native_session_confirm_frame( + session: *mut c_void, + generation: u64, + key: i64, + confirmed_revision: u64, + error: *mut NativeBytes, +) -> bool { + let outcome = catch_unwind(AssertUnwindSafe(|| { + let session = + session_ref(session).ok_or_else(|| "Native reflow session is null".to_owned())?; + session.confirm(generation, key, confirmed_revision) + })); + match outcome { + Ok(Ok(confirmed)) => confirmed, + Ok(Err(message)) => { + set_error(error, message); + false + } + Err(_) => { + set_error(error, "Native reflow confirmation panicked"); + false + } + } +} + +#[no_mangle] +pub extern "C" fn ebox_native_session_attach_readiness_channel( + session: *mut c_void, + fd: c_int, + signal: Option bool>, + close: Option, +) -> bool { + catch_unwind(AssertUnwindSafe(|| { + let Some(session) = session_ref(session) else { + return false; + }; + let Some(signal) = signal else { + return false; + }; + let Some(close) = close else { + return false; + }; + session.attach_readiness_channel(fd, signal, close) + })) + .unwrap_or(false) +} + +#[no_mangle] +pub extern "C" fn ebox_native_session_detach_readiness_channel(session: *mut c_void) { + let _ = catch_unwind(AssertUnwindSafe(|| { + if let Some(session) = session_ref(session) { + session.detach_readiness_channel(); + } + })); +} + +#[no_mangle] +pub extern "C" fn ebox_native_session_cancel(session: *mut c_void, generation: u64) { + let _ = catch_unwind(AssertUnwindSafe(|| { + if let Some(session) = session_ref(session) { + session.cancel(generation); + } + })); +} + +#[no_mangle] +pub extern "C" fn ebox_native_session_stats(session: *mut c_void) -> NativeBytes { + catch_unwind(AssertUnwindSafe(|| { + session_ref(session) + .and_then(|session| serde_json::to_vec(&session.stats()).ok()) + .map_or_else(NativeBytes::empty, NativeBytes::from_vec) + })) + .unwrap_or_else(|_| NativeBytes::empty()) +} + +#[no_mangle] +pub extern "C" fn ebox_native_session_release(session: *mut c_void) { + if session.is_null() { + return; + } + let _ = catch_unwind(AssertUnwindSafe(|| { + let session = unsafe { Box::from_raw(session as *mut Session) }; + session.stop(false); + })); +} + +#[no_mangle] +pub extern "C" fn ebox_native_session_finalize(session: *mut c_void) { + if session.is_null() { + return; + } + let _ = catch_unwind(AssertUnwindSafe(|| { + let session = unsafe { Box::from_raw(session as *mut Session) }; + session.stop(false); + })); +} + +#[no_mangle] +pub extern "C" fn ebox_native_bytes_free(bytes: NativeBytes) { + if bytes.data.is_null() || bytes.len == 0 { + return; + } + let pointer = ptr::slice_from_raw_parts_mut(bytes.data, bytes.len); + unsafe { + drop(Box::from_raw(pointer)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + use std::time::{Duration, Instant}; + + static READINESS_WAKE_COUNT: AtomicUsize = AtomicUsize::new(0); + static READINESS_CLOSE_COUNT: AtomicUsize = AtomicUsize::new(0); + static READINESS_FAILURE_CLOSE_COUNT: AtomicUsize = AtomicUsize::new(0); + + extern "C" fn test_readiness_signal(fd: c_int) -> bool { + if fd == 7 { + READINESS_WAKE_COUNT.fetch_add(1, Ordering::SeqCst); + } + true + } + + extern "C" fn test_readiness_signal_failure(_fd: c_int) -> bool { + false + } + + extern "C" fn test_readiness_close(fd: c_int) { + if fd == 7 { + READINESS_CLOSE_COUNT.fetch_add(1, Ordering::SeqCst); + } else { + READINESS_FAILURE_CLOSE_COUNT.fetch_add(1, Ordering::SeqCst); + } + } + + fn batch(frames: &str) -> Vec { + format!(r#"{{"version":1,"frames":{frames}}}"#).into_bytes() + } + + fn proof_layout_payload(frames: &str) -> Vec { + format!( + r#"{{"version":1,"document":{{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{{"type":"box","region-id":1,"content":{{"lines":[{{"clusters":[{{"text":"x","width":8,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"viewport"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","padding-left":0,"padding-right":0,"padding-top":0,"padding-bottom":0,"margin-left":0,"margin-right":0,"margin-top":0,"margin-bottom":0,"border-left":0,"border-right":0,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}}},"frames":{frames}}}"# + ) + .into_bytes() + } + + fn column_proof_layout_payload(frames: &str) -> Vec { + format!( + r#"{{"version":1,"document":{{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{{"type":"column","children":[{{"type":"box","region-id":1,"content":{{"lines":[{{"clusters":[{{"text":"x","width":8,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","padding-left":0,"padding-right":0,"padding-top":0,"padding-bottom":0,"margin-left":0,"margin-right":0,"margin-top":0,"margin-bottom":0,"border-left":0,"border-right":0,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}},{{"type":"box","region-id":2,"content":{{"lines":[{{"clusters":[{{"text":"yy","width":16,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","padding-left":0,"padding-right":0,"padding-top":0,"padding-bottom":0,"margin-left":0,"margin-right":0,"margin-top":0,"margin-bottom":0,"border-left":0,"border-right":0,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}]}}}},"frames":{frames}}}"# + ) + .into_bytes() + } + + fn read_le_u64(bytes: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()) + } + + fn full_tape_character_count(bytes: &[u8]) -> u64 { + read_le_u64(bytes, 92) + } + + fn full_tape_line_widths(bytes: &[u8]) -> Vec { + let line_count = u32::from_le_bytes(bytes[120..124].try_into().unwrap()) as usize; + (0..line_count) + .map(|index| read_le_u64(bytes, 136 + index * 8)) + .collect() + } + + fn wait_until(mut predicate: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(2); + while !predicate() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(1)); + } + assert!(predicate()); + } + + #[test] + fn bounded_session_round_trips_owned_payloads() { + let session = Session::new(2, 8, 8, 4096).unwrap(); + let control = batch(r#"[{"key":1,"payload":"alpha"},{"key":2,"payload":"beta"}]"#); + assert_eq!(session.submit(1, &control).unwrap(), 2); + wait_until(|| session.ready(1, 1) && session.ready(1, 2)); + assert_eq!(session.take(1, 1).unwrap(), b"alpha"); + assert_eq!(session.take(1, 2).unwrap(), b"beta"); + assert_eq!(session.stats().result_bytes, 0); + session.stop(true); + } + + #[test] + fn readiness_channel_wakes_on_completion_and_detaches_cleanly() { + READINESS_WAKE_COUNT.store(0, Ordering::SeqCst); + READINESS_CLOSE_COUNT.store(0, Ordering::SeqCst); + let session = Session::new(1, 8, 8, 4096).unwrap(); + assert!(session.attach_readiness_channel(7, test_readiness_signal, test_readiness_close)); + assert!(!session.attach_readiness_channel(8, test_readiness_signal, test_readiness_close)); + let control = + batch(r#"[{"key":1,"payload":"alpha"},{"key":2,"payload":"beta","delay-ms":20}]"#); + assert_eq!(session.submit(1, &control).unwrap(), 2); + wait_until(|| READINESS_WAKE_COUNT.load(Ordering::SeqCst) >= 1); + assert!(session.ready(1, 1)); + assert_eq!(session.take(1, 1).unwrap(), b"alpha"); + + session.cancel(1); + let wakes_after_cancel = READINESS_WAKE_COUNT.load(Ordering::SeqCst); + thread::sleep(Duration::from_millis(30)); + assert_eq!( + READINESS_WAKE_COUNT.load(Ordering::SeqCst), + wakes_after_cancel + ); + + session.detach_readiness_channel(); + assert_eq!(READINESS_CLOSE_COUNT.load(Ordering::SeqCst), 1); + let detached_wakes = READINESS_WAKE_COUNT.load(Ordering::SeqCst); + let detached = batch(r#"[{"key":3,"payload":"gamma"}]"#); + assert_eq!(session.submit(2, &detached).unwrap(), 1); + wait_until(|| session.ready(2, 3)); + thread::sleep(Duration::from_millis(5)); + assert_eq!(READINESS_WAKE_COUNT.load(Ordering::SeqCst), detached_wakes); + session.stop(true); + + let raw = ebox_native_session_create(1, 2, 2, 4096, ptr::null_mut()); + assert!(!raw.is_null()); + assert!(ebox_native_session_attach_readiness_channel( + raw, + 7, + Some(test_readiness_signal), + Some(test_readiness_close) + )); + ebox_native_session_release(raw); + assert_eq!(READINESS_CLOSE_COUNT.load(Ordering::SeqCst), 2); + } + + #[test] + fn readiness_signal_failure_closes_and_releases_the_channel() { + READINESS_FAILURE_CLOSE_COUNT.store(0, Ordering::SeqCst); + let session = Session::new(1, 2, 2, 4096).unwrap(); + assert!(session.attach_readiness_channel( + 9, + test_readiness_signal_failure, + test_readiness_close + )); + let control = batch(r#"[{"key":1,"payload":"ready"}]"#); + assert_eq!(session.submit(1, &control).unwrap(), 1); + wait_until(|| session.ready(1, 1)); + wait_until(|| READINESS_FAILURE_CLOSE_COUNT.load(Ordering::SeqCst) == 1); + assert!(session.attach_readiness_channel(10, test_readiness_signal, test_readiness_close)); + session.detach_readiness_channel(); + assert_eq!(READINESS_FAILURE_CLOSE_COUNT.load(Ordering::SeqCst), 2); + session.stop(true); + } + + #[test] + fn cancellation_drops_delayed_generation() { + let session = Session::new(1, 4, 4, 4096).unwrap(); + let control = batch(r#"[{"key":1,"payload":"stale","delay-ms":50}]"#); + assert_eq!(session.submit(7, &control).unwrap(), 1); + session.cancel(7); + thread::sleep(Duration::from_millis(80)); + assert!(!session.ready(7, 1)); + assert!(session.stats().stale_drops > 0); + session.stop(true); + } + + #[test] + fn malformed_and_over_capacity_batches_are_rejected() { + let session = Session::new(1, 1, 1, 4).unwrap(); + assert!(session.submit(1, b"not-json").is_err()); + let oversized = batch(r#"[{"key":1,"payload":"12345"}]"#); + assert!(session.submit(1, &oversized).is_err()); + assert_eq!(session.stats().queued_jobs, 0); + session.stop(true); + } + + #[test] + fn newer_generation_purges_old_capacity_and_duplicate_keys_are_rejected() { + let session = Session::new(1, 2, 2, 4096).unwrap(); + let old = batch(r#"[{"key":1,"payload":"old","delay-ms":50},{"key":2,"payload":"old"}]"#); + assert_eq!(session.submit(3, &old).unwrap(), 2); + let replacement = batch(r#"[{"key":7,"payload":"new"}]"#); + assert_eq!(session.submit(4, &replacement).unwrap(), 1); + wait_until(|| session.ready(4, 7)); + assert_eq!(session.take(4, 7).unwrap(), b"new"); + assert!(!session.ready(3, 1)); + assert!(!session.ready(3, 2)); + + let duplicate = batch(r#"[{"key":8,"payload":"a"},{"key":8,"payload":"b"}]"#); + assert!(session.submit(4, &duplicate).is_err()); + session.stop(true); + } + + #[test] + fn registered_layout_is_reused_by_later_generations() { + let session = Session::new(1, 2, 2, 4096).unwrap(); + let first = br#"{"version":1,"document":{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{"type":"box","region-id":1,"content":{"lines":[{"clusters":[{"text":"x","width":8,"cjk":false,"space":false}]}]},"child":null,"content-width-exact":false,"width":{"kind":"viewport"},"min-width":{"kind":"pixels","value":0},"max-width":{"kind":"none"},"height":{"kind":"auto"},"min-height":{"kind":"lines","value":0},"max-height":{"kind":"none"},"box-sizing":"border-box","padding-left":0,"padding-right":0,"padding-top":0,"padding-bottom":0,"margin-left":0,"margin-right":0,"margin-top":0,"margin-bottom":0,"border-left":0,"border-right":0,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}},"frames":[{"key":1,"viewport-width":80,"viewport-height":10}]}"#; + assert_eq!(session.submit(1, first).unwrap(), 1); + wait_until(|| session.ready(1, 1)); + assert!(!session.take(1, 1).unwrap().is_empty()); + + let reused = + br#"{"version":1,"frames":[{"key":2,"viewport-width":120,"viewport-height":12}]}"#; + assert_eq!(session.submit(2, reused).unwrap(), 1); + wait_until(|| session.ready(2, 2)); + assert!(!session.take(2, 2).unwrap().is_empty()); + session.stop(true); + } + + #[test] + fn confirmed_baseline_is_promoted_only_after_explicit_confirmation() { + let session = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = proof_layout_payload( + r#"[{"key":1,"viewport-width":120,"viewport-height":10,"root-width":120,"patch":true,"base-viewport-width":80,"base-viewport-height":10,"base-root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + assert_eq!(session.submit(1, &first).unwrap(), 1); + wait_until(|| session.ready(1, 1)); + assert!(!session.confirm(1, 99, 1).unwrap()); + let first_stats = session.stats(); + assert_eq!(first_stats.baseline_misses, 1); + assert_eq!(first_stats.baseline_hits, 0); + assert_eq!(first_stats.base_renders, 1); + assert_eq!(first_stats.target_renders, 1); + assert_eq!(first_stats.pending_baselines, 1); + assert!(!first_stats.confirmed_baseline); + + assert!(!session.take(1, 1).unwrap().is_empty()); + let taken_stats = session.stats(); + assert_eq!(taken_stats.pending_baselines, 1); + assert!(!taken_stats.confirmed_baseline); + assert!(session.confirm(1, 1, 0).is_err()); + assert!(!session.stats().confirmed_baseline); + + let first_again = proof_layout_payload( + r#"[{"key":2,"viewport-width":120,"viewport-height":10,"root-width":120,"patch":true,"base-viewport-width":80,"base-viewport-height":10,"base-root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + assert_eq!(session.submit(2, &first_again).unwrap(), 1); + wait_until(|| session.ready(2, 2)); + assert!(session.confirm(2, 2, 1).unwrap()); + assert!(session.take(2, 2).unwrap().len() >= MIN_TAPE_BYTES); + + let second = batch( + r#"[{"key":3,"viewport-width":80,"viewport-height":10,"root-width":80,"patch":true,"base-viewport-width":120,"base-viewport-height":10,"base-root-width":120,"runtime-revision":1,"context-hash":77}]"#, + ); + assert_eq!(session.submit(3, &second).unwrap(), 1); + wait_until(|| session.ready(3, 3)); + assert!(!session.take(3, 3).unwrap().is_empty()); + let final_stats = session.stats(); + assert_eq!(final_stats.baseline_misses, 2); + assert_eq!(final_stats.baseline_hits, 1); + assert_eq!(final_stats.base_renders, 2); + assert_eq!(final_stats.target_renders, 3); + session.stop(true); + } + + #[test] + fn synchronous_proof_render_returns_full_tape_without_session() { + let payload = + proof_layout_payload(r#"[{"key":9,"viewport-width":80,"viewport-height":10}]"#); + let tape = render_proof(&payload).unwrap(); + assert!(tape.len() >= MIN_TAPE_BYTES); + assert_eq!(read_le_u64(&tape, 20), SYNC_RENDER_SESSION_ID); + } + + #[test] + fn synchronous_proof_render_distinguishes_known_and_unknown_width() { + let known = render_proof(&column_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10}]"#, + )) + .unwrap(); + let unknown = render_proof(&column_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-width-known":false,"viewport-height":10}]"#, + )) + .unwrap(); + + assert_eq!(full_tape_character_count(&unknown), 5); + assert_eq!(full_tape_character_count(&known), 6); + assert_eq!(full_tape_line_widths(&unknown), vec![16, 16]); + assert_eq!(full_tape_line_widths(&known), vec![80, 80]); + } + + #[test] + fn synchronous_proof_render_rejects_non_proof_batches() { + let echo = batch(r#"[{"key":1,"payload":"not-layout"}]"#); + assert!(render_proof(&echo) + .unwrap_err() + .contains("requires an inline document")); + + let multiple = proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10},{"key":2,"viewport-width":80,"viewport-height":10}]"#, + ); + assert!(render_proof(&multiple) + .unwrap_err() + .contains("exactly one layout frame")); + + let patch = proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"patch":true,"base-viewport-width":80,"base-viewport-height":10}]"#, + ); + assert!(render_proof(&patch) + .unwrap_err() + .contains("full layout frames only")); + + let incomplete = proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"complete":false}]"#, + ); + assert!(render_proof(&incomplete) + .unwrap_err() + .contains("requires complete frames")); + + let full_with_base = proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"base-viewport-width-known":false}]"#, + ); + assert!(render_proof(&full_with_base) + .unwrap_err() + .contains("cannot contain base geometry")); + } +} diff --git a/native/vendor/emacs-30/emacs-module.h b/native/vendor/emacs-30/emacs-module.h new file mode 100644 index 0000000..ad0c120 --- /dev/null +++ b/native/vendor/emacs-30/emacs-module.h @@ -0,0 +1,1122 @@ +/* emacs-module.h - GNU Emacs module API. + +Copyright (C) 2015-2025 Free Software Foundation, Inc. + +This file is part of GNU Emacs. + +GNU Emacs is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or (at +your option) any later version. + +GNU Emacs is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with GNU Emacs. If not, see . */ + +/* +This file defines the Emacs module API. Please see the chapter +`Dynamic Modules' in the GNU Emacs Lisp Reference Manual for +information how to write modules and use this header file. +*/ + +#ifndef EMACS_MODULE_H +#define EMACS_MODULE_H + +#include +#include +#include + +#if ((defined __STDC_VERSION__ ? __STDC_VERSION__ : 0) < 202311 \ + && !defined __bool_true_false_are_defined && !defined __cplusplus) +#include +#endif + +#define EMACS_MAJOR_VERSION 30 + +#if defined __cplusplus && __cplusplus >= 201103L +# define EMACS_NOEXCEPT noexcept +#else +# define EMACS_NOEXCEPT +#endif + +#if defined __cplusplus && __cplusplus >= 201703L +# define EMACS_NOEXCEPT_TYPEDEF noexcept +#else +# define EMACS_NOEXCEPT_TYPEDEF +#endif + +#if 3 < __GNUC__ + (3 <= __GNUC_MINOR__) +# define EMACS_ATTRIBUTE_NONNULL(...) \ + __attribute__ ((__nonnull__ (__VA_ARGS__))) +#elif (defined __has_attribute \ + && (!defined __clang_minor__ \ + || 3 < __clang_major__ + (5 <= __clang_minor__))) +# if __has_attribute (__nonnull__) +# define EMACS_ATTRIBUTE_NONNULL(...) \ + __attribute__ ((__nonnull__ (__VA_ARGS__))) +# endif +#endif +#ifndef EMACS_ATTRIBUTE_NONNULL +# define EMACS_ATTRIBUTE_NONNULL(...) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* Current environment. */ +typedef struct emacs_env_30 emacs_env; + +/* Opaque pointer representing an Emacs Lisp value. + BEWARE: Do not assume NULL is a valid value! */ +typedef struct emacs_value_tag *emacs_value; + +enum { emacs_variadic_function = -2 }; + +/* Struct passed to a module init function (emacs_module_init). */ +struct emacs_runtime +{ + /* Structure size (for version checking). */ + ptrdiff_t size; + + /* Private data; users should not touch this. */ + struct emacs_runtime_private *private_members; + + /* Return an environment pointer. */ + emacs_env *(*get_environment) (struct emacs_runtime *runtime) + EMACS_ATTRIBUTE_NONNULL (1); +}; + +/* Type aliases for function pointer types used in the module API. + Note that we don't use these aliases directly in the API to be able + to mark the function arguments as 'noexcept' before C++20. + However, users can use them if they want. */ + +/* Function prototype for the module Lisp functions. These must not + throw C++ exceptions. */ +typedef emacs_value (*emacs_function) (emacs_env *env, ptrdiff_t nargs, + emacs_value *args, + void *data) + EMACS_NOEXCEPT_TYPEDEF EMACS_ATTRIBUTE_NONNULL (1); + +/* Function prototype for module user-pointer and function finalizers. + These must not throw C++ exceptions. */ +typedef void (*emacs_finalizer) (void *data) EMACS_NOEXCEPT_TYPEDEF; + +/* Possible Emacs function call outcomes. */ +enum emacs_funcall_exit +{ + /* Function has returned normally. */ + emacs_funcall_exit_return = 0, + + /* Function has signaled an error using `signal'. */ + emacs_funcall_exit_signal = 1, + + /* Function has exited using `throw'. */ + emacs_funcall_exit_throw = 2 +}; + +/* Possible return values for emacs_env.process_input. */ +enum emacs_process_input_result +{ + /* Module code may continue */ + emacs_process_input_continue = 0, + + /* Module code should return control to Emacs as soon as possible. */ + emacs_process_input_quit = 1 +}; + +/* Define emacs_limb_t so that it is likely to match GMP's mp_limb_t. + This micro-optimization can help modules that use mpz_export and + mpz_import, which operate more efficiently on mp_limb_t. It's OK + (if perhaps a bit slower) if the two types do not match, and + modules shouldn't rely on the two types matching. */ +typedef size_t emacs_limb_t; +#define EMACS_LIMB_MAX SIZE_MAX + +struct emacs_env_25 +{ + /* Structure size (for version checking). */ + ptrdiff_t size; + + /* Private data; users should not touch this. */ + struct emacs_env_private *private_members; + + /* Memory management. */ + + emacs_value (*make_global_ref) (emacs_env *env, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*free_global_ref) (emacs_env *env, emacs_value global_value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Non-local exit handling. */ + + enum emacs_funcall_exit (*non_local_exit_check) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_clear) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + enum emacs_funcall_exit (*non_local_exit_get) + (emacs_env *env, emacs_value *symbol, emacs_value *data) + EMACS_ATTRIBUTE_NONNULL(1, 2, 3); + + void (*non_local_exit_signal) (emacs_env *env, + emacs_value symbol, emacs_value data) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_throw) (emacs_env *env, + emacs_value tag, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Function registration. */ + + emacs_value (*make_function) (emacs_env *env, + ptrdiff_t min_arity, + ptrdiff_t max_arity, + emacs_value (*func) (emacs_env *env, + ptrdiff_t nargs, + emacs_value* args, + void *data) + EMACS_NOEXCEPT + EMACS_ATTRIBUTE_NONNULL(1), + const char *docstring, + void *data) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + emacs_value (*funcall) (emacs_env *env, + emacs_value func, + ptrdiff_t nargs, + emacs_value* args) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*intern) (emacs_env *env, const char *name) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Type conversion. */ + + emacs_value (*type_of) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*is_not_nil) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*eq) (emacs_env *env, emacs_value a, emacs_value b) + EMACS_ATTRIBUTE_NONNULL(1); + + intmax_t (*extract_integer) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_integer) (emacs_env *env, intmax_t n) + EMACS_ATTRIBUTE_NONNULL(1); + + double (*extract_float) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_float) (emacs_env *env, double d) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Copy the content of the Lisp string VALUE to BUFFER as an utf8 + null-terminated string. + + SIZE must point to the total size of the buffer. If BUFFER is + NULL or if SIZE is not big enough, write the required buffer size + to SIZE and return true. + + Note that SIZE must include the last null byte (e.g. "abc" needs + a buffer of size 4). + + Return true if the string was successfully copied. */ + + bool (*copy_string_contents) (emacs_env *env, + emacs_value value, + char *buf, + ptrdiff_t *len) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + /* Create a Lisp string from a utf8 encoded string. */ + emacs_value (*make_string) (emacs_env *env, + const char *str, ptrdiff_t len) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Embedded pointer type. */ + emacs_value (*make_user_ptr) (emacs_env *env, + void (*fin) (void *) EMACS_NOEXCEPT, + void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void *(*get_user_ptr) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_ptr) (emacs_env *env, emacs_value arg, void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*(*get_user_finalizer) (emacs_env *env, emacs_value uptr)) + (void *) EMACS_NOEXCEPT EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_finalizer) (emacs_env *env, emacs_value arg, + void (*fin) (void *) EMACS_NOEXCEPT) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Vector functions. */ + emacs_value (*vec_get) (emacs_env *env, emacs_value vector, ptrdiff_t index) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*vec_set) (emacs_env *env, emacs_value vector, ptrdiff_t index, + emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + ptrdiff_t (*vec_size) (emacs_env *env, emacs_value vector) + EMACS_ATTRIBUTE_NONNULL(1); +}; + +struct emacs_env_26 +{ + /* Structure size (for version checking). */ + ptrdiff_t size; + + /* Private data; users should not touch this. */ + struct emacs_env_private *private_members; + + /* Memory management. */ + + emacs_value (*make_global_ref) (emacs_env *env, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*free_global_ref) (emacs_env *env, emacs_value global_value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Non-local exit handling. */ + + enum emacs_funcall_exit (*non_local_exit_check) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_clear) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + enum emacs_funcall_exit (*non_local_exit_get) + (emacs_env *env, emacs_value *symbol, emacs_value *data) + EMACS_ATTRIBUTE_NONNULL(1, 2, 3); + + void (*non_local_exit_signal) (emacs_env *env, + emacs_value symbol, emacs_value data) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_throw) (emacs_env *env, + emacs_value tag, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Function registration. */ + + emacs_value (*make_function) (emacs_env *env, + ptrdiff_t min_arity, + ptrdiff_t max_arity, + emacs_value (*func) (emacs_env *env, + ptrdiff_t nargs, + emacs_value* args, + void *data) + EMACS_NOEXCEPT + EMACS_ATTRIBUTE_NONNULL(1), + const char *docstring, + void *data) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + emacs_value (*funcall) (emacs_env *env, + emacs_value func, + ptrdiff_t nargs, + emacs_value* args) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*intern) (emacs_env *env, const char *name) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Type conversion. */ + + emacs_value (*type_of) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*is_not_nil) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*eq) (emacs_env *env, emacs_value a, emacs_value b) + EMACS_ATTRIBUTE_NONNULL(1); + + intmax_t (*extract_integer) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_integer) (emacs_env *env, intmax_t n) + EMACS_ATTRIBUTE_NONNULL(1); + + double (*extract_float) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_float) (emacs_env *env, double d) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Copy the content of the Lisp string VALUE to BUFFER as an utf8 + null-terminated string. + + SIZE must point to the total size of the buffer. If BUFFER is + NULL or if SIZE is not big enough, write the required buffer size + to SIZE and return true. + + Note that SIZE must include the last null byte (e.g. "abc" needs + a buffer of size 4). + + Return true if the string was successfully copied. */ + + bool (*copy_string_contents) (emacs_env *env, + emacs_value value, + char *buf, + ptrdiff_t *len) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + /* Create a Lisp string from a utf8 encoded string. */ + emacs_value (*make_string) (emacs_env *env, + const char *str, ptrdiff_t len) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Embedded pointer type. */ + emacs_value (*make_user_ptr) (emacs_env *env, + void (*fin) (void *) EMACS_NOEXCEPT, + void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void *(*get_user_ptr) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_ptr) (emacs_env *env, emacs_value arg, void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*(*get_user_finalizer) (emacs_env *env, emacs_value uptr)) + (void *) EMACS_NOEXCEPT EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_finalizer) (emacs_env *env, emacs_value arg, + void (*fin) (void *) EMACS_NOEXCEPT) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Vector functions. */ + emacs_value (*vec_get) (emacs_env *env, emacs_value vector, ptrdiff_t index) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*vec_set) (emacs_env *env, emacs_value vector, ptrdiff_t index, + emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + ptrdiff_t (*vec_size) (emacs_env *env, emacs_value vector) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Returns whether a quit is pending. */ + bool (*should_quit) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); +}; + +struct emacs_env_27 +{ + /* Structure size (for version checking). */ + ptrdiff_t size; + + /* Private data; users should not touch this. */ + struct emacs_env_private *private_members; + + /* Memory management. */ + + emacs_value (*make_global_ref) (emacs_env *env, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*free_global_ref) (emacs_env *env, emacs_value global_value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Non-local exit handling. */ + + enum emacs_funcall_exit (*non_local_exit_check) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_clear) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + enum emacs_funcall_exit (*non_local_exit_get) + (emacs_env *env, emacs_value *symbol, emacs_value *data) + EMACS_ATTRIBUTE_NONNULL(1, 2, 3); + + void (*non_local_exit_signal) (emacs_env *env, + emacs_value symbol, emacs_value data) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_throw) (emacs_env *env, + emacs_value tag, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Function registration. */ + + emacs_value (*make_function) (emacs_env *env, + ptrdiff_t min_arity, + ptrdiff_t max_arity, + emacs_value (*func) (emacs_env *env, + ptrdiff_t nargs, + emacs_value* args, + void *data) + EMACS_NOEXCEPT + EMACS_ATTRIBUTE_NONNULL(1), + const char *docstring, + void *data) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + emacs_value (*funcall) (emacs_env *env, + emacs_value func, + ptrdiff_t nargs, + emacs_value* args) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*intern) (emacs_env *env, const char *name) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Type conversion. */ + + emacs_value (*type_of) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*is_not_nil) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*eq) (emacs_env *env, emacs_value a, emacs_value b) + EMACS_ATTRIBUTE_NONNULL(1); + + intmax_t (*extract_integer) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_integer) (emacs_env *env, intmax_t n) + EMACS_ATTRIBUTE_NONNULL(1); + + double (*extract_float) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_float) (emacs_env *env, double d) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Copy the content of the Lisp string VALUE to BUFFER as an utf8 + null-terminated string. + + SIZE must point to the total size of the buffer. If BUFFER is + NULL or if SIZE is not big enough, write the required buffer size + to SIZE and return true. + + Note that SIZE must include the last null byte (e.g. "abc" needs + a buffer of size 4). + + Return true if the string was successfully copied. */ + + bool (*copy_string_contents) (emacs_env *env, + emacs_value value, + char *buf, + ptrdiff_t *len) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + /* Create a Lisp string from a utf8 encoded string. */ + emacs_value (*make_string) (emacs_env *env, + const char *str, ptrdiff_t len) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Embedded pointer type. */ + emacs_value (*make_user_ptr) (emacs_env *env, + void (*fin) (void *) EMACS_NOEXCEPT, + void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void *(*get_user_ptr) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_ptr) (emacs_env *env, emacs_value arg, void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*(*get_user_finalizer) (emacs_env *env, emacs_value uptr)) + (void *) EMACS_NOEXCEPT EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_finalizer) (emacs_env *env, emacs_value arg, + void (*fin) (void *) EMACS_NOEXCEPT) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Vector functions. */ + emacs_value (*vec_get) (emacs_env *env, emacs_value vector, ptrdiff_t index) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*vec_set) (emacs_env *env, emacs_value vector, ptrdiff_t index, + emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + ptrdiff_t (*vec_size) (emacs_env *env, emacs_value vector) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Returns whether a quit is pending. */ + bool (*should_quit) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Processes pending input events and returns whether the module + function should quit. */ + enum emacs_process_input_result (*process_input) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL (1); + + struct timespec (*extract_time) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL (1); + + emacs_value (*make_time) (emacs_env *env, struct timespec time) + EMACS_ATTRIBUTE_NONNULL (1); + + bool (*extract_big_integer) (emacs_env *env, emacs_value arg, int *sign, + ptrdiff_t *count, emacs_limb_t *magnitude) + EMACS_ATTRIBUTE_NONNULL (1); + + emacs_value (*make_big_integer) (emacs_env *env, int sign, ptrdiff_t count, + const emacs_limb_t *magnitude) + EMACS_ATTRIBUTE_NONNULL (1); +}; + +struct emacs_env_28 +{ + /* Structure size (for version checking). */ + ptrdiff_t size; + + /* Private data; users should not touch this. */ + struct emacs_env_private *private_members; + + /* Memory management. */ + + emacs_value (*make_global_ref) (emacs_env *env, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*free_global_ref) (emacs_env *env, emacs_value global_value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Non-local exit handling. */ + + enum emacs_funcall_exit (*non_local_exit_check) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_clear) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + enum emacs_funcall_exit (*non_local_exit_get) + (emacs_env *env, emacs_value *symbol, emacs_value *data) + EMACS_ATTRIBUTE_NONNULL(1, 2, 3); + + void (*non_local_exit_signal) (emacs_env *env, + emacs_value symbol, emacs_value data) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_throw) (emacs_env *env, + emacs_value tag, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Function registration. */ + + emacs_value (*make_function) (emacs_env *env, + ptrdiff_t min_arity, + ptrdiff_t max_arity, + emacs_value (*func) (emacs_env *env, + ptrdiff_t nargs, + emacs_value* args, + void *data) + EMACS_NOEXCEPT + EMACS_ATTRIBUTE_NONNULL(1), + const char *docstring, + void *data) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + emacs_value (*funcall) (emacs_env *env, + emacs_value func, + ptrdiff_t nargs, + emacs_value* args) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*intern) (emacs_env *env, const char *name) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Type conversion. */ + + emacs_value (*type_of) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*is_not_nil) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*eq) (emacs_env *env, emacs_value a, emacs_value b) + EMACS_ATTRIBUTE_NONNULL(1); + + intmax_t (*extract_integer) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_integer) (emacs_env *env, intmax_t n) + EMACS_ATTRIBUTE_NONNULL(1); + + double (*extract_float) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_float) (emacs_env *env, double d) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Copy the content of the Lisp string VALUE to BUFFER as an utf8 + null-terminated string. + + SIZE must point to the total size of the buffer. If BUFFER is + NULL or if SIZE is not big enough, write the required buffer size + to SIZE and return true. + + Note that SIZE must include the last null byte (e.g. "abc" needs + a buffer of size 4). + + Return true if the string was successfully copied. */ + + bool (*copy_string_contents) (emacs_env *env, + emacs_value value, + char *buf, + ptrdiff_t *len) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + /* Create a Lisp string from a utf8 encoded string. */ + emacs_value (*make_string) (emacs_env *env, + const char *str, ptrdiff_t len) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Embedded pointer type. */ + emacs_value (*make_user_ptr) (emacs_env *env, + void (*fin) (void *) EMACS_NOEXCEPT, + void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void *(*get_user_ptr) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_ptr) (emacs_env *env, emacs_value arg, void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*(*get_user_finalizer) (emacs_env *env, emacs_value uptr)) + (void *) EMACS_NOEXCEPT EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_finalizer) (emacs_env *env, emacs_value arg, + void (*fin) (void *) EMACS_NOEXCEPT) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Vector functions. */ + emacs_value (*vec_get) (emacs_env *env, emacs_value vector, ptrdiff_t index) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*vec_set) (emacs_env *env, emacs_value vector, ptrdiff_t index, + emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + ptrdiff_t (*vec_size) (emacs_env *env, emacs_value vector) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Returns whether a quit is pending. */ + bool (*should_quit) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Processes pending input events and returns whether the module + function should quit. */ + enum emacs_process_input_result (*process_input) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL (1); + + struct timespec (*extract_time) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL (1); + + emacs_value (*make_time) (emacs_env *env, struct timespec time) + EMACS_ATTRIBUTE_NONNULL (1); + + bool (*extract_big_integer) (emacs_env *env, emacs_value arg, int *sign, + ptrdiff_t *count, emacs_limb_t *magnitude) + EMACS_ATTRIBUTE_NONNULL (1); + + emacs_value (*make_big_integer) (emacs_env *env, int sign, ptrdiff_t count, + const emacs_limb_t *magnitude) + EMACS_ATTRIBUTE_NONNULL (1); + + void (*(*EMACS_ATTRIBUTE_NONNULL (1) + get_function_finalizer) (emacs_env *env, + emacs_value arg)) (void *) EMACS_NOEXCEPT; + + void (*set_function_finalizer) (emacs_env *env, emacs_value arg, + void (*fin) (void *) EMACS_NOEXCEPT) + EMACS_ATTRIBUTE_NONNULL (1); + + int (*open_channel) (emacs_env *env, emacs_value pipe_process) + EMACS_ATTRIBUTE_NONNULL (1); + + void (*make_interactive) (emacs_env *env, emacs_value function, + emacs_value spec) + EMACS_ATTRIBUTE_NONNULL (1); + + /* Create a unibyte Lisp string from a string. */ + emacs_value (*make_unibyte_string) (emacs_env *env, + const char *str, ptrdiff_t len) + EMACS_ATTRIBUTE_NONNULL(1, 2); +}; + +struct emacs_env_29 +{ + /* Structure size (for version checking). */ + ptrdiff_t size; + + /* Private data; users should not touch this. */ + struct emacs_env_private *private_members; + + /* Memory management. */ + + emacs_value (*make_global_ref) (emacs_env *env, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*free_global_ref) (emacs_env *env, emacs_value global_value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Non-local exit handling. */ + + enum emacs_funcall_exit (*non_local_exit_check) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_clear) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + enum emacs_funcall_exit (*non_local_exit_get) + (emacs_env *env, emacs_value *symbol, emacs_value *data) + EMACS_ATTRIBUTE_NONNULL(1, 2, 3); + + void (*non_local_exit_signal) (emacs_env *env, + emacs_value symbol, emacs_value data) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_throw) (emacs_env *env, + emacs_value tag, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Function registration. */ + + emacs_value (*make_function) (emacs_env *env, + ptrdiff_t min_arity, + ptrdiff_t max_arity, + emacs_value (*func) (emacs_env *env, + ptrdiff_t nargs, + emacs_value* args, + void *data) + EMACS_NOEXCEPT + EMACS_ATTRIBUTE_NONNULL(1), + const char *docstring, + void *data) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + emacs_value (*funcall) (emacs_env *env, + emacs_value func, + ptrdiff_t nargs, + emacs_value* args) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*intern) (emacs_env *env, const char *name) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Type conversion. */ + + emacs_value (*type_of) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*is_not_nil) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*eq) (emacs_env *env, emacs_value a, emacs_value b) + EMACS_ATTRIBUTE_NONNULL(1); + + intmax_t (*extract_integer) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_integer) (emacs_env *env, intmax_t n) + EMACS_ATTRIBUTE_NONNULL(1); + + double (*extract_float) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_float) (emacs_env *env, double d) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Copy the content of the Lisp string VALUE to BUFFER as an utf8 + null-terminated string. + + SIZE must point to the total size of the buffer. If BUFFER is + NULL or if SIZE is not big enough, write the required buffer size + to SIZE and return true. + + Note that SIZE must include the last null byte (e.g. "abc" needs + a buffer of size 4). + + Return true if the string was successfully copied. */ + + bool (*copy_string_contents) (emacs_env *env, + emacs_value value, + char *buf, + ptrdiff_t *len) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + /* Create a Lisp string from a utf8 encoded string. */ + emacs_value (*make_string) (emacs_env *env, + const char *str, ptrdiff_t len) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Embedded pointer type. */ + emacs_value (*make_user_ptr) (emacs_env *env, + void (*fin) (void *) EMACS_NOEXCEPT, + void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void *(*get_user_ptr) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_ptr) (emacs_env *env, emacs_value arg, void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*(*get_user_finalizer) (emacs_env *env, emacs_value uptr)) + (void *) EMACS_NOEXCEPT EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_finalizer) (emacs_env *env, emacs_value arg, + void (*fin) (void *) EMACS_NOEXCEPT) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Vector functions. */ + emacs_value (*vec_get) (emacs_env *env, emacs_value vector, ptrdiff_t index) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*vec_set) (emacs_env *env, emacs_value vector, ptrdiff_t index, + emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + ptrdiff_t (*vec_size) (emacs_env *env, emacs_value vector) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Returns whether a quit is pending. */ + bool (*should_quit) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Processes pending input events and returns whether the module + function should quit. */ + enum emacs_process_input_result (*process_input) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL (1); + + struct timespec (*extract_time) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL (1); + + emacs_value (*make_time) (emacs_env *env, struct timespec time) + EMACS_ATTRIBUTE_NONNULL (1); + + bool (*extract_big_integer) (emacs_env *env, emacs_value arg, int *sign, + ptrdiff_t *count, emacs_limb_t *magnitude) + EMACS_ATTRIBUTE_NONNULL (1); + + emacs_value (*make_big_integer) (emacs_env *env, int sign, ptrdiff_t count, + const emacs_limb_t *magnitude) + EMACS_ATTRIBUTE_NONNULL (1); + + void (*(*EMACS_ATTRIBUTE_NONNULL (1) + get_function_finalizer) (emacs_env *env, + emacs_value arg)) (void *) EMACS_NOEXCEPT; + + void (*set_function_finalizer) (emacs_env *env, emacs_value arg, + void (*fin) (void *) EMACS_NOEXCEPT) + EMACS_ATTRIBUTE_NONNULL (1); + + int (*open_channel) (emacs_env *env, emacs_value pipe_process) + EMACS_ATTRIBUTE_NONNULL (1); + + void (*make_interactive) (emacs_env *env, emacs_value function, + emacs_value spec) + EMACS_ATTRIBUTE_NONNULL (1); + + /* Create a unibyte Lisp string from a string. */ + emacs_value (*make_unibyte_string) (emacs_env *env, + const char *str, ptrdiff_t len) + EMACS_ATTRIBUTE_NONNULL(1, 2); + +}; + +struct emacs_env_30 +{ + /* Structure size (for version checking). */ + ptrdiff_t size; + + /* Private data; users should not touch this. */ + struct emacs_env_private *private_members; + + /* Memory management. */ + + emacs_value (*make_global_ref) (emacs_env *env, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*free_global_ref) (emacs_env *env, emacs_value global_value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Non-local exit handling. */ + + enum emacs_funcall_exit (*non_local_exit_check) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_clear) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + enum emacs_funcall_exit (*non_local_exit_get) + (emacs_env *env, emacs_value *symbol, emacs_value *data) + EMACS_ATTRIBUTE_NONNULL(1, 2, 3); + + void (*non_local_exit_signal) (emacs_env *env, + emacs_value symbol, emacs_value data) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*non_local_exit_throw) (emacs_env *env, + emacs_value tag, emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Function registration. */ + + emacs_value (*make_function) (emacs_env *env, + ptrdiff_t min_arity, + ptrdiff_t max_arity, + emacs_value (*func) (emacs_env *env, + ptrdiff_t nargs, + emacs_value* args, + void *data) + EMACS_NOEXCEPT + EMACS_ATTRIBUTE_NONNULL(1), + const char *docstring, + void *data) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + emacs_value (*funcall) (emacs_env *env, + emacs_value func, + ptrdiff_t nargs, + emacs_value* args) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*intern) (emacs_env *env, const char *name) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Type conversion. */ + + emacs_value (*type_of) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*is_not_nil) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + bool (*eq) (emacs_env *env, emacs_value a, emacs_value b) + EMACS_ATTRIBUTE_NONNULL(1); + + intmax_t (*extract_integer) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_integer) (emacs_env *env, intmax_t n) + EMACS_ATTRIBUTE_NONNULL(1); + + double (*extract_float) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + + emacs_value (*make_float) (emacs_env *env, double d) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Copy the content of the Lisp string VALUE to BUFFER as an utf8 + null-terminated string. + + SIZE must point to the total size of the buffer. If BUFFER is + NULL or if SIZE is not big enough, write the required buffer size + to SIZE and return true. + + Note that SIZE must include the last null byte (e.g. "abc" needs + a buffer of size 4). + + Return true if the string was successfully copied. */ + + bool (*copy_string_contents) (emacs_env *env, + emacs_value value, + char *buf, + ptrdiff_t *len) + EMACS_ATTRIBUTE_NONNULL(1, 4); + + /* Create a Lisp string from a utf8 encoded string. */ + emacs_value (*make_string) (emacs_env *env, + const char *str, ptrdiff_t len) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + /* Embedded pointer type. */ + emacs_value (*make_user_ptr) (emacs_env *env, + void (*fin) (void *) EMACS_NOEXCEPT, + void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void *(*get_user_ptr) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_ptr) (emacs_env *env, emacs_value arg, void *ptr) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*(*get_user_finalizer) (emacs_env *env, emacs_value uptr)) + (void *) EMACS_NOEXCEPT EMACS_ATTRIBUTE_NONNULL(1); + void (*set_user_finalizer) (emacs_env *env, emacs_value arg, + void (*fin) (void *) EMACS_NOEXCEPT) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Vector functions. */ + emacs_value (*vec_get) (emacs_env *env, emacs_value vector, ptrdiff_t index) + EMACS_ATTRIBUTE_NONNULL(1); + + void (*vec_set) (emacs_env *env, emacs_value vector, ptrdiff_t index, + emacs_value value) + EMACS_ATTRIBUTE_NONNULL(1); + + ptrdiff_t (*vec_size) (emacs_env *env, emacs_value vector) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Returns whether a quit is pending. */ + bool (*should_quit) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL(1); + + /* Processes pending input events and returns whether the module + function should quit. */ + enum emacs_process_input_result (*process_input) (emacs_env *env) + EMACS_ATTRIBUTE_NONNULL (1); + + struct timespec (*extract_time) (emacs_env *env, emacs_value arg) + EMACS_ATTRIBUTE_NONNULL (1); + + emacs_value (*make_time) (emacs_env *env, struct timespec time) + EMACS_ATTRIBUTE_NONNULL (1); + + bool (*extract_big_integer) (emacs_env *env, emacs_value arg, int *sign, + ptrdiff_t *count, emacs_limb_t *magnitude) + EMACS_ATTRIBUTE_NONNULL (1); + + emacs_value (*make_big_integer) (emacs_env *env, int sign, ptrdiff_t count, + const emacs_limb_t *magnitude) + EMACS_ATTRIBUTE_NONNULL (1); + + void (*(*EMACS_ATTRIBUTE_NONNULL (1) + get_function_finalizer) (emacs_env *env, + emacs_value arg)) (void *) EMACS_NOEXCEPT; + + void (*set_function_finalizer) (emacs_env *env, emacs_value arg, + void (*fin) (void *) EMACS_NOEXCEPT) + EMACS_ATTRIBUTE_NONNULL (1); + + int (*open_channel) (emacs_env *env, emacs_value pipe_process) + EMACS_ATTRIBUTE_NONNULL (1); + + void (*make_interactive) (emacs_env *env, emacs_value function, + emacs_value spec) + EMACS_ATTRIBUTE_NONNULL (1); + + /* Create a unibyte Lisp string from a string. */ + emacs_value (*make_unibyte_string) (emacs_env *env, + const char *str, ptrdiff_t len) + EMACS_ATTRIBUTE_NONNULL(1, 2); + + + /* Add module environment functions newly added in Emacs 30 here. + Before Emacs 30 is released, remove this comment and start + module-env-31.h on the master branch. */ +}; + +/* Every module should define a function as follows. */ +extern int emacs_module_init (struct emacs_runtime *runtime) + EMACS_NOEXCEPT + EMACS_ATTRIBUTE_NONNULL (1); + +#ifdef __cplusplus +} +#endif + +#endif /* EMACS_MODULE_H */ diff --git a/scripts/ebox-package-lint.el b/scripts/ebox-package-lint.el new file mode 100644 index 0000000..c95666e --- /dev/null +++ b/scripts/ebox-package-lint.el @@ -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 diff --git a/scripts/ebox-visual-check.el b/scripts/ebox-visual-check.el new file mode 100644 index 0000000..cebe80a --- /dev/null +++ b/scripts/ebox-visual-check.el @@ -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 diff --git a/tests/ebox-ci-contract-tests.el b/tests/ebox-ci-contract-tests.el new file mode 100644 index 0000000..3123dfd --- /dev/null +++ b/tests/ebox-ci-contract-tests.el @@ -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 diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el new file mode 100644 index 0000000..3ed97d9 --- /dev/null +++ b/tests/ebox-commit-tests.el @@ -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 diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el new file mode 100644 index 0000000..8a97b7c --- /dev/null +++ b/tests/ebox-core-render-tests.el @@ -0,0 +1,17079 @@ +;;; ebox-core-render-tests.el --- Core render and update regression tests -*- 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")) + +(setq load-prefer-newer t) + +(load-file (expand-file-name "../ebox.el" (file-name-directory load-file-name))) + +(defvar ebox--scroll-window-initial-lookahead-lines-override nil) + +(defun ebox-test--reset-runtime-state () + "Reset global runtime state used by ebox rendering tests." + (setq ebox--region-id-counter 0) + (setq ebox--runtime-node-id-counter 0) + (setq ebox--render-region-id nil) + (setq ebox--scroll-allow-noninteractive-prefetch t) + (ebox-cache-reset-default-registry) + (dolist (table (list ebox--region-box-table + ebox--box-extents + ebox--buffer-render-state-table + ebox--scroll-global-state + ebox--render-root-cache-table-table + ebox--render-cache-entry-side-effects-table + ebox--rendered-root-metadata-table + ebox--rendered-uniform-width-table + ebox--flex-sized-render-observation-table)) + (clrhash table)) + (maphash (lambda (_region-id timer) + (when (timerp timer) + (cancel-timer timer))) + ebox--scroll-idle-prefetch-timers) + (clrhash ebox--scroll-idle-prefetch-timers) + (maphash (lambda (_buffer timer) + (when (timerp timer) + (cancel-timer timer))) + ebox--runtime-prewarm-timers) + (clrhash ebox--runtime-prewarm-timers) + (clrhash ebox--runtime-prewarm-jobs) + (maphash (lambda (_buffer timer) + (when (timerp timer) + (cancel-timer timer))) + ebox--reflow-cache-prewarm-timers) + (clrhash ebox--reflow-cache-prewarm-timers) + (when (timerp ebox--deferred-render-gc-timer) + (cancel-timer ebox--deferred-render-gc-timer)) + (setq ebox--deferred-render-gc-timer nil) + (setq ebox--deferred-render-gc-state nil)) + +(defmacro ebox-test--with-rendered-buffer (layout &rest body) + "Render LAYOUT into a temporary ebox buffer and run BODY there." + (declare (indent 1)) + `(let* ((buffer-name (generate-new-buffer-name " *ebox-test*")) + (buffer (ebox-render-to-buffer buffer-name ,layout))) + (unwind-protect + (with-current-buffer buffer + ,@body) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(defun ebox-test--set-buffer-update-report (buffer report) + "Store REPORT in BUFFER's render state." + (plist-put (ebox--buffer-render-state buffer) :last-update-report report)) + +(defun ebox-test--buffer-update-report (&optional buffer) + "Return BUFFER's update report, defaulting to the active rendered buffer." + (let ((target + (or buffer + (and (ebox--buffer-render-state (current-buffer)) + (current-buffer)) + (let (candidate ambiguous) + (maphash + (lambda (render-buffer _state) + (when (buffer-live-p render-buffer) + (if candidate + (setq ambiguous t) + (setq candidate render-buffer)))) + ebox--buffer-render-state-table) + (and (not ambiguous) candidate))))) + (ebox-buffer-update-report target))) + +(ert-deftest ebox-scroll-window-lookahead-accepts-root-reflow-override () + "Root reflow should be able to defer all hidden lookahead work." + (let ((ebox-scroll-lazy-prefix-lookahead-lines 8) + (ebox-wheel-scroll-step 16) + (ebox--scroll-window-initial-lookahead-lines-override 0)) + (should (= (ebox--scroll-window-initial-lookahead-lines) 0)))) + +(ert-deftest ebox-root-reflow-lookahead-keeps-flex-rows-demand-driven () + "Root reflow should not cross an explicit flex item's atomic row eagerly." + (ebox-test--reset-runtime-state) + (let (plain-buffer flex-buffer) + (unwind-protect + (progn + (setq plain-buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-plain-lookahead*") + (ebox-create :content "plain" :width 120))) + (setq flex-buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-flex-lookahead*") + (ebox-flex + :width '(120) + (ebox-flex-item + (ebox-create :content "item" :width '(40)))))) + (should + (= (ebox--root-reflow-scroll-lookahead-lines-for-buffer + plain-buffer) + ebox--root-reflow-scroll-lookahead-lines)) + (should + (= (ebox--root-reflow-scroll-lookahead-lines-for-buffer + flex-buffer) + 0))) + (dolist (buffer (list plain-buffer flex-buffer)) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer)))))) + +(ert-deftest ebox-root-reflow-lookahead-keeps-large-runtimes-demand-driven () + "Root reflow should not eagerly cross costly rows in a large runtime." + (ebox-test--reset-runtime-state) + (let ((buffer (generate-new-buffer " *ebox-large-lookahead*")) + (node-table (make-hash-table :test 'equal)) + (selector-type-table (make-hash-table :test 'eq))) + (unwind-protect + (progn + (dotimes (index 512) + (puthash index t node-table)) + (puthash buffer + (list :node-table node-table + :selector-type-table selector-type-table) + ebox--buffer-render-state-table) + (should + (= (ebox--root-reflow-scroll-lookahead-lines-for-buffer buffer) + 0))) + (remhash buffer ebox--buffer-render-state-table) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-exact-scroll-prefix-skips-completion-sentinel () + "Interactive prefix extension should request only the visible line bound." + (ebox-test--reset-runtime-state) + (let* ((region-id 'exact-prefix) + (all-lines '("zero" "one" "two" "three" "four")) + requested-limits + (render-prefix + (lambda (_state _region-id limit) + (push limit requested-limits) + (list :content-lines (seq-take all-lines limit) + :rendered-content-lines (seq-take all-lines limit) + :content-height 2 + :complete nil))) + (state (list :content-lines (seq-take all-lines 3) + :rendered-content-lines (seq-take all-lines 3) + :content-height 2 + :render-content-prefix render-prefix))) + (let ((ebox-scroll-lazy-prefix-lookahead-lines 0)) + (ebox--scroll-state-ensure-prefix-lines region-id state 4 t)) + (should (equal requested-limits '(4))) + (setq requested-limits nil + state (list :content-lines (seq-take all-lines 3) + :rendered-content-lines (seq-take all-lines 3) + :content-height 2 + :render-content-prefix render-prefix)) + (let ((ebox-scroll-lazy-prefix-lookahead-lines 0)) + (ebox--scroll-state-ensure-prefix-lines region-id state 4)) + (should (equal requested-limits '(5))))) + +(ert-deftest ebox-scroll-lightweight-line-bounds-extend-incrementally () + "Lazy line bounds should extend without rebuilding prior prefix entries." + (ebox-test--reset-runtime-state) + (let* ((line-a (propertize "a" 'ebox-content 11)) + (line-b (propertize "b" 'ebox-content 12)) + (line-c (propertize "c" 'ebox-content 11)) + (ebox--defer-scroll-content-index t) + (state (ebox--scroll-state-set-lines nil (list line-a line-b))) + (index (plist-get state :region-line-bounds-index))) + (should (equal (gethash 11 index) '(0 . 0))) + (should (equal (gethash 12 index) '(1 . 1))) + (setq state + (ebox--scroll-state-set-lines + state (list line-a line-b line-c))) + (should (eq index (plist-get state :region-line-bounds-index))) + (should (equal (gethash 11 index) '(0 . 2))) + (should (equal (gethash 12 index) '(1 . 1))) + (should (plist-get state :region-line-span-index-deferred)))) + +(ert-deftest ebox-scroll-prefix-publishes-private-line-with-suffix-paint () + "A cached hidden source line should not rebuild the rendered prefix." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "" :width 20 :height 2)) + (content-lines '("zero" "one" "two" "three")) + (state (list :content-lines (seq-take content-lines 3) + :rendered-content-lines + (seq-take content-lines 3) + :content-height 2)) + (source-renders 0) + rendered-requests + (original-rendered-lines + (symbol-function 'ebox--scroll-rendered-content-lines)) + (renderer + (ebox--scroll-window-prefix-renderer + box '(:stack ignored) 20 2 nil content-lines))) + (cl-letf (((symbol-function 'ebox--render-scroll-window-source) + (lambda (&rest _) + (cl-incf source-renders) + (error "Private cached line should satisfy the prefix"))) + ((symbol-function 'ebox--scroll-rendered-content-lines) + (lambda (&rest args) + (push (list (length (cadr args)) (nth 3 args)) + rendered-requests) + (apply original-rendered-lines args)))) + (let ((result (funcall renderer state 'private-prefix 4))) + (should (= source-renders 0)) + (should (equal rendered-requests '((1 3)))) + (should (= (length (plist-get result :content-lines)) 4)) + (should (= (length (plist-get result :rendered-content-lines)) 4)))))) + +(defun ebox-test--face-has-key-value-p (face key value) + "Return non-nil when FACE contains KEY with VALUE." + (cond + ((null face) nil) + ((and (listp face) (keywordp (car face))) + (equal (plist-get face key) value)) + ((listp face) + (cl-some (lambda (item) + (ebox-test--face-has-key-value-p item key value)) + face)) + (t nil))) + +(defun ebox-test--face-has-key-p (face key) + "Return non-nil when FACE contains KEY." + (cond + ((null face) nil) + ((and (listp face) (keywordp (car face))) + (plist-member face key)) + ((listp face) + (cl-some (lambda (item) + (ebox-test--face-has-key-p item key)) + face)) + (t nil))) + +(defun ebox-test--border-face-has-color-p (face role color) + "Return non-nil when FACE paints border ROLE with COLOR." + (pcase role + ('bt + (ebox-test--face-has-key-value-p face :overline color)) + ('bb + (ebox-test--face-has-key-value-p + face :underline (list :position t :color color))) + ((or 'bl 'br) + (ebox-test--face-has-key-value-p face :background color)))) + +(defun ebox-test--face-at-property (string property) + "Return first face in STRING at PROPERTY." + (let ((pos 0) + (len (length string)) + face) + (while (and (< pos len) (not face)) + (when (get-text-property pos property string) + (setq face (get-text-property pos 'face string))) + (setq pos (or (next-single-property-change pos property string) + (1+ pos)))) + face)) + +(defun ebox-test--first-content-face (string) + "Return first content face in STRING." + (ebox-test--face-at-property string 'ebox-content)) + +(defmacro ebox-test--count-full-rerenders (&rest body) + "Run BODY and return the number of full buffer rerenders it triggered." + (declare (indent 0)) + `(let ((full-rerenders 0) + (original-full-rerender (symbol-function 'ebox--full-rerender-buffer))) + (cl-letf (((symbol-function 'ebox--full-rerender-buffer) + (lambda (&rest args) + (cl-incf full-rerenders) + (apply original-full-rerender args)))) + ,@body) + full-rerenders)) + +(defconst ebox-test--region-update-paint-cases + '((:color "#123456" "#123456") + (:bgcolor "#FDE68A" "#FDE68A") + (:border-left-color "#111111" "#111111") + (:border-right-color "#222222" "#222222") + (:border-top-color "#333333" "#333333") + (:border-bottom-color "#444444" "#444444") + (:border-top-p t t) + (:border-bottom-p t t)) + "Paint-only box longhand update cases: (PROPERTY VALUE EXPECTED).") + +(defconst ebox-test--region-update-geometry-cases + '((:box-sizing content-box content-box owner-rerender) + (:width (120) 120 owner-rerender) + (:min-width (100) 100 owner-rerender) + (:max-width (140) 140 owner-rerender) + (:height 3 3 owner-rerender) + (:min-height 3 3 owner-rerender) + (:max-height 1 1 owner-rerender) + (:padding-left-pixel 9 9 span-patch) + (:padding-right-pixel 10 10 span-patch) + (:padding-top-height 1 1 span-patch) + (:padding-bottom-height 1 1 span-patch) + (:margin-left-pixel 7 7 owner-rerender) + (:margin-right-pixel 8 8 owner-rerender) + (:margin-top-height 1 1 owner-rerender) + (:margin-bottom-height 1 1 owner-rerender) + (:border-left-pixel 5 5 span-patch) + (:border-right-pixel 6 6 span-patch) + (:text-align center center span-patch) + (:vertical-align middle center span-patch) + (:overflow hidden hidden owner-rerender) + (:wrap-mode char char span-patch) + (:scroll-offset 1 1 span-patch) + (:visibility hidden hidden span-patch)) + "Geometry box longhand update cases: (PROPERTY VALUE EXPECTED STRATEGY).") + +(defun ebox-test--region-update-case-properties (cases) + "Return the update property names covered by CASES." + (mapcar #'car cases)) + +(ert-deftest ebox-root-background-covers-transparent-layout-wrappers () + "A root canvas background should remain under transparent child wrappers." + (ebox-test--reset-runtime-state) + (let* ((ebox-viewport-width 240) + (ebox-viewport-height 6) + (layout + (ebox-build + '(box :width (viewport) :height (viewport-height) + :bgcolor "#151A23" + (column + (box :width (viewport) :height 1 :bgcolor "#202938" + "surface") + (box :width (viewport) :height 1 "transparent") + (box :width (viewport) :height 1 :bgcolor "#29364A" + "surface-two"))))) + (lines (ebox-string-lines (ebox-render layout)))) + (should (= (length lines) 6)) + (dolist (line lines) + (should (ebox-test--face-has-key-value-p + (get-text-property 0 'face line) + :background "#151A23"))) + (should (ebox-test--face-has-key-value-p + (get-text-property 0 'face (nth 0 lines)) + :background "#202938")) + (should (ebox-test--face-has-key-value-p + (get-text-property 0 'face (nth 2 lines)) + :background "#29364A")))) + +(ert-deftest ebox-region-ids-assigns-stable-ids-before-render () + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "L" :width 40)) + (right (ebox-create :content "R" :width 50)) + (layout (ebox-concat left right)) + (ids-before (ebox-region-ids layout))) + (should (equal ids-before '(1 2))) + (should (equal (ebox-region-ids layout) ids-before)) + (should (equal (ebox-region-ids (ebox-render layout)) ids-before)))) + +(ert-deftest ebox-box-extent-template-excludes-interleaved-concat-boxes () + "Prepared extents should omit boxes split by a neighboring concat column." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "A\nB" :width 60)) + (right (ebox-create :content "R1\nR2\nR3" :width 80)) + (rendered (ebox-render (ebox-concat left right))) + (template (ebox--build-box-extent-template rendered))) + (should-not (gethash (plist-get left :region-id) template)) + (should-not (gethash (plist-get right :region-id) template)))) + +(ert-deftest ebox-live-mount-uses-runtime-copy-and-leaves-source-stable () + "First live mount should copy SOURCE while preserving stable source ids." + (ebox-test--reset-runtime-state) + (let* ((source (ebox-build + '(box :id "root" :content "before\nhidden" :width (120) + :color "#111111" :height 1 :overflow scroll))) + (buffer (generate-new-buffer " *ebox-live-copy-source*"))) + (unwind-protect + (progn + (should-not (plist-member source :region-id)) + (ebox-render-to-buffer buffer source) + (let* ((runtime-root (ebox--buffer-root-node buffer)) + (source-ids (ebox-region-ids source)) + (region-id (car source-ids)) + (runtime-box + (gethash region-id + (ebox--buffer-region-box-table buffer)))) + (should source-ids) + (should (equal (ebox-region-ids source) source-ids)) + (should-not (eq runtime-root source)) + (should (eq runtime-root runtime-box)) + (should (eq (gethash region-id ebox--region-box-table) + runtime-box)) + (let ((ebox--region-update-buffer-hint buffer)) + (ebox-region-update region-id + :content "after\nhidden" + :color "#222222" + :width '(140) + :scroll-offset 1)) + (should (equal (ebox-region-ids source) source-ids)) + (should (equal (ebox-get source :content) "before\nhidden")) + (should (equal (ebox-get source :color) "#111111")) + (should (equal (ebox-get source :width) 120)) + (should (equal (ebox-get source :scroll-offset) 0)) + (should (equal (ebox-get runtime-box :content) "after\nhidden")) + (should (equal (ebox-get runtime-box :color) "#222222")) + (should (equal (ebox-get runtime-box :width) 140)) + (should (equal (ebox-get runtime-box :scroll-offset) 1)) + (should (= (plist-get (ebox--scroll-get-state region-id) + :scroll-offset) + 1)) + (with-current-buffer buffer + (should (string-match-p "hidden" + (buffer-string)))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-live-mount-rejects-second-owner-before-target-cleanup () + "Second live mount of retained ids should fail without touching target state." + (ebox-test--reset-runtime-state) + (let* ((source (ebox-build + '(box :id "root" :content "owned\nhidden" :width (120) + :height 1 :overflow scroll))) + (_source-ids (ebox-region-ids source)) + (retained-copy (copy-tree source)) + (missing-target-name (generate-new-buffer-name " *ebox-live-missing*")) + owner-buffer target-buffer + owner-state owner-root owner-region-id owner-scroll-state + owner-region-box owner-extents owner-timer + target-state target-text) + (unwind-protect + (progn + (setq owner-buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-live-owner*") + source)) + (setq target-buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-live-target*") + (ebox-create :content "target stays" :width 120))) + (setq owner-state (ebox--buffer-render-state owner-buffer) + owner-root (plist-get owner-state :root-node) + owner-region-id (car (ebox-region-ids source)) + owner-scroll-state (ebox--scroll-get-state owner-region-id) + owner-region-box (gethash owner-region-id ebox--region-box-table) + owner-extents (gethash owner-region-id ebox--box-extents) + target-state (ebox--buffer-render-state target-buffer)) + (setq owner-timer (run-with-timer 3600 nil #'ignore)) + (puthash owner-region-id owner-timer + ebox--scroll-idle-prefetch-timers) + (with-current-buffer target-buffer + (setq target-text (buffer-string))) + (dolist (root (list source retained-copy)) + (should-error + (ebox-render-to-buffer target-buffer root) + :type 'user-error) + (should (eq (ebox--buffer-render-state owner-buffer) owner-state)) + (should (eq (plist-get owner-state :root-node) owner-root)) + (should (eq (ebox--scroll-get-state owner-region-id) + owner-scroll-state)) + (should (eq (gethash owner-region-id ebox--region-box-table) + owner-region-box)) + (should (eq (gethash owner-region-id ebox--box-extents) + owner-extents)) + (should (eq (gethash owner-region-id + ebox--scroll-idle-prefetch-timers) + owner-timer)) + (should (eq (ebox--buffer-render-state target-buffer) + target-state)) + (with-current-buffer target-buffer + (should (equal-including-properties (buffer-string) + target-text))))) + (should-error + (ebox-render-to-buffer missing-target-name retained-copy) + :type 'user-error) + (should-not (get-buffer missing-target-name)) + (dolist (buffer (list owner-buffer target-buffer)) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))) + (when (timerp owner-timer) + (cancel-timer owner-timer)) + (when-let ((buffer (get-buffer missing-target-name))) + (kill-buffer buffer))))) + +(ert-deftest ebox-live-mount-releases-old-source-after-target-replacement () + "Replacing a target root should release the old source ids for reuse." + (ebox-test--reset-runtime-state) + (let* ((old-source (ebox-create :content "old" :width 120)) + (new-source (ebox-create :content "new" :width 120)) + primary-buffer secondary-buffer) + (unwind-protect + (progn + (setq primary-buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-live-primary*") + old-source)) + (ebox-render-to-buffer primary-buffer new-source) + (setq secondary-buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-live-secondary*") + old-source)) + (kill-buffer secondary-buffer) + (setq secondary-buffer nil) + (setq secondary-buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-live-secondary*") + old-source)) + (should (eq (ebox--region-buffer (car (ebox-region-ids old-source))) + secondary-buffer))) + (dolist (buffer (list primary-buffer secondary-buffer)) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer)))))) + +(ert-deftest ebox-region-update-hint-uses-buffer-owned-runtime-box () + "Hinted direct updates should use the buffer-owned box table over globals." + (ebox-test--reset-runtime-state) + (let* ((source (ebox-build + '(box :id "target" :content "before" :width (120) + :color "#111111"))) + (buffer (generate-new-buffer " *ebox-hinted-runtime-box*"))) + (unwind-protect + (progn + (ebox-render-to-buffer buffer source) + (let* ((region-id (ebox-test--selector-region-id buffer "#target")) + (runtime-box (gethash region-id ebox--region-box-table)) + (poison (ebox-create :content "poison" :width 120 + :color "#ff0000"))) + (puthash region-id poison ebox--region-box-table) + (let ((ebox--region-update-buffer-hint buffer)) + (ebox-region-update region-id :content "after")) + (should (equal (ebox-get runtime-box :content) "after")) + (should (equal (ebox-get poison :content) "poison")) + (should (eq (gethash region-id ebox--region-box-table) + runtime-box)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-node-ids-cover-layout-and-box-nodes () + "Every runtime node should have a stable internal node id." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-stack + (ebox-create :content "Top" :width 60) + (ebox-concat + (ebox-create :content "Left" :width 40) + (ebox-create :content "Right" :width 40)))) + (ids-before (ebox--runtime-node-ids layout))) + (should (= (length ids-before) 5)) + (should (= (length (delete-dups (copy-sequence ids-before))) 5)) + (ebox-render layout) + (should (equal (ebox--runtime-node-ids layout) ids-before)))) + +(ert-deftest ebox-wrapper-child-nodes-are-in-runtime-index () + "A wrapper box's child layout should remain visible in runtime state." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-build + '(column :padding 1 + (box :content "One") + (box :content "Two")))) + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + node)) + (let ((ids (ebox--runtime-node-ids node))) + (should (> (length ids) 1)) + (dolist (node-id ids) + (should (ebox--buffer-runtime-node buffer node-id))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-region-render-owner-index-maps-boxes-and-flex-wrapper () + "Region lookup should map each box to its smallest renderable owner." + (ebox-test--reset-runtime-state) + (let* ((plain (ebox-create :content "Plain" :width '(80))) + (flex-child (ebox-create :content "Flex child" :width '(80))) + (flex (ebox-flex :width '(120) :padding 1 flex-child)) + (flex-wrapper (plist-get flex :box)) + (layout (ebox-column plain flex))) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (table (ebox--buffer-region-node-table buffer)) + (plain-region-id (ebox--ensure-region-id plain)) + (wrapper-region-id (ebox--ensure-region-id flex-wrapper)) + (child-region-id (ebox--ensure-region-id flex-child)) + (plain-node-id (ebox--ensure-node-id plain)) + (flex-node-id (ebox--ensure-node-id flex)) + (wrapper-node-id (ebox--ensure-node-id flex-wrapper)) + (child-node-id (ebox--ensure-node-id flex-child)) + (runtime-plain (ebox--buffer-runtime-node buffer plain-node-id)) + (runtime-flex (ebox--buffer-runtime-node buffer flex-node-id)) + (runtime-child (ebox--buffer-runtime-node buffer child-node-id))) + (should (hash-table-p table)) + (should (equal (gethash plain-region-id table) plain-node-id)) + (should (equal (gethash wrapper-region-id table) flex-node-id)) + (should-not (equal (gethash wrapper-region-id table) + wrapper-node-id)) + (should (equal (gethash child-region-id table) child-node-id)) + (should (eq (ebox--buffer-region-render-owner-node + buffer plain-region-id) + runtime-plain)) + (should (eq (ebox--buffer-region-render-owner-node + buffer wrapper-region-id) + runtime-flex)) + (should (eq (ebox--buffer-region-render-owner-node + buffer child-region-id) + runtime-child)))))) + +(ert-deftest ebox-region-render-owner-index-keeps-earlier-duplicate-box () + "A later flex wrapper should not replace an earlier duplicate region owner." + (ebox-test--reset-runtime-state) + (let* ((region-id 9001) + (plain (ebox-create :content "First" :width '(80))) + (flex (ebox-flex :width '(120) + (ebox-create :content "Later" :width '(80)))) + (wrapper (plist-get flex :box)) + (layout (ebox-column plain flex))) + (ebox-put plain :region-id region-id) + (ebox-put wrapper :region-id region-id) + (let* ((index (ebox--runtime-index layout)) + (table (plist-get index :region-node-table)) + (expected (car (ebox--node-path-to-region layout region-id)))) + (should (eq expected plain)) + (should (equal (gethash region-id table) + (ebox--ensure-node-id expected)))))) + +(ert-deftest ebox-region-render-owner-index-keeps-outer-flex-duplicate () + "Nested duplicate flex wrappers should retain recursive first-match lookup." + (ebox-test--reset-runtime-state) + (let* ((region-id 9002) + (inner (ebox-flex :width '(100) :padding 1 + (ebox-create :content "Inner" :width '(80)))) + (outer (ebox-flex :width '(140) :padding 1 inner)) + (inner-wrapper (plist-get inner :box)) + (outer-wrapper (plist-get outer :box))) + (ebox-put inner-wrapper :region-id region-id) + (ebox-put outer-wrapper :region-id region-id) + (let* ((index (ebox--runtime-index outer)) + (table (plist-get index :region-node-table)) + (expected (car (ebox--node-path-to-region outer region-id)))) + (should (eq expected outer)) + (should (equal (gethash region-id table) + (ebox--ensure-node-id expected)))))) + +(ert-deftest ebox-runtime-index-region-box-table-matches-complete-render () + "Runtime indexing should collect the box table with render overwrite order." + (ebox-test--reset-runtime-state) + (let* ((shared-region-id 9003) + (inner (ebox-create :content "Inner" :width '(60))) + (outer (ebox-create :content "" :width '(90))) + (flex-child (ebox-create :content "Flex child" :width '(50))) + (flex (ebox-flex :width '(120) :padding 1 flex-child)) + (layout (ebox-column outer flex)) + (render-table (make-hash-table :test 'equal))) + (plist-put outer :ebox-content-node inner) + ;; Complete rendering records parents after their child content. A + ;; duplicated legacy id therefore resolves to OUTER in the box table. + (ebox-put inner :region-id shared-region-id) + (ebox-put outer :region-id shared-region-id) + (let* ((index (ebox--runtime-index layout t)) + (index-table (plist-get index :region-box-table)) + (ebox--region-box-table render-table) + (ebox--scroll-global-state (make-hash-table :test 'equal))) + (ebox-render layout) + (should (= (hash-table-count index-table) + (hash-table-count render-table))) + (maphash + (lambda (region-id box) + (should (eq (gethash region-id index-table) box))) + render-table) + (should (eq (gethash shared-region-id index-table) outer)) + (should + (eq (gethash (ebox--ensure-region-id (plist-get flex :box)) + index-table) + (plist-get flex :box)))))) + +(ert-deftest ebox-declarative-candidate-reuses-indexed-region-box-table () + "Candidate preparation should not rewalk the tree to collect source boxes." + (ebox-test--reset-runtime-state) + (let ((root (ebox-column + (ebox-create :key 'first :content "First" :width '(80)) + (ebox-create :key 'second :content "Second" :width '(80)))) + (next-root + (ebox-column + (ebox-create :key 'first :content "Changed" :width '(80)) + (ebox-create :key 'second :content "Second" :width '(80))))) + (ebox-test--with-rendered-buffer root + (let* ((buffer (current-buffer)) + (old-state (ebox--buffer-render-state buffer)) + (recache-calls 0) + prepared + candidate-state) + (cl-letf (((symbol-function 'ebox--flex-recache-source-boxes) + (lambda (&rest _) + (cl-incf recache-calls)))) + (setq prepared + (ebox-incremental--prepare-declarative-root + buffer old-state next-root)) + (setq candidate-state + (ebox-incremental--candidate-state + old-state + (plist-get prepared :root) + (plist-get prepared :index) + prepared))) + (should (= recache-calls 0)) + (should + (eq (plist-get candidate-state :region-box-table) + (plist-get (plist-get prepared :index) :region-box-table))) + (should (= (hash-table-count + (plist-get candidate-state :region-box-table)) + 2)))))) + +(ert-deftest ebox-region-render-owner-index-falls-back-for-legacy-state () + "A runtime state without the new table should retain recursive lookup." + (ebox-test--reset-runtime-state) + (let* ((child (ebox-create :content "Flex child" :width '(80))) + (flex (ebox-flex :width '(120) :padding 1 child)) + (wrapper (plist-get flex :box))) + (ebox-test--with-rendered-buffer flex + (let* ((buffer (current-buffer)) + (state (ebox--buffer-render-state buffer)) + (table (ebox--buffer-region-node-table buffer)) + (region-id (ebox--ensure-region-id wrapper)) + (expected-node-id + (ebox--buffer-region-render-owner-node-id buffer region-id)) + (expected-node + (ebox--buffer-region-render-owner-node buffer region-id)) + (original-path + (symbol-function 'ebox--node-path-to-region)) + (recursive-calls 0)) + (unwind-protect + (progn + (plist-put state :region-node-table nil) + (cl-letf (((symbol-function 'ebox--node-path-to-region) + (lambda (&rest args) + (cl-incf recursive-calls) + (apply original-path args)))) + (should (equal + (ebox--buffer-region-render-owner-node-id + buffer region-id) + expected-node-id)) + (should (eq (ebox--buffer-region-render-owner-node + buffer region-id) + expected-node))) + (should (> recursive-calls 0))) + (plist-put state :region-node-table table)))))) + +(ert-deftest ebox-runtime-node-id-survives-viewport-rerender () + "Viewport-only rerender should not assign new runtime node ids." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-flex + :flex-wrap 'wrap + (ebox-create :content "A" :width '(100)) + (ebox-create :content "B" :width '(100)))) + (ids-before nil) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (setq ids-before (ebox--runtime-node-ids layout)) + (let ((ebox-viewport-width 160)) + (ebox--rerender-buffer-preserving-runtime buffer)) + (should (equal (ebox--runtime-node-ids layout) ids-before))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-reconcile-preserves-keyed-child-id () + "Keyed children should keep runtime ids across structural rebuilds." + (ebox-test--reset-runtime-state) + (let* ((old (ebox--runtime-tree + (ebox-column + (ebox-create :key 'a :content "A" :width 40) + (ebox-create :key 'b :content "B" :width 40)))) + (old-b-id (ebox--runtime-node-id-by-key old 'b)) + (new (ebox--reconcile-runtime-tree + old + (ebox-column + (ebox-create :key 'x :content "X" :width 40) + (ebox-create :key 'b :content "B2" :width 40))))) + (should (equal (ebox--runtime-node-id-by-key new 'b) old-b-id)))) + +(ert-deftest ebox-tree-helpers-see-wrapper-child-nodes () + "Tree helpers should see wrapper children without rendering static content." + (let* ((node (ebox-build + '(column :padding 1 + (box :content "Child" :width (120))))) + (children (ebox-tree-children node))) + (should children) + (should (plist-get (car children) :node-id)))) + +(ert-deftest ebox-tree-shallow-copy-replaces-box-content-node () + "A box content-node replacement should leave other values shared." + (let* ((old-content (list :ebox-type 'box :content "old")) + (equal-content (copy-tree old-content)) + (new-content (list :ebox-type 'box :content "new")) + (wrong-content (list :ebox-type 'box :content "wrong")) + (metadata (list :owner 'host)) + (node (list :ebox-type 'box + :ebox-content-node old-content + :metadata metadata)) + (copy + (ebox-tree-copy-with-direct-child-replacements + node (list (cons equal-content wrong-content) + (cons old-content new-content))))) + (should-not (eq copy node)) + (should (eq (plist-get copy :ebox-content-node) new-content)) + (should (eq (plist-get copy :metadata) metadata)) + (should (eq (plist-get node :ebox-content-node) old-content)))) + +(ert-deftest ebox-tree-shallow-copy-replaces-concat-children-and-aliases () + "A concat copy should update flat children and both legacy aliases." + (let* ((left (list :ebox-type 'box :content "left")) + (right (list :ebox-type 'box :content "right")) + (new-left (list :ebox-type 'box :content "new-left")) + (children (list left right)) + (node (list :ebox-type 'concat + :children children + :left left + :right right)) + (copy + (ebox-tree-copy-with-direct-child-replacements + node (list (cons left new-left)))) + (copy-children (plist-get copy :children))) + (should-not (eq copy node)) + (should-not (eq copy-children children)) + (should (eq (car copy-children) new-left)) + (should (eq (cadr copy-children) right)) + (should (eq (plist-get copy :left) new-left)) + (should (eq (plist-get copy :right) right)))) + +(ert-deftest ebox-tree-shallow-copy-replaces-stack-children-and-aliases () + "A stack copy should update flat children and both legacy aliases." + (let* ((top (list :ebox-type 'box :content "top")) + (bottom (list :ebox-type 'box :content "bottom")) + (new-bottom (list :ebox-type 'box :content "new-bottom")) + (children (list top bottom)) + (node (list :ebox-type 'stack + :children children + :top top + :bottom bottom)) + (copy + (ebox-tree-copy-with-direct-child-replacements + node (list (cons bottom new-bottom)))) + (copy-children (plist-get copy :children))) + (should-not (eq copy node)) + (should-not (eq copy-children children)) + (should (eq (car copy-children) top)) + (should (eq (cadr copy-children) new-bottom)) + (should (eq (plist-get copy :top) top)) + (should (eq (plist-get copy :bottom) new-bottom)))) + +(ert-deftest ebox-tree-shallow-copy-replaces-flex-box-and-children-once () + "One flex copy should apply multiple replacements and share one sibling." + (let* ((box (list :ebox-type 'box :content "wrapper")) + (first (list :ebox-type 'box :content "first")) + (untouched (list :ebox-type 'box :content "untouched")) + (last (list :ebox-type 'box :content "last")) + (new-box (list :ebox-type 'box :content "new-wrapper")) + (new-first (list :ebox-type 'box :content "new-first")) + (new-last (list :ebox-type 'box :content "new-last")) + (children (list first untouched last)) + (node (list :ebox-type 'flex :box box :children children)) + (copy + (ebox-tree-copy-with-direct-child-replacements + node (list (cons box new-box) + (cons first new-first) + (cons last new-last)))) + (copy-children (plist-get copy :children))) + (should-not (eq copy node)) + (should (eq (plist-get copy :box) new-box)) + (should (eq (nth 0 copy-children) new-first)) + (should (eq (nth 1 copy-children) untouched)) + (should (eq (nth 2 copy-children) new-last)) + (should (eq (plist-get node :box) box)) + (should (eq (nth 0 children) first)) + (should (eq (nth 1 children) untouched)) + (should (eq (nth 2 children) last)))) + +(ert-deftest ebox-tree-shallow-copy-replaces-flex-item-node () + "A flex-item copy should replace its wrapped node by identity." + (let* ((old-child (list :ebox-type 'box :content "old")) + (new-child (list :ebox-type 'box :content "new")) + (props (list :flex-grow 1)) + (node (list :ebox-type 'flex-item :node old-child :props props)) + (copy + (ebox-tree-copy-with-direct-child-replacements + node (list (cons old-child new-child))))) + (should-not (eq copy node)) + (should (eq (plist-get copy :node) new-child)) + (should (eq (plist-get copy :props) props)))) + +(ert-deftest ebox-tree-shallow-copy-without-replacements-shares-values () + "A zero-replacement copy should share every nested value." + (let* ((first (list :ebox-type 'box :content "first")) + (second (list :ebox-type 'box :content "second")) + (children (list first second)) + (node (list :ebox-type 'concat :children children)) + (copy (ebox-tree-copy-with-direct-child-replacements node nil))) + (should-not (eq copy node)) + (should (eq (plist-get copy :children) children)) + (should (eq (car (plist-get copy :children)) first)) + (should (eq (cadr (plist-get copy :children)) second)))) + +(ert-deftest ebox-fragment-signature-excludes-backend-spans () + "Fragment signatures should describe layout facts, not backend markers." + (let ((fragment (ebox-fragment-create + :node-id "n1" + :region-ids '("r1") + :size '(120 . 24) + :line-signature '(("Child")) + :buffer-span '(1 . 8)))) + (should (equal (ebox-fragment-layout-signature fragment) + '(:node-id "n1" :region-ids ("r1") :size (120 . 24) + :line-signature (("Child"))))))) + +(ert-deftest ebox-cache-registry-records-scope-deps-and-lifetime () + "Cache specs should make ownership, scope, dependencies, and lifetime explicit." + (unwind-protect + (let ((spec (ebox-cache-register + :name 'computed-style + :owner 'ebox-style + :scope 'node + :key-deps '(specified-style class-version theme-version) + :invalidates '(style class theme) + :lifetime 'buffer-runtime + :value-owner 'ebox-style))) + (should (eq (ebox-cache-spec-name spec) 'computed-style)) + (should (eq (ebox-cache-spec-owner spec) 'ebox-style)) + (should (eq (ebox-cache-spec-scope spec) 'node)) + (should (equal (ebox-cache-spec-invalidates spec) + '(style class theme))) + (should (eq (ebox-cache-spec-lifetime spec) 'buffer-runtime))) + (ebox-cache-reset-default-registry))) + +(ert-deftest ebox-cache-invalidates-by-dirty-kind () + "Dirty facts should map to the cache specs they invalidate." + (unwind-protect + (progn + (ebox-cache-clear-registry) + (ebox-cache-register + :name 'render-body + :owner 'ebox-buffer-backend + :scope 'buffer + :key-deps '(fragment-signature paint-signature backend-display-signature) + :invalidates '(paint geometry structure backend-display) + :lifetime 'buffer-runtime + :value-owner 'ebox-buffer-backend) + (ebox-cache-register + :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) + (should (equal (sort (ebox-cache-invalidated-spec-names '(paint)) + #'string<) + '(render-body))) + (should (equal (sort (ebox-cache-invalidated-spec-names '(geometry)) + #'string<) + '(layout-fragment render-body)))) + (ebox-cache-reset-default-registry))) + +(ert-deftest ebox-cache-events-appear-in-update-report () + "Incremental update reports should expose cache invalidation facts." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-create :content "Cache" :border-color "#111111")) + (id (car (ebox-region-ids node)))) + (ebox-test--with-rendered-buffer node + (ebox-region-update id :border-color "#222222") + (let ((report (ebox-test--buffer-update-report))) + (should (plist-member report :cache-invalidated)) + (should (plist-member report :cache-hit-count)) + (should (plist-member report :cache-miss-count)))))) + +(ert-deftest ebox-render-cache-retains-distinct-viewport-widths () + "Returning to a warm width should not evict its prior rendered value." + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-cache-report-buffer nil) + (width-a '(display-a 720 42 body)) + (width-b '(display-a 900 42 body))) + (ebox--render-cache-store 'responsive width-a 'rendered-a) + (ebox--render-cache-store 'responsive width-b 'rendered-b) + (should (eq (ebox--render-cache-lookup 'responsive width-a) + 'rendered-a)) + (should (eq (ebox--render-cache-lookup 'responsive width-b) + 'rendered-b)))) + +(ert-deftest ebox-render-cache-retains-distinct-box-width-signatures () + "Ordinary box-width variants should coexist under one cache key." + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-cache-report-buffer nil) + (width-a '(display-a nil 42 (:width 720))) + (width-b '(display-a nil 42 (:width 900)))) + (ebox--render-cache-store 'responsive width-a 'rendered-a) + (ebox--render-cache-store 'responsive width-b 'rendered-b) + (should (eq (ebox--render-cache-lookup 'responsive width-a) + 'rendered-a)) + (should (eq (ebox--render-cache-lookup 'responsive width-b) + 'rendered-b)))) + +(ert-deftest ebox-render-cache-distinguishes-content-text-properties () + "Equal characters with different properties must not share rendered output." + (let* ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-cache-report-buffer nil) + (normal + (list 'display-a nil 42 + (list :content + (propertize "Same" 'face 'default)))) + (selected + (list 'display-a nil 42 + (list :content + (propertize "Same" 'face 'highlight))))) + (ebox--render-cache-store 'cell normal 'rendered-normal) + (should-not (ebox--render-cache-lookup 'cell selected)) + (ebox--render-cache-store 'cell selected 'rendered-selected) + (should (eq (ebox--render-cache-lookup 'cell selected) + 'rendered-selected)))) + +(ert-deftest ebox-render-cache-evicts-oldest-entry-at-capacity () + "A full cache should retain newer width variants without unbounded growth." + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-render-cache-max-entries 2) + (ebox-cache-report-buffer nil)) + (ebox--render-cache-store 'responsive 'width-a 'rendered-a) + (ebox--render-cache-store 'responsive 'width-b 'rendered-b) + (ebox--render-cache-store 'responsive 'width-c 'rendered-c) + (should (= (hash-table-count ebox--render-cache-table) 2)) + (should-not (ebox--render-cache-lookup 'responsive 'width-a)) + (should (eq (ebox--render-cache-lookup 'responsive 'width-b) + 'rendered-b)) + (should (eq (ebox--render-cache-lookup 'responsive 'width-c) + 'rendered-c)))) + +(ert-deftest ebox-render-cache-evicts-oldest-entry-at-byte-budget () + "Large rendered values should be bounded even below the entry limit." + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-render-cache-max-entries 16) + (ebox-render-cache-max-bytes 400) + (ebox-cache-report-buffer nil)) + (ebox--render-cache-store 'responsive 'width-a "aa") + (ebox--render-cache-store 'responsive 'width-b "bb") + (should-not (ebox--render-cache-lookup 'responsive 'width-a)) + (should (equal (ebox--render-cache-lookup 'responsive 'width-b) "bb")) + (should (= (hash-table-count ebox--render-cache-table) 1)))) + +(ert-deftest ebox-render-cache-does-not-retain-one-oversized-value () + "One value above the byte budget should be returned but not retained." + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-render-cache-max-entries 16) + (ebox-render-cache-max-bytes 128) + (ebox-cache-report-buffer nil)) + (should (equal (ebox--render-cache-store + 'responsive 'width-a "oversized") + "oversized")) + (should (= (hash-table-count ebox--render-cache-table) 0)) + (should-not (ebox--render-cache-lookup 'responsive 'width-a)))) + +(ert-deftest ebox-render-cache-oversized-store-preserves-healthy-entries () + "One oversized store must not purge unrelated retained entries." + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-render-cache-max-entries 16) + (ebox-render-cache-max-bytes 1024) + (ebox-cache-report-buffer nil)) + (ebox--render-cache-store 'responsive 'width-a "aa") + (ebox--render-cache-store 'responsive 'width-b "bb") + (should (equal (ebox--render-cache-store + 'responsive 'width-oversized (make-string 64 ?x)) + (make-string 64 ?x))) + (should-not (ebox--render-cache-lookup 'responsive 'width-oversized)) + (should (equal (ebox--render-cache-lookup 'responsive 'width-a) "aa")) + (should (equal (ebox--render-cache-lookup 'responsive 'width-b) "bb")) + (should (= (hash-table-count ebox--render-cache-table) 2)))) + +(ert-deftest ebox-render-cache-oversized-update-releases-only-that-entry () + "Growing one entry above the budget must release only that entry." + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-render-cache-max-entries 16) + (ebox-render-cache-max-bytes 1024) + (ebox-cache-report-buffer nil)) + (ebox--render-cache-store 'responsive 'width-a "aa") + (ebox--render-cache-store 'responsive 'width-b "bb") + (ebox--render-cache-store 'responsive 'width-b (make-string 64 ?x)) + (should-not (ebox--render-cache-lookup 'responsive 'width-b)) + (should (equal (ebox--render-cache-lookup 'responsive 'width-a) "aa")) + (should (= (hash-table-count ebox--render-cache-table) 1)))) + +(ert-deftest ebox-render-cache-retains-distinct-sxhash-collisions () + "Distinct signatures with one bounded hash must retain separate entries." + (let* ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-render-cache-max-entries 16) + (ebox-cache-report-buffer nil) + (signature-a '(same (same (same (same "a"))))) + (signature-b '(same (same (same (same "b")))))) + (should-not (equal signature-a signature-b)) + (should (= (sxhash-equal signature-a) (sxhash-equal signature-b))) + (ebox--render-cache-store 'page signature-a 'rendered-a) + (ebox--render-cache-store 'page signature-b 'rendered-b) + (should (= (hash-table-count ebox--render-cache-table) 2)) + (should (eq (ebox--render-cache-lookup 'page signature-a) 'rendered-a)) + (should (eq (ebox--render-cache-lookup 'page signature-b) 'rendered-b)))) + +(ert-deftest ebox-render-cache-byte-budget-includes-scroll-side-effects () + "Scroll-state metadata must not bypass the retained-byte budget." + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-render-cache-max-entries 16) + (ebox-render-cache-max-bytes 1024) + (ebox-cache-report-buffer nil) + (side-effects + (list :scroll-actions + (list + (list 'set 'scroll + (list :content-lines + (list (make-string 4096 ?x))))) + :stateful-p t + :restorable-p t))) + (should (equal (ebox--render-cache-store + 'responsive 'width-a "x" side-effects) + "x")) + (should (= (hash-table-count ebox--render-cache-table) 0)) + (should-not (ebox--render-cache-lookup 'responsive 'width-a)))) + +(ert-deftest ebox-render-cache-reuses-portable-scroll-state-in-candidate-tree () + "Portable scroll templates rebind complete state to the candidate box." + (ebox-test--reset-runtime-state) + (let* ((buffer (generate-new-buffer " *ebox-portable-scroll-cache*")) + (old-box (ebox-create :content "old" :scroll-offset 0)) + (node-id (ebox--ensure-node-id old-box)) + (candidate-box (ebox-create :content "candidate" :scroll-offset 0)) + (node-table (make-hash-table :test 'eql)) + (template + (list :box old-box + :content-lines '("alpha" "beta") + :rendered-content-lines '("alpha") + :content-height 1 + :scroll-offset 0)) + (actions (list (list 'set 'scroll template))) + (entry (cons 'signature "rendered")) + (metadata + (list :scroll-actions actions + :stateful-p t + :portable-p t + :restorable-p t)) + (ebox--render-cache-scroll-state-restorable-p nil)) + (plist-put candidate-box :node-id node-id) + (puthash node-id candidate-box node-table) + (puthash buffer (list :node-table node-table) + ebox--buffer-render-state-table) + (puthash entry metadata ebox--render-cache-entry-side-effects-table) + (unwind-protect + (with-current-buffer buffer + (should (ebox--scroll-cache-actions-portable-p actions)) + (should (ebox--render-cache-entry-reusable-p entry)) + (ebox--render-cache-replay-entry-side-effects entry) + (let ((state (ebox--scroll-get-state 'scroll))) + (should (eq (plist-get state :box) candidate-box)) + (should (equal (plist-get state :content-lines) + '("alpha" "beta"))) + (should (equal (plist-get state :rendered-content-lines) + '("alpha"))))) + (ebox--scroll-clear-state 'scroll) + (remhash entry ebox--render-cache-entry-side-effects-table) + (remhash buffer ebox--buffer-render-state-table) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-render-cache-rejects-nonportable-candidate-scroll-state () + "Candidate publication must not replay closures owned by the old tree." + (let* ((prefix-renderer (lambda (_count) '("deferred"))) + (materializer (lambda () '("materialized"))) + (actions + (list + (list 'set 'scroll + (list :box (ebox-create :content "old" :scroll-offset 0) + :content-lines '("alpha") + :render-content-prefix prefix-renderer + :materialize-content-lines materializer)))) + (ebox--render-cache-scroll-state-restorable-p nil) + (entry (cons 'signature "rendered")) + (metadata + (list :scroll-actions actions + :stateful-p t + :portable-p nil + :restorable-p t))) + (puthash entry metadata ebox--render-cache-entry-side-effects-table) + (unwind-protect + (progn + (should-not (ebox--scroll-cache-actions-portable-p actions)) + (should-not (ebox--render-cache-entry-reusable-p entry))) + (remhash entry ebox--render-cache-entry-side-effects-table)))) + +(ert-deftest ebox-render-cache-ring-recovers-after-clrhash () + "An exceptional cache clear should not leave stale eviction metadata." + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-render-cache-max-entries 2) + (ebox-cache-report-buffer nil)) + (ebox--render-cache-store 'responsive 'width-a 'rendered-a) + (ebox--render-cache-store 'responsive 'width-b 'rendered-b) + (clrhash ebox--render-cache-table) + (ebox--render-cache-store 'responsive 'width-c 'rendered-c) + (ebox--render-cache-store 'responsive 'width-d 'rendered-d) + (ebox--render-cache-store 'responsive 'width-e 'rendered-e) + (should (= (hash-table-count ebox--render-cache-table) 2)) + (should-not (ebox--render-cache-lookup 'responsive 'width-c)) + (should (eq (ebox--render-cache-lookup 'responsive 'width-d) + 'rendered-d)) + (should (eq (ebox--render-cache-lookup 'responsive 'width-e) + 'rendered-e)))) + +(ert-deftest ebox-render-cache-ring-retains-newest-entries-when-limit-shrinks () + "Lowering the cache limit should retain the newest insertion-order entries." + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox-render-cache-max-entries 3) + (ebox-cache-report-buffer nil)) + (ebox--render-cache-store 'responsive 'width-a 'rendered-a) + (ebox--render-cache-store 'responsive 'width-b 'rendered-b) + (ebox--render-cache-store 'responsive 'width-c 'rendered-c) + (setq ebox-render-cache-max-entries 2) + (ebox--render-cache-store 'responsive 'width-d 'rendered-d) + (should (= (hash-table-count ebox--render-cache-table) 2)) + (should-not (ebox--render-cache-lookup 'responsive 'width-a)) + (should-not (ebox--render-cache-lookup 'responsive 'width-b)) + (should (eq (ebox--render-cache-lookup 'responsive 'width-c) + 'rendered-c)) + (should (eq (ebox--render-cache-lookup 'responsive 'width-d) + 'rendered-d)))) + +(ert-deftest ebox-buffer-render-cache-stays-bounded-across-unique-widths () + "Continuous resize should not retain an unbounded set of rendered widths." + (ebox-test--reset-runtime-state) + (let* ((ebox-render-cache-max-entries 32) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil) + (layout + (ebox-build + `(box :id "root" :width (viewport) + (flex :width (viewport) :gap 1 + ,@(cl-loop for index below 8 + collect + `(box :id ,(format "card-%d" index) + :content ,(format "card %d" index) + :width 72 :height 1)))))) + buffer) + (unwind-protect + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-cache-bound*") layout)) + (cl-loop for width from 241 to 280 + do (ebox-rerender-buffer-with-context buffer width)) + (let ((cache (ebox--buffer-render-cache buffer))) + (should (<= (hash-table-count cache) + ebox-render-cache-max-entries))) + (with-current-buffer buffer + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-root-owner-rerender-reuses-a-returning-width-output () + "Returning to a cached root width should not rebuild the rendered owner." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) + (box :id "scroll" :width (viewport) :height 2 + :overflow scroll :border t + :content "cached root owner\nline two\nline three")))) + buffer + root-id + (render-box-count 0) + (role-refresh-count 0) + (scroll-refresh-count 0) + (original-role-refresh + (symbol-function 'ebox-buffer-refresh-region-role-spans)) + (original-scroll-refresh + (symbol-function 'ebox--refresh-buffer-scroll-content-markers)) + (original-render-box (symbol-function 'ebox--render-box))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-root-cache*") layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (with-current-buffer buffer + (ebox-region-update root-id :width '(260)) + (ebox-region-update root-id :width '(280))) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (&rest args) + (cl-incf render-box-count) + (apply original-render-box args))) + ((symbol-function 'ebox-buffer-refresh-region-role-spans) + (lambda (&rest args) + (cl-incf role-refresh-count) + (apply original-role-refresh args))) + ((symbol-function + 'ebox--refresh-buffer-scroll-content-markers) + (lambda (&rest args) + (cl-incf scroll-refresh-count) + (apply original-scroll-refresh args)))) + (with-current-buffer buffer + (ebox-region-update root-id :width '(260)))) + (should (= render-box-count 0)) + (should (= role-refresh-count 0)) + (should (= scroll-refresh-count 0)) + (with-current-buffer buffer + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-root-cache-keeps-one-manual-resize-round-trip () + "A maintained resize sweep should keep its exact widths for the return leg." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) + (flex :width (viewport) :gap 1 + (box :content "alpha" :width 72 :height 1) + (box :content "beta" :width 72 :height 1))))) + (widths '(488 552 616 680 728 664 600 536 472 424)) + buffer + (render-box-count 0) + (original-render-box (symbol-function 'ebox--render-box))) + (unwind-protect + (let ((ebox-viewport-width 440) + (ebox-viewport-height 8) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-resize-round-trip*") + layout)) + (dolist (width widths) + (ebox-rerender-buffer-with-context buffer width 8)) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (&rest args) + (cl-incf render-box-count) + (apply original-render-box args)))) + (ebox-rerender-buffer-with-context buffer (car widths) 8)) + (should (= render-box-count 0)) + (with-current-buffer buffer + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-returning-width-cache-restores-lazy-scroll-context () + "A cached subtree must restore lazy scroll state for its render context." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(column :id "viewport" :width (viewport) + (box :id "scroll" :width (viewport) :height 4 + :overflow scroll + (column :width (viewport) + ,@(cl-loop for index below 32 + collect + `(box :content ,(format "cached row %02d" index) + :width (viewport) :height 1))))))) + buffer + scroll-id) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 6) + ;; Evict the first complete root so the return leg renders the + ;; root while retaining its generic cached scroll subtree. + (ebox-render-root-cache-max-entries 1) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-scroll-lazy-idle-prefetch-delay 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-scroll-cache-context*") + layout)) + (setq scroll-id + (ebox-test--selector-region-id buffer "#scroll")) + (ebox-rerender-buffer-with-context buffer 320 6) + (ebox-rerender-buffer-with-context buffer 240 6) + (let ((state (ebox--scroll-get-state scroll-id))) + (should state) + (should (= (ebox--string-pixel-width + (car (plist-get state :rendered-content-lines))) + 240)) + (dotimes (_ 16) + (setq state + (ebox--scroll-state-ensure-prefix-lines + scroll-id state + (1+ (length (plist-get state :content-lines))) + t)))) + (with-current-buffer buffer + (dotimes (_ 6) + (should (= (ebox--scroll-region-by scroll-id 1) 1))) + (let ((scrolled (buffer-string))) + (ebox-buffer-apply-root-rerender buffer) + (should (equal-including-properties + scrolled (buffer-string)))))) + (when scroll-id + (ebox--scroll-cancel-idle-prefetch scroll-id)) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-reflow-cache-prewarm-caches-the-complete-root-output () + "A predicted viewport should publish a root render reusable by its reflow." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) + (box :content "predicted root owner" :width (viewport) :height 1)))) + buffer + (render-box-count 0) + (original-render-box (symbol-function 'ebox--render-box))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-root-prewarm*") layout)) + (ebox--cancel-buffer-runtime-prewarm buffer) + (let* ((state (ebox--buffer-render-state buffer)) + (revision (plist-get state :runtime-revision))) + (should + (ebox--prewarm-buffer-render-cache-at-context + buffer state revision 240 260 4))) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (&rest args) + (cl-incf render-box-count) + (apply original-render-box args)))) + (ebox-rerender-buffer-with-context buffer 260 4)) + (should (= render-box-count 0)) + (with-current-buffer buffer + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-cold-ordinary-root-reflow-skips-complete-templates () + "An ordinary visible cache miss should leave complete scans to prewarming." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (240) + (box :content "cold root metadata" :height 1)))) + buffer + (template-build-count 0) + (original-template-builder + (symbol-function 'ebox-buffer--build-region-role-span-template))) + (unwind-protect + (let ((ebox-viewport-width 900) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-cold-root-metadata*") + layout)) + (let ((root-id (ebox-test--selector-region-id buffer "#root"))) + (cl-letf (((symbol-function + 'ebox-buffer--build-region-role-span-template) + (lambda (&rest arguments) + (cl-incf template-build-count) + (apply original-template-builder arguments)))) + (let ((ebox--region-update-buffer-hint buffer)) + (with-current-buffer buffer + (ebox-region-update root-id :width '(260)))) + (should (= template-build-count 0)) + (with-current-buffer buffer + (should + (ebox-test--buffer-visually-matches-runtime-render-p)))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-region-role-template-coalesces-unrelated-properties () + "Role templates should inspect present properties and preserve full spans." + (let ((rendered (copy-sequence "abcd"))) + (add-text-properties 0 4 '(ebox-content 7) rendered) + (add-text-properties 1 2 '(face bold) rendered) + (add-text-properties 2 4 '(ebox-pl 8) rendered) + (let ((template + (ebox-buffer--build-region-role-span-template rendered))) + (should (equal (gethash (list 7 'content) template) + '((1 . 5)))) + (should (equal (gethash (list 8 'pl) template) + '((3 . 5))))))) + +(ert-deftest ebox-prepared-root-render-is-exact-one-shot-fast-path () + "An exact prepared root should bypass layout once and never affect children." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (240) + (box :id "child" :content "prepared" :height 1)))) + buffer) + (unwind-protect + (let ((ebox-viewport-width 900) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prepared-root*") layout)) + (let* ((state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (child (ebox--buffer-region-render-owner-node + buffer + (ebox-test--selector-region-id buffer "#child"))) + (rendered (propertize "prepared-root" 'face 'bold)) + (prepared + (list :rendered rendered + :render-state state + :node-id (ebox--ensure-node-id root) + :runtime-revision 0 + :display-signature + (with-current-buffer buffer + (ebox-display-signature)) + :kind 'root-width + :region-id + (ebox-test--selector-region-id buffer "#root") + :root-width 240 + :viewport-width 900 + :viewport-height 4))) + (with-current-buffer buffer + (ebox--with-buffer-render-context buffer + (let ((ebox--prepared-root-render prepared)) + ;; A local subtree lookup must not inspect or consume the + ;; root-only handoff. + (ebox--render-with-cache child) + (should-not (plist-get prepared :consumed-p)) + (cl-letf (((symbol-function 'ebox-render) + (lambda (&rest _) + (ert-fail "Exact prepared root reran layout")))) + (should (eq (ebox--render-with-cache root t) rendered))) + (should (plist-get prepared :consumed-p)) + (should-not ebox--prepared-root-render)))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-prepared-root-render-rejects-stale-runtime () + "A stale prepared root should fall back without entering later updates." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build '(box :id "root" :width (240) :content "old"))) + buffer + (fallback-count 0)) + (unwind-protect + (let ((ebox-viewport-width 900) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-stale-prepared-root*") + layout)) + (let* ((state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (prepared + (list :rendered "stale" + :render-state state + :node-id (ebox--ensure-node-id root) + :runtime-revision 1 + :display-signature + (with-current-buffer buffer + (ebox-display-signature)) + :kind 'root-width + :region-id + (ebox-test--selector-region-id buffer "#root") + :root-width 240 + :viewport-width 900 + :viewport-height 4))) + (with-current-buffer buffer + (ebox--with-buffer-render-context buffer + (let ((ebox--prepared-root-render prepared) + (ebox--render-cache-table + (make-hash-table :test 'equal))) + (cl-letf (((symbol-function 'ebox-render) + (lambda (&rest _) + (cl-incf fallback-count) + "fresh"))) + (should (equal (ebox--render-with-cache root t) "fresh"))) + (should (= fallback-count 1)) + (should-not (plist-get prepared :consumed-p)) + (should-not ebox--prepared-root-render)))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-prepared-animation-restores-complete-final-runtime () + "Display-only root frames should defer indexes until one complete final frame." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (240) + (column + (box :id "a" :content "alpha" :height 1) + (box :id "b" :content "beta" :height 1))))) + buffer) + (unwind-protect + (let ((ebox-viewport-width 900) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prepared-animation*") + layout)) + (let* ((state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (root-node-id (ebox--ensure-node-id root)) + (root-id (ebox-test--selector-region-id buffer "#root")) + (initial + (with-current-buffer buffer + (buffer-string))) + (render-at + (lambda (width) + (let* ((isolated (copy-tree root)) + (box (ebox--root-region-box isolated root-id)) + (ebox-viewport-width 900) + (ebox-viewport-height 4) + (ebox--render-runtime-revision + (plist-get state :runtime-revision)) + (ebox--render-cache-table + (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache + (make-hash-table :test 'eq)) + (ebox--region-box-table + (make-hash-table :test 'equal)) + (ebox--scroll-global-state + (make-hash-table :test 'equal))) + (ebox-put box :width width) + (ebox-render isolated)))) + (full-260 (funcall render-at 260)) + (light-260 (copy-sequence full-260)) + (prepared-260 nil)) + (remove-list-of-text-properties + 0 (length light-260) + '(ebox-content-idx ebox-content ebox-content-owners + ebox-content-owner ebox-bb ebox-br ebox-pr ebox-pb ebox-pl + ebox-bl ebox-bt ebox-pt ebox-scroll-window) + light-260) + (setq prepared-260 + (list :rendered light-260 + :render-state state + :node-id root-node-id + :runtime-revision 0 + :display-signature + (with-current-buffer buffer + (ebox-display-signature)) + :kind 'root-width + :region-id root-id + :root-width 260 + :viewport-width 900 + :viewport-height 4)) + (with-current-buffer buffer + (let ((ebox--prepared-root-render prepared-260) + (ebox--prepared-root-defer-index-refresh t) + (ebox--region-update-buffer-hint buffer)) + (ebox-region-update root-id :width '(260)))) + (should (plist-get prepared-260 :consumed-p)) + (should (eq (plist-get (ebox-test--buffer-update-report) + :snapshot-refresh) + 'prepared-animation)) + (should (eq (plist-get (ebox-test--buffer-update-report) :index-refresh) + 'defer-extents)) + (should-not (ebox--buffer-region-role-span-table buffer)) + (let* ((snapshot + (gethash root-node-id + (ebox--ensure-buffer-layout-snapshots buffer))) + (spans (plist-get snapshot :buffer-spans))) + (should (= (caar spans) 1)) + (should (= (cdr (car (last spans))) + (with-current-buffer buffer (point-max))))) + (let ((prepared-240 + (list :rendered initial + :render-state state + :node-id root-node-id + :runtime-revision + (plist-get state :runtime-revision) + :display-signature + (with-current-buffer buffer + (ebox-display-signature)) + :kind 'root-width + :region-id root-id + :root-width 240 + :viewport-width 900 + :viewport-height 4))) + (with-current-buffer buffer + (let ((ebox--prepared-root-render prepared-240) + (ebox--prepared-root-defer-index-refresh nil) + (ebox--region-update-buffer-hint buffer)) + (ebox-region-update root-id :width '(240)))) + (should (plist-get prepared-240 :consumed-p))) + (with-current-buffer buffer + (should (equal-including-properties (buffer-string) initial)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-complete-root-output-cache-retains-only-recent-widths () + "Full root outputs should not crowd long-lived subtree cache entries." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) + (box :content "bounded root output" :width (viewport))))) + (ebox-render-root-cache-max-entries 2) + (ebox-render-root-cache-max-bytes (* 8 1024 1024)) + buffer) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-bounded-root-cache*") + layout)) + (dolist (width '(260 280 300)) + (ebox-rerender-buffer-with-context buffer width 4)) + (let* ((render-cache (ebox--buffer-render-cache buffer)) + (root-cache + (gethash render-cache ebox--render-root-cache-table-table))) + (should (hash-table-p root-cache)) + (should (= (hash-table-count root-cache) 2)) + (should-not (eq root-cache render-cache)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-root-width-cache-prewarm-is-isolated-and-reusable () + "Predicted ordinary root width output should stay isolated until update." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (240) + (box :id "scroll" :width (viewport) :height 2 + :overflow scroll :border t + :content "predicted ordinary root\nline two\nline three")))) + buffer + root-id + (render-box-count 0) + (role-refresh-count 0) + (scroll-refresh-count 0) + (original-render-box (symbol-function 'ebox--render-box)) + (original-role-refresh + (symbol-function 'ebox-buffer-refresh-region-role-spans)) + (original-scroll-refresh + (symbol-function 'ebox--refresh-buffer-scroll-content-markers))) + (unwind-protect + (let ((ebox-viewport-width 900) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-root-width-prewarm*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--buffer-render-state buffer)) + (revision (plist-get state :runtime-revision)) + (root-before (copy-tree (plist-get state :root-node))) + (text-before (with-current-buffer buffer (buffer-string)))) + (should + (ebox--prewarm-buffer-render-cache-at-root-width + buffer state revision root-id 240 260)) + (should (equal (plist-get state :root-node) root-before)) + (should (equal-including-properties + (with-current-buffer buffer (buffer-string)) + text-before))) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (&rest args) + (cl-incf render-box-count) + (apply original-render-box args))) + ((symbol-function 'ebox-buffer-refresh-region-role-spans) + (lambda (&rest args) + (cl-incf role-refresh-count) + (apply original-role-refresh args))) + ((symbol-function + 'ebox--refresh-buffer-scroll-content-markers) + (lambda (&rest args) + (cl-incf scroll-refresh-count) + (apply original-scroll-refresh args)))) + (with-current-buffer buffer + (ebox-region-update root-id :width '(260)))) + (should (= render-box-count 0)) + (should (= role-refresh-count 0)) + (should (= scroll-refresh-count 0)) + (with-current-buffer buffer + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-root-width-prewarm-reuses-confirmed-isolated-scratch () + "Consecutive root predictions should reuse one confirmed isolated tree." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (240) + (box :content "reusable isolated tree" :height 1)))) + buffer) + (unwind-protect + (let ((ebox-viewport-width 900) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-reflow-scratch*") layout)) + (let* ((state (ebox--buffer-render-state buffer)) + (root-id (ebox-test--selector-region-id buffer "#root")) + (revision (plist-get state :runtime-revision)) + live-root scratch scratch-tree) + (setq live-root (plist-get state :root-node)) + (should + (ebox--prewarm-buffer-render-cache-at-root-width + buffer state revision root-id 240 260)) + (setq scratch (plist-get state :reflow-prewarm-scratch) + scratch-tree (plist-get scratch :tree)) + (should scratch) + (should-not (eq scratch-tree live-root)) + (should (= (ebox--literal-root-pixel-width + (ebox--root-region-box scratch-tree root-id)) + 260)) + (should (= (ebox--literal-root-pixel-width + (ebox--root-region-box live-root root-id)) + 240)) + (let ((ebox--region-update-buffer-hint buffer)) + (with-current-buffer buffer + (ebox-region-update root-id :width '(260)))) + (should (eq scratch + (plist-get state :reflow-prewarm-scratch))) + (should (= (plist-get scratch :live-revision) 1)) + (should-not (plist-get scratch :predicted-revision)) + (should + (ebox--prewarm-buffer-render-cache-at-root-width + buffer state 1 root-id 260 280)) + (should (eq scratch-tree + (plist-get + (plist-get state :reflow-prewarm-scratch) :tree))) + (should (= (ebox--literal-root-pixel-width + (ebox--root-region-box live-root root-id)) + 260)) + (should (= (ebox--literal-root-pixel-width + (ebox--root-region-box scratch-tree root-id)) + 280)) + (with-current-buffer buffer + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-root-width-prewarm-discards-mismatched-scratch () + "A visible root width other than the prediction should discard its scratch." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (240) + (box :content "discard stale prediction" :height 1)))) + buffer) + (unwind-protect + (let ((ebox-viewport-width 900) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-stale-scratch*") layout)) + (let* ((state (ebox--buffer-render-state buffer)) + (root-id (ebox-test--selector-region-id buffer "#root"))) + (should + (ebox--prewarm-buffer-render-cache-at-root-width + buffer state 0 root-id 240 260)) + (should (plist-get state :reflow-prewarm-scratch)) + (let ((ebox--region-update-buffer-hint buffer)) + (with-current-buffer buffer + (ebox-region-update root-id :width '(280)))) + (should-not (plist-get state :reflow-prewarm-scratch)) + (with-current-buffer buffer + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-prewarm-reuses-live-isolated-scratch () + "Viewport predictions should reuse one nonmutating isolated live tree." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) + (box :content "viewport scratch" :width (viewport) :height 1)))) + buffer) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-viewport-scratch*") layout)) + (let* ((state (ebox--buffer-render-state buffer)) + (live-root (plist-get state :root-node)) + scratch tree) + (should + (ebox--prewarm-buffer-render-cache-at-context + buffer state 0 240 260 4)) + (setq scratch (plist-get state :reflow-prewarm-scratch) + tree (plist-get scratch :tree)) + (should scratch) + (should-not (eq tree live-root)) + (should + (ebox--prewarm-buffer-render-cache-at-context + buffer state 0 240 280 4)) + (should (eq tree + (plist-get + (plist-get state :reflow-prewarm-scratch) :tree))) + (should (eq live-root (plist-get state :root-node))) + (should-not (plist-get scratch :predicted-revision)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-root-width-cache-prewarm-yields-to-user-input () + "Ordinary root prediction should run only after an idle interval." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (240) + (box :content "scheduled ordinary root" :height 1)))) + buffer + scheduled-delay + scheduled-repeat + scheduled-function + scheduled-arguments) + (unwind-protect + (let ((ebox-viewport-width 900) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm t) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-root-schedule*") + layout)) + (let ((root-id (ebox-test--selector-region-id buffer "#root"))) + (cl-letf (((symbol-function 'run-at-time) + (lambda (&rest _) + (ert-fail "Resize prediction must yield to input"))) + ((symbol-function 'run-with-idle-timer) + (lambda (delay repeat function &rest arguments) + (setq scheduled-delay delay + scheduled-repeat repeat + scheduled-function function + scheduled-arguments arguments) + 'scheduled-reflow-timer))) + (ebox--schedule-buffer-root-width-cache-prewarm + buffer root-id 240 260))) + (should (= scheduled-delay + ebox-runtime-idle-reflow-cache-prewarm-delay)) + (should-not scheduled-repeat) + (should (eq scheduled-function + #'ebox--run-root-width-cache-prewarm)) + (should (equal (car scheduled-arguments) buffer)) + (should (eq (gethash buffer ebox--reflow-cache-prewarm-timers) + 'scheduled-reflow-timer))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-scheduled-reflow-prewarms-use-offscreen-gc-pressure () + "Both predicted reflow paths should move GC pressure off visible frames." + (let ((prewarm-count 0) + (gc-count 0) + observed) + (cl-letf (((symbol-function + 'ebox--prewarm-buffer-render-cache-at-root-width) + (lambda (&rest _) + (cl-incf prewarm-count) + (push (list gc-cons-threshold gc-cons-percentage + ebox-render-gc-cons-threshold + ebox-render-gc-cons-percentage) + observed) + t)) + ((symbol-function + 'ebox--prewarm-buffer-render-cache-at-context) + (lambda (&rest _) + (cl-incf prewarm-count) + (push (list gc-cons-threshold gc-cons-percentage + ebox-render-gc-cons-threshold + ebox-render-gc-cons-percentage) + observed) + t)) + ((symbol-function 'garbage-collect) + (lambda () (cl-incf gc-count)))) + (let ((ebox-reflow-cache-prewarm-gc-cons-threshold 123456) + (ebox-reflow-cache-prewarm-gc-cons-percentage 0.2)) + (ebox--run-root-width-cache-prewarm + nil nil 0 'root 300 315) + (ebox--run-reflow-cache-prewarm + nil nil 0 300 315 20))) + (should (= prewarm-count 2)) + (should (= gc-count 0)) + (should (equal observed + '((123456 0.2 123456 0.2) + (123456 0.2 123456 0.2)))))) + +(ert-deftest ebox-reflow-prewarm-gc-adapts-to-measured-layout-cost () + "Automatic prewarm GC should follow measured allocation and layout time." + (let ((ebox--physical-memory-bytes (* 16 1024 1024 1024)) + (ebox-reflow-cache-prewarm-gc-auto-frame-budget 0.2) + (ebox-reflow-cache-prewarm-gc-auto-min-threshold (* 64 1024 1024)) + (ebox-reflow-cache-prewarm-gc-auto-max-threshold (* 1024 1024 1024)) + (ebox-reflow-cache-prewarm-gc-auto-target-layouts 24)) + (should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation + (* 28 1024 1024) 0.16) + (* 672 1024 1024))) + (should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation + (* 8 1024 1024) 0.08) + (* 128 1024 1024))) + (should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation + (* 2 1024 1024) 0.02) + (* 64 1024 1024))) + (should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation + (* 64 1024 1024) 0.21) + (* 1024 1024 1024))) + (should (= (ebox--memory-use-counts-estimated-bytes + '(0 0 0 0 0 0 0) '(1 1 1 1 1 1 1)) + 161)) + (let ((state (list :runtime-revision 0))) + (ebox--record-reflow-cache-prewarm-cost + state 0.16 0 (* 512 1024 1024) (* 28 1024 1024)) + (should (= (plist-get state :reflow-prewarm-gc-threshold) + (* 672 1024 1024))) + (ebox--record-reflow-cache-prewarm-cost + state 0.5 1 (* 672 1024 1024) (* 28 1024 1024)) + (should (= (plist-get state :reflow-prewarm-gc-threshold) + (* 672 1024 1024))) + (should (= (plist-get state :reflow-prewarm-layout-seconds) 0.16))))) + +(ert-deftest ebox-buffer-backend-maps-paint-style-to-text-properties () + "Paint mapping should be a backend operation, not a style-model operation." + (let* ((style (ebox-style-compute + (list :color "#111111" + :background-color "#eeeeee" + :border-color "#333333"))) + (props (ebox-buffer-paint-text-properties style 'content))) + (should props) + (should (plist-member props 'face)))) + +(ert-deftest ebox-buffer-backend-does-not-map-layout-only-properties () + "Layout-only properties should not produce text properties directly." + (let* ((style (ebox-style-compute + (list :width '(120) + :padding '(1 2)))) + (props (ebox-buffer-paint-text-properties style 'content))) + (should-not (memq :width props)) + (should-not (memq :padding-inline-start props)))) + +(ert-deftest ebox-add-content-owner-ignores-paint-only-segment-changes () + "Owner stacking should segment only when owner metadata changes." + (let ((line (copy-sequence "abcdefgh")) + (stack-lookups 0) + (original-stack-at (symbol-function 'ebox--content-owner-stack-at))) + (add-text-properties 0 4 '(ebox-content-owner child-a) line) + (add-text-properties 4 8 '(ebox-content-owner child-b) line) + (add-text-properties 1 2 '(face (:foreground "#111111")) line) + (add-text-properties 2 3 '(display (space :width (3))) line) + (add-text-properties 5 6 '(face (:background "#eeeeee")) line) + (cl-letf (((symbol-function 'ebox--content-owner-stack-at) + (lambda (&rest args) + (cl-incf stack-lookups) + (apply original-stack-at args)))) + (let ((owned (ebox--add-content-owner line 'root))) + (should (= stack-lookups 2)) + (should (equal '(child-a root) + (get-text-property 0 'ebox-content-owners owned))) + (should (equal '(child-b root) + (get-text-property 4 'ebox-content-owners owned))))))) + +(ert-deftest ebox-add-content-owner-scans-complete-owner-stacks-once () + "Complete owner stacks should not require a parallel owner-property scan." + (let ((line (copy-sequence "abcdefgh")) + (property-scans 0) + (original-next (symbol-function 'next-single-property-change))) + (add-text-properties + 0 4 '(ebox-content-owner child-a ebox-content-owners (child-a)) line) + (add-text-properties + 4 8 '(ebox-content-owner child-b ebox-content-owners (child-b)) line) + (cl-letf (((symbol-function 'next-single-property-change) + (lambda (position property &rest args) + (when (memq property + '(ebox-content-owner ebox-content-owners)) + (cl-incf property-scans)) + (apply original-next position property args)))) + (let ((owned (ebox--add-content-owner line 'root))) + (should (= property-scans 2)) + (should (equal '(child-a root) + (get-text-property 0 'ebox-content-owners owned))) + (should (equal '(child-b root) + (get-text-property 4 'ebox-content-owners owned))))))) + +(ert-deftest ebox-add-content-owners-merges-all-owners-in-one-scan () + "Adding several owners should scan each original owner run only once." + (let ((line (copy-sequence "abcdefgh")) + (property-scans 0) + (original-next (symbol-function 'next-single-property-change))) + (add-text-properties 0 4 '(ebox-content-owner child-a) line) + (add-text-properties 4 8 '(ebox-content-owner child-b) line) + (cl-letf (((symbol-function 'next-single-property-change) + (lambda (position property &rest args) + (when (memq property + '(ebox-content-owner ebox-content-owners)) + (cl-incf property-scans)) + (apply original-next position property args)))) + (let ((owned (ebox--add-content-owners line '(middle root)))) + (should (= property-scans 4)) + (should (eq (get-text-property 0 'ebox-content-owner owned) 'root)) + (should (equal '(child-a middle root) + (get-text-property 0 'ebox-content-owners owned))) + (should (equal '(child-b middle root) + (get-text-property 4 'ebox-content-owners owned))))))) + +(ert-deftest ebox-propertize-content-line-can-own-private-line-in-place () + "A private split line should accept wrapper ownership without another copy." + (let* ((source (propertize "content" + 'ebox-content 'child + 'ebox-content-owner 'child + 'ebox-content-owners '(child))) + (source-before (copy-sequence source)) + (private-line (copy-sequence source)) + (regular + (ebox--propertize-content-line source 'wrapper 0 "fallback")) + (owned + (let ((ebox--propertize-private-content-line-p t)) + (ebox--propertize-content-line + private-line 'wrapper 0 "fallback")))) + (should-not (eq regular source)) + (should (equal-including-properties source source-before)) + (should (eq owned private-line)) + (should (eq (get-text-property 0 'ebox-content-owner owned) 'wrapper)) + (should (equal (get-text-property 0 'ebox-content-owners owned) + '(child wrapper))))) + +(ert-deftest ebox-scroll-rendered-content-lines-own-color-copy-in-place () + "Colored scroll lines should add ownership to the color copy in place." + (let* ((box (ebox-create :width 120 + :color "#112233" + :bgcolor "#F4F5F6")) + (region-id 901) + (source (propertize "child" 'ebox-content 77 + 'ebox-content-owner 77 + 'ebox-content-owners '(77))) + (source-before (copy-sequence source)) + (filler (ebox-pixel-space (ebox--content-pixel box))) + (expected + (ebox--propertize-colors + (ebox--propertize-content-line source region-id 4 filler) + (ebox-get box :color) + (ebox-get box :bgcolor))) + (copying-owner-count 0) + (original-copying-owner + (symbol-function 'ebox--add-content-owner)) + actual) + (cl-letf (((symbol-function 'ebox--add-content-owner) + (lambda (&rest args) + (cl-incf copying-owner-count) + (apply original-copying-owner args)))) + (setq actual + (car (ebox--scroll-rendered-content-lines + box (list source) region-id 4)))) + (should (equal-including-properties source source-before)) + (should (equal-including-properties actual expected)) + (should (= copying-owner-count 0)))) + +(ert-deftest ebox-box-render-coalesces-foreground-background-pass () + "Foreground and background should not each copy the rendered box lines." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "paint" + :width 12 :height 1 + :color "#112233" + :bgcolor "#ddeeff")) + (reference (ebox-render box)) + (original-color (symbol-function 'ebox--propertize-color)) + (original-bgcolor (symbol-function 'ebox--propertize-bgcolor)) + (color-calls 0) + (bgcolor-calls 0) + actual) + (cl-letf (((symbol-function 'ebox--propertize-color) + (lambda (&rest args) + (cl-incf color-calls) + (apply original-color args))) + ((symbol-function 'ebox--propertize-bgcolor) + (lambda (&rest args) + (cl-incf bgcolor-calls) + (apply original-bgcolor args)))) + (setq actual (ebox-render box))) + (should (= color-calls 0)) + (should (= bgcolor-calls 0)) + (should (equal-including-properties actual reference)))) + +(ert-deftest ebox-incremental-update-scheduler-batches-multiple-style-changes () + "Scheduler seed should coalesce multiple property changes into one flush." + (let* ((node (ebox-create :content "Batch" :border-color "#111111")) + (id (car (ebox-region-ids node)))) + (ebox-test--with-rendered-buffer node + (ebox-incremental-begin-batch (current-buffer)) + (ebox-region-update id :border-color "#222222") + (ebox-region-update id :background-color "#eeeeee") + (should (= (ebox-incremental-pending-count (current-buffer)) 2)) + (ebox-incremental-flush (current-buffer)) + (let ((report (ebox-test--buffer-update-report))) + (should (= (plist-get report :flush-count) 1)) + (should (= (plist-get report :patch-count) 1)) + (should (member 'paint (plist-get report :dirty-kinds))))))) + +(ert-deftest ebox-explicit-batch-keeps-stronger-same-owner-operation () + "Same-owner paint and geometry must preserve the stronger operation." + (dolist (order '((paint geometry) (geometry paint))) + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Batch width" :width '(160) + :color "black")) + (id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (let ((buffer (current-buffer))) + (ebox-incremental-begin-batch buffer) + (dolist (operation order) + (pcase operation + ('paint (ebox-region-update id :color "red")) + ('geometry (ebox-region-update id :width '(80))))) + (ebox-incremental-flush buffer)) + (let ((report (ebox-test--buffer-update-report))) + (let ((runtime-box + (gethash id (ebox--buffer-region-box-table + (current-buffer))))) + (should (equal (ebox-get box :width) 160)) + (should (= (ebox-get runtime-box :width) 80))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (should-not (eq (plist-get report :strategy) 'paint-patch))))))) + +(ert-deftest ebox-explicit-batch-keeps-descendant-paint-after-ancestor-paint () + "Ancestor and child paint must execute general-to-specific in any input order." + (dolist (order '((parent child) (child parent))) + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id parent :width (120) :color "black" + (box :id child :content "Child" :width (120) + :color "black"))))) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (parent-id (ebox-test--selector-region-id buffer "#parent")) + (child-id (ebox-test--selector-region-id buffer "#child"))) + (ebox-incremental-begin-batch buffer) + (dolist (operation order) + (pcase operation + ('parent (ebox-region-update parent-id :color "red")) + ('child (ebox-region-update child-id :color "blue")))) + (ebox-incremental-flush buffer)) + (goto-char (point-min)) + (search-forward "Child") + (let ((face (get-text-property (- (point) (length "Child")) 'face))) + (should (ebox-test--face-has-key-value-p + face :foreground "blue"))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-explicit-batch-paints-before-descendant-text-change () + "Ancestor paint must run before a descendant text-changing operation." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id parent :width (160) :color "black" + (box :id child :content "Child" :width (120) + :color "blue"))))) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (parent-id (ebox-test--selector-region-id buffer "#parent")) + (child-id (ebox-test--selector-region-id buffer "#child"))) + (ebox-incremental-begin-batch buffer) + (ebox-region-update parent-id :color "red") + (ebox-region-update child-id :width '(80)) + (ebox-incremental-flush buffer)) + (goto-char (point-min)) + (search-forward "Child") + (let ((face (get-text-property (- (point) (length "Child")) 'face))) + (should (ebox-test--face-has-key-value-p + face :foreground "blue"))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(defun ebox-test--multi-row-batch-flex-layout () + "Return a wrapped flex fixture with targets on separate rows." + (ebox-build + '(flex :id matrix :width (500) :flex-flow (row wrap) + :column-gap (12) :row-gap 1 :padding (0 (12)) + :border "#999999" + (column :width (210) :border "#999999" + (box :content "Title A" :width (190)) + (box :id target-a :content "Target A" :width (160) + :height 3 :padding (0 (8)) :border "#999999" + :text-align right) + (box :content "Metric A" :width (190) :height 2)) + (column :width (210) :border "#999999" + (box :content "Peer A" :width (190)) + (box :content "Same first row" :width (190) :height 3) + (box :content "Metric B" :width (190) :height 2)) + (column :width (210) :border "#999999" + (box :content "Title B" :width (190)) + (box :id target-b :content "Target B" :width (160) + :height 3 :padding (0 (8)) :border "#999999" + :text-align right) + (box :content "Metric C" :width (190) :height 2)) + (column :width (210) :border "#999999" + (box :content "Peer B" :width (190)) + (box :content "Same second row" :width (190) :height 3) + (box :content "Metric D" :width (190) :height 2))))) + +(ert-deftest ebox-explicit-batch-multiple-flex-rows-skip-line-fast-path () + "Dirty descendants on separate flex rows must rerender in either order." + (dolist (order '((target-a target-b) (target-b target-a))) + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--multi-row-batch-flex-layout))) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (target-a-id + (ebox-test--selector-region-id buffer "#target-a")) + (target-b-id + (ebox-test--selector-region-id buffer "#target-b"))) + (ebox-incremental-begin-batch buffer) + (dolist (target order) + (ebox-region-update + (if (eq target 'target-a) target-a-id target-b-id) + :width '(210) :min-width '(150) :max-width '(260))) + (ebox-incremental-flush buffer)) + (let ((report (ebox-test--buffer-update-report))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (should-not (plist-get report :flex-line-rerender))))))) + +(ert-deftest ebox-explicit-batch-absorbed-flex-descendant-keeps-provenance () + "An absorbed descendant update must disable a partial flex owner rerender." + (dolist (order '((geometry paint) (paint geometry))) + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--multi-row-batch-flex-layout))) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (target-a-id + (ebox-test--selector-region-id buffer "#target-a")) + (target-b-id + (ebox-test--selector-region-id buffer "#target-b"))) + (ebox-incremental-begin-batch buffer) + (dolist (operation order) + (pcase operation + ('geometry + (ebox-region-update target-a-id + :width '(210) + :min-width '(150) + :max-width '(260))) + ('paint + (ebox-region-update target-b-id :color "blue")))) + (ebox-incremental-flush buffer)) + (goto-char (point-min)) + (search-forward "Target B") + (let* ((start (- (point) (length "Target B"))) + (face (get-text-property start 'face)) + (report (ebox-test--buffer-update-report))) + (should (ebox-test--face-has-key-value-p + face :foreground "blue")) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (should-not (plist-get report :flex-line-rerender))))))) + +(ert-deftest ebox-layout-module-renders-row-and-column-through-facade () + "Facade rendering should still use extracted layout owners." + (let ((rendered (ebox-render + (ebox-build + '(column + (row + (box :content "A" :width (40)) + (box :content "B" :width (40)))))))) + (should (string-match-p "A" rendered)) + (should (string-match-p "B" rendered)))) + +(ert-deftest ebox-layout-snapshot-details-are-computed-lazily () + "Initial render should store cheap snapshots and complete spans on demand." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "L" :width 40 :border t)) + (right (ebox-create :content "R" :width 40 :border t)) + (layout (ebox-concat left right)) + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (let* ((root-id (plist-get layout :node-id)) + (snapshot (ebox--layout-snapshot buffer root-id))) + (should snapshot) + (should (eq (plist-get snapshot :type) 'concat)) + (should-not (plist-member snapshot :line-signature)) + (should-not (plist-member snapshot :buffer-spans)) + (let ((detailed + (ebox--ensure-layout-snapshot-details buffer root-id))) + (should (plist-get detailed :line-signature)) + (should (plist-get detailed :buffer-spans)) + (should (plist-member + (ebox--layout-snapshot buffer root-id) + :line-signature))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-layout-snapshot-details-include-footprint-signatures () + "Detailed snapshots should expose explicit L2 footprint signatures." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Footprint" :width '(120) + :height 1 :border t)) + (node-id (ebox--ensure-node-id box))) + (ebox-test--with-rendered-buffer box + (let* ((snapshot (ebox--ensure-layout-snapshot-details + (current-buffer) node-id)) + (span-footprint + (plist-get snapshot :span-footprint-signature)) + (external-footprint + (plist-get snapshot :external-footprint-signature)) + (parent-slot + (plist-get snapshot :parent-slot-signature)) + (role-topology + (plist-get snapshot :role-topology-signature)) + (overflow + (plist-get snapshot :overflow-signature))) + (should span-footprint) + (should external-footprint) + (should parent-slot) + (should role-topology) + (should overflow) + (should (= (plist-get span-footprint :span-count) 1)) + (should (equal (plist-get span-footprint :line-pixel-widths) + (plist-get snapshot :line-signature))) + (should (= (plist-get external-footprint :block-lines) 1)) + (should (= (plist-get external-footprint :max-line-pixel-width) + (car (plist-get snapshot :line-signature)))) + (should (= (plist-get parent-slot :slot-count) 1)) + (should (memq 'content (plist-get role-topology :roles))) + (should-not (plist-get overflow :visible)))))) + +(ert-deftest ebox-role-topology-signature-uses-published-span-index () + "Role topology should read the published index without per-role span copies." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Indexed" :width '(120) :border t)) + (region-id (ebox--ensure-region-id box))) + (ebox-test--with-rendered-buffer box + (cl-letf (((symbol-function 'ebox--region-find) + (lambda (&rest _arguments) + (ert-fail "role topology rescanned region spans")))) + (let ((roles (plist-get (ebox--role-topology-signature + (list region-id)) + :roles))) + (should (memq 'content roles)) + (should (memq 'content-owner roles))))))) + +(ert-deftest ebox-replace-line-spans-returns-new-spans-for-contiguous-lines () + "Backend span replacement should return updated spans for every path." + (with-temp-buffer + (insert "one\ntwo\n") + (let* ((spans (save-excursion + (goto-char (point-min)) + (list (cons (line-beginning-position) + (line-end-position)) + (progn + (forward-line 1) + (cons (line-beginning-position) + (line-end-position)))))) + (new-spans (ebox--replace-line-spans spans + "alpha\nbeta\ngamma"))) + (should (equal (buffer-string) "alpha\nbeta\ngamma\n")) + (should (equal (mapcar (lambda (span) + (buffer-substring-no-properties + (car span) (cdr span))) + new-spans) + '("alpha" "beta" "gamma")))))) + +(ert-deftest ebox-resolve-line-anchor-falls-back-when-line-is-gone () + "Window-state anchors should resolve in bounded time after line replacement." + (with-temp-buffer + (insert "before\n") + (let ((anchor (ebox--line-anchor-at (point-min)))) + (erase-buffer) + (insert "after\n") + (should (= (ebox--resolve-line-anchor anchor) (point-min)))))) + +(ert-deftest ebox-line-anchor-occurrence-tracks-duplicate-plain-lines () + "Unowned duplicate lines keep resolving to the captured occurrence." + (with-temp-buffer + (insert "dup\nother\ndup\ndup\ntail\n") + (goto-char (point-min)) + (forward-line 3) + (forward-char 1) + (let ((anchor (ebox--line-anchor-at (point)))) + (should (= (plist-get anchor :occurrence) 2)) + (erase-buffer) + (insert "prefix\ndup\nother\ndup\nmiddle\ndup\ntail\n") + (goto-char (ebox--resolve-line-anchor anchor)) + (should (= (line-number-at-pos) 6)) + (should (= (current-column) 1))))) + +(ert-deftest ebox-line-anchor-occurrence-treats-line-text-literally () + "Occurrence counting must not read regexp syntax or partial matches." + (with-temp-buffer + (insert "a.*b [x]\naXb x\nxa.*b [x]y\na.*b [x]\n\n\ntext\n\n") + ;; "aXb x" satisfies "a.*b [x]" read as a regexp, and the superstring + ;; line contains it verbatim; only the literal full-line copy counts. + (goto-char (point-min)) + (forward-line 3) + (should (= (plist-get (ebox--line-anchor-at (point)) :occurrence) 1)) + ;; An empty anchor line counts only preceding empty lines and terminates. + (goto-char (point-max)) + (forward-line -1) + (should (= (plist-get (ebox--line-anchor-at (point)) :occurrence) 2)))) + +(ert-deftest ebox-layout-snapshot-capture-skips-line-ownership-scan () + "Initial snapshot capture should not scan line ownership for every node." + (ebox-test--reset-runtime-state) + (let* ((layout (apply + #'ebox-column + (cl-loop for index below 8 + collect + (ebox-flex + :border "#64748B" :bgcolor "#F8FAFC" + :flex-flow '(row wrap) + (ebox-create + :content (format "A%d" index) + :width 60 :bgcolor "#DBEAFE") + (ebox-create + :content (format "B%d" index) + :width 60 :bgcolor "#DCFCE7") + (ebox-create + :content (format "C%d" index) + :width 60 :bgcolor "#FEF3C7"))))) + (calls 0) + (original (symbol-function 'ebox--pos-region-ids)) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 900)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (ebox--clear-layout-snapshots buffer) + (cl-letf (((symbol-function 'ebox--pos-region-ids) + (lambda (&rest args) + (cl-incf calls) + (apply original args)))) + (ebox--refresh-buffer-layout-snapshots buffer)) + (should (= calls 0))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-stack-render-flattens-before-width-measurement () + "Column rendering should not repeatedly measure already stacked strings." + (ebox-test--reset-runtime-state) + (let* ((layout (apply + #'ebox-column + (cl-loop for index below 12 + collect + (ebox-create + :content (format "Row %02d" index) + :width (+ 40 (* index 2)))))) + (multiline-width-calls 0) + (original (symbol-function 'ebox--string-pixel-width))) + (cl-letf (((symbol-function 'ebox--string-pixel-width) + (lambda (string) + (when (and (stringp string) + (string-match-p "\n" string)) + (cl-incf multiline-width-calls)) + (funcall original string)))) + (ebox-render layout)) + (should (= multiline-width-calls 0)))) + +(ert-deftest ebox-lines-concat-horizontal-splits-each-input-once () + "Horizontal line concatenation should avoid split-pad-resplit work." + (ebox-test--reset-runtime-state) + (let ((split-count 0) + (original (symbol-function 'ebox-string-lines)) + result) + (cl-letf (((symbol-function 'ebox-string-lines) + (lambda (&rest args) + (cl-incf split-count) + (apply original args)))) + (setq result + (ebox--lines-concat-horizontal + "A\nB\nC" + "1\n2" + "x"))) + (should (string= (substring-no-properties result) + "A1x\nB2 \nC ")) + (should (= split-count 3)))) + +(ert-deftest ebox-layout-snapshot-table-is-created-when-missing () + "Snapshot storage should tolerate render states created before snapshots." + (ebox-test--reset-runtime-state) + (let ((buffer (generate-new-buffer " *ebox-test*")) + (node-id "legacy-node")) + (unwind-protect + (progn + (puthash buffer + (list :root-node nil + :viewport-width 80) + ebox--buffer-render-state-table) + (ebox--put-layout-snapshot buffer node-id (list :node-id node-id)) + (should (equal (plist-get (ebox--layout-snapshot buffer node-id) + :node-id) + node-id))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-layout-snapshot-records-display-style-and-wrapper-children () + "Snapshots should describe display, style, and child ids for wrapper boxes." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-build + '(column :padding 1 :bgcolor "linen" + (box :content "Child A" :width (120)) + (box :content "Child B" :width (120))))) + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + node)) + (let* ((snapshot (ebox--layout-snapshot + buffer (plist-get node :node-id))) + (child-ids (plist-get snapshot :child-ids)) + (child-snapshot (ebox--layout-snapshot buffer (car child-ids)))) + (should (equal (plist-get snapshot :display) '(block flow))) + (should (equal (plist-get child-snapshot :display) + '(block column))) + (should (plist-get snapshot :style-signature)) + (should child-ids) + (should (= (length child-ids) 1)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-dirty-set-classifies-paint-only-change () + "A color-only change should produce paint dirty, not geometry dirty." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Paint" :width 80 + :border t :border-color "red")) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (let ((old (ebox--current-layout-snapshots (current-buffer)))) + (ebox-region-update region-id :border-color "blue") + (let ((dirty (ebox--diff-layout-snapshots + old + (ebox--current-layout-snapshots (current-buffer))))) + (should dirty) + (should (eq (plist-get (car dirty) :dirty-kind) 'paint))))))) + +(ert-deftest ebox-dirty-set-classifies-viewport-wrap-change () + "Viewport wrapping should produce geometry or placement dirty." + (ebox-test--reset-runtime-state) + (let ((ebox-viewport-width 240) + buffer + layout) + (setq layout + (ebox-flex :flex-wrap 'wrap + (ebox-create :content "A" :width '(120)) + (ebox-create :content "B" :width '(120)))) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (let ((old (ebox--current-layout-snapshots buffer t))) + (ebox-rerender-buffer-with-context buffer 130) + (let ((dirty (ebox--diff-layout-snapshots + old + (ebox--current-layout-snapshots buffer t)))) + (should (cl-some + (lambda (entry) + (memq (plist-get entry :dirty-kind) + '(geometry placement structure))) + dirty))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-dirty-set-classifies-wrapper-child-viewport-geometry () + "Viewport reflow under a wrapper should produce geometry dirty, not no-op." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-build + '(column :padding 1 + (box :content "Text that wraps differently when narrow" + :wrap-mode word)))) + buffer) + (unwind-protect + (progn + (setq buffer + (let ((ebox-viewport-width 520)) + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + node))) + (ebox-rerender-buffer-with-context buffer 36) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :constraint-source) 'viewport)) + (should (member 'geometry (plist-get report :dirty-kinds))) + (should-not (eq (plist-get report :strategy) 'no-op)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-patch-set-paints-without-rendered-text-replace () + "Paint-only dirty entries should produce paint-patch ops." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Paint" :width 80 + :border t :border-color "red")) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (let ((replace-count 0) + (original-replace (symbol-function 'ebox--replace-line-spans))) + (cl-letf (((symbol-function 'ebox--replace-line-spans) + (lambda (&rest args) + (cl-incf replace-count) + (apply original-replace args)))) + (let ((full-rerenders + (ebox-test--count-full-rerenders + (ebox-region-update region-id :border-color "blue")))) + (should (= full-rerenders 0)))) + (should (= replace-count 0))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'paint-patch)) + (should (eq (plist-get report :full-rerender) nil)))))) + +(ert-deftest ebox-flex-wrapper-style-update-uses-paint-patch () + "Flex wrapper style updates should patch faces without replacing spans." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-flex + :border "#5E7F6A" :bgcolor "#F8FCF8" + (ebox-create :content "A" :width 60 + :bgcolor "#DCEBDD"))) + (region-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (let ((replace-count 0) + (original-replace (symbol-function 'ebox--replace-line-spans))) + (cl-letf (((symbol-function 'ebox--replace-line-spans) + (lambda (&rest args) + (cl-incf replace-count) + (apply original-replace args)))) + (ebox-region-update region-id :bgcolor "#FDE68A")) + (should (= replace-count 0))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'paint-patch)) + (should-not (plist-get report :full-rerender)) + (should (equal (plist-get report :patch-ops) + '(paint-patch))))))) + +(ert-deftest ebox-patch-set-promotes-interleaved-row-child-to-row-owner () + "Interleaved row children should promote to their common render owner." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "Left" :width 60)) + (right (ebox-create :content "Right" :width 60)) + (layout (ebox-row left right)) + (left-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (ebox-region-update left-id :width '(120)) + (let ((report (ebox-test--buffer-update-report))) + (should (memq (plist-get report :owner-type) '(concat row))) + (should-not (eq (plist-get report :owner-type) 'box)))))) + +(ert-deftest ebox-viewport-resize-patches-single-dependent-box () + "Viewport resize should patch the smallest dependent owner when possible." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-column + (ebox-create :content "Fixed" :width '(120) :border t) + (ebox-create :content "Fluid" :width 'stretch :border t))) + buffer + ids-before) + (unwind-protect + (progn + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (setq ids-before (ebox-region-ids layout)) + (let ((returned + (ebox-rerender-buffer-with-context buffer 260))) + (should (equal returned + (ebox-buffer-update-report buffer)))) + (should (equal (ebox-region-ids layout) ids-before)) + (let ((report (ebox-test--buffer-update-report))) + (should (memq (plist-get report :strategy) + '(span-patch owner-rerender root-rerender))) + (should (plist-get report :dirty-count)) + (should (= (plist-get report :dirty-count) 1)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-rerender-report-describes-constraint-change () + "Viewport resize reports should use the shared constraint-change model." + (ebox-test--reset-runtime-state) + (let ((ebox-viewport-width 240) + buffer + layout) + (setq layout + (ebox-flex :flex-wrap 'wrap + (ebox-create :content "A" :width '(120)) + (ebox-create :content "B" :width '(120)))) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (ebox-rerender-buffer-with-context buffer 160) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :constraint-source) 'viewport)) + (should (eq (plist-get report :constraint-owner-type) + 'viewport)) + (should (equal (plist-get report :constraint-owner-id) + 'viewport)) + (should (equal (plist-get report :constraint-root-node-id) + (plist-get layout :node-id))) + (should (equal (plist-get report :dirty-kinds) + '(geometry))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-rerender-patches-column-children-independently () + "Column children should be independent owners when viewport resize rewraps them." + (ebox-test--reset-runtime-state) + (let* ((top (ebox-create + :content "Top text should wrap into more lines when narrow." + :wrap-mode 'word)) + (bottom (ebox-create + :content "Bottom text should also survive after top grows." + :wrap-mode 'word)) + (layout (ebox-column top bottom)) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 260)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (ebox-rerender-buffer-with-context buffer 90) + (let ((report (ebox-test--buffer-update-report)) + (plain (with-current-buffer buffer + (buffer-substring-no-properties + (point-min) (point-max))))) + (should (equal (plist-get report :patch-ops) + '(owner-rerender owner-rerender))) + (should (= (plist-get report :patch-count) 2)) + (should-not (member (plist-get layout :node-id) + (plist-get report :owner-ids))) + (should (member (plist-get top :node-id) + (plist-get report :owner-ids))) + (should (member (plist-get bottom :node-id) + (plist-get report :owner-ids))) + (should (string-match-p "Top text" plain)) + (should (string-match-p "Bottom text" plain)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-rerender-noops-without-viewport-deps () + "Viewport resize should not rerender when no node depends on viewport width." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-create :content "Fixed" :width 80)) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (ebox-rerender-buffer-with-context buffer 160) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'no-op)) + (should (= (plist-get report :dirty-count) 0)) + (should (= (plist-get report :patch-count) 0)) + (should-not (plist-get report :full-rerender)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-region-width-update-report-describes-constraint-change () + "Ordinary box geometry updates should use the shared constraint-change model." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "Left" :width 60)) + (right (ebox-create :content "Right" :width 60)) + (layout (ebox-row left right)) + (left-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (ebox-region-update left-id :width '(120)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :constraint-source) 'region)) + (should (eq (plist-get report :constraint-owner-type) 'box)) + (should (equal (plist-get report :constraint-owner-id) + (plist-get left :node-id))) + (should (equal (plist-get report :dirty-kinds) + '(geometry))))))) + +(ert-deftest ebox-root-region-width-reflow-invalidates-derived-indexes () + "A root box width change should use viewport-equivalent index invalidation." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :width (240) + :content "ordinary root width"))) + (root-id (car (ebox-region-ids layout))) + (snapshot-refreshes 0) + (extent-refreshes 0) + (region-enumerations 0) + (scroll-probes 0) + (visible-index-probes 0) + (scroll-state-syncs 0) + (generic-patch-plans 0) + (original-snapshots + (symbol-function 'ebox--refresh-buffer-layout-snapshots)) + (original-extents + (symbol-function 'ebox--refresh-buffer-box-extents)) + (original-region-ids + (symbol-function 'ebox--node-all-region-ids)) + (original-lazy + (symbol-function 'ebox--try-lazy-scroll-deferred-patch)) + (original-hidden + (symbol-function 'ebox--try-hidden-scroll-state-span-patch)) + (original-hidden-root + (symbol-function 'ebox--try-hidden-scroll-root-fallback)) + (original-scroll-state + (symbol-function 'ebox--try-scroll-state-span-patch)) + (original-visible-indexes + (symbol-function 'ebox--ensure-visible-node-runtime-indexes)) + (original-scroll-state-sync + (symbol-function 'ebox--sync-scroll-state-lines-for-node)) + (original-patch-plan + (symbol-function 'ebox--patch-set-from-dirty-set))) + (ebox-test--with-rendered-buffer layout + (cl-letf (((symbol-function 'ebox--node-all-region-ids) + (lambda (&rest args) + (cl-incf region-enumerations) + (apply original-region-ids args)))) + (let ((change (ebox--region-constraint-change + (current-buffer) root-id 'geometry '(:width)))) + (should-not (plist-get (car (plist-get change :dirty-set)) + :region-ids)))) + (should (= region-enumerations 0)) + (cl-letf (((symbol-function 'ebox--refresh-buffer-layout-snapshots) + (lambda (&rest args) + (cl-incf snapshot-refreshes) + (apply original-snapshots args))) + ((symbol-function 'ebox--refresh-buffer-box-extents) + (lambda (&rest args) + (cl-incf extent-refreshes) + (apply original-extents args))) + ((symbol-function 'ebox--try-lazy-scroll-deferred-patch) + (lambda (&rest args) + (cl-incf scroll-probes) + (apply original-lazy args))) + ((symbol-function 'ebox--try-hidden-scroll-state-span-patch) + (lambda (&rest args) + (cl-incf scroll-probes) + (apply original-hidden args))) + ((symbol-function 'ebox--try-hidden-scroll-root-fallback) + (lambda (&rest args) + (cl-incf scroll-probes) + (apply original-hidden-root args))) + ((symbol-function 'ebox--try-scroll-state-span-patch) + (lambda (&rest args) + (cl-incf scroll-probes) + (apply original-scroll-state args))) + ((symbol-function 'ebox--ensure-visible-node-runtime-indexes) + (lambda (&rest args) + (cl-incf visible-index-probes) + (apply original-visible-indexes args))) + ((symbol-function 'ebox--sync-scroll-state-lines-for-node) + (lambda (&rest args) + (cl-incf scroll-state-syncs) + (apply original-scroll-state-sync args))) + ((symbol-function 'ebox--patch-set-from-dirty-set) + (lambda (&rest args) + (cl-incf generic-patch-plans) + (apply original-patch-plan args)))) + (ebox-region-update root-id :width '(160))) + (should (= snapshot-refreshes 0)) + (should (= extent-refreshes 0)) + (should (= scroll-probes 0)) + (should (= visible-index-probes 0)) + (should (= scroll-state-syncs 0)) + (should (= generic-patch-plans 0)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :constraint-source) 'region)) + (should (eq (plist-get report :snapshot-refresh) + 'invalidate-details)) + (should (eq (plist-get report :index-refresh) + 'invalidate-extents)))))) + +(ert-deftest ebox-root-region-width-reflow-rebinds-lazy-content-viewport () + "A root width update should constrain lazy descendants like viewport resize." + (ebox-test--reset-runtime-state) + (let* ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (layout + (ebox-build + '(box :id "root" :width (240) :height (viewport-height) + :overflow scroll + (column + (box :content "first" :width (viewport)) + (box :content "second" :width (viewport)) + (box :content "third" :width (viewport)))))) + (root-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (ebox-region-update root-id :width '(160)) + (let ((widths + (mapcar #'ebox--string-pixel-width + (ebox-string-lines (buffer-string))))) + (should widths) + (should (= (apply #'max widths) 160)))))) + +(ert-deftest ebox-region-width-update-promotes-parent-layout-owner () + "Child geometry changes should patch the parent layout that reflows siblings." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "Left" :width 60)) + (right (ebox-create :content "Right" :width 60)) + (layout (ebox-row left right)) + (left-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (ebox-region-update left-id :width '(120)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :constraint-owner-type) 'box)) + (should (eq (plist-get report :owner-type) 'concat)) + (should (equal (plist-get report :patch-ops) + '(owner-rerender)))) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "Left" plain)) + (should (string-match-p "Right" plain)))))) + +(ert-deftest ebox-wrapper-child-width-update-promotes-to-wrapper-context () + "Child geometry inside a wrapper should promote to the wrapper formatting context." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-build + '(column :padding 1 + (row + (box :content "Left" :width (60)) + (box :content "Right" :width (60)))))) + (ids (ebox-region-ids node)) + (left-id (nth 1 ids))) + (ebox-test--with-rendered-buffer node + (ebox-region-update left-id :width '(120)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :constraint-source) 'region)) + (should (memq 'owner-rerender (plist-get report :patch-ops)))) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "Left" plain)) + (should (string-match-p "Right" plain)))))) + +(ert-deftest ebox-patch-set-uses-wrapper-owner-for-contained-layout-change () + "Contained layout changes should patch a real owner instead of vanishing." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-build + '(column + (box :content "Stable header" :width (180)) + (column :padding 1 + (box :content "Child text wraps under the wrapper viewport" + :wrap-mode word))))) + buffer) + (unwind-protect + (progn + (setq buffer + (let ((ebox-viewport-width 520)) + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + node))) + (ebox-rerender-buffer-with-context buffer 36) + (let ((report (ebox-test--buffer-update-report))) + (should (memq (plist-get report :strategy) + '(owner-rerender root-rerender))) + (should (plist-get report :owner-ids)) + (should-not (equal (plist-get report :patch-ops) + '(paint-patch))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-patch-set-skips-dirty-nodes-covered-by-owner () + "Viewport patch planning should not repeatedly inspect covered dirty nodes." + (ebox-test--reset-runtime-state) + (let* ((layout (apply + #'ebox-row + (cl-loop for index below 24 + collect + (ebox-create + :content (format "Fluid %02d" index) + :border t)))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 640)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (let* ((root (ebox--buffer-root-node buffer)) + (dirty (ebox--viewport-dirty-set root)) + (detail-calls 0) + (original + (symbol-function 'ebox--ensure-layout-snapshot-details)) + patches) + (should (> (length dirty) 10)) + (cl-letf (((symbol-function + 'ebox--ensure-layout-snapshot-details) + (lambda (&rest args) + (cl-incf detail-calls) + (apply original args)))) + (setq patches + (ebox--patch-set-from-dirty-set dirty buffer))) + (should (= (length patches) 1)) + (should (eq (plist-get (car patches) :op) + 'owner-rerender)) + (should (<= detail-calls 4)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-patch-set-coalesces-large-column-coverage () + "Large local owner sets should coalesce when they cover most root lines." + (ebox-test--reset-runtime-state) + (let* ((layout (apply + #'ebox-column + (cl-loop for index below 12 + collect + (ebox-create + :content (format "Fluid section %02d" index) + :border t)))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 640)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (let* ((root (ebox--buffer-root-node buffer)) + (dirty (ebox--viewport-dirty-set root)) + (patches (ebox--patch-set-from-dirty-set dirty buffer)) + (source-ids + (ebox--dirty-provenance-node-ids + (plist-get (car patches) :dirty)))) + (should (> (length dirty) 8)) + (should (= (length patches) 1)) + (should (equal (plist-get (car patches) :owner-id) + (plist-get layout :node-id))) + (should (= (length source-ids) (length dirty))) + (dolist (entry dirty) + (should (member (plist-get entry :node-id) source-ids))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-rerender-coalesces-large-dirty-set-before-promotion () + "Large viewport dirty sets should choose root before local owner promotion." + (ebox-test--reset-runtime-state) + (let* ((layout (apply + #'ebox-column + (cl-loop for index below 12 + collect + (ebox-create + :content (format "Fluid section %02d" index) + :border t)))) + (promote-count 0) + (dirty-entry-count 0) + (original (symbol-function 'ebox--promote-to-patchable-owner)) + (original-dirty-entry (symbol-function 'ebox--dirty-entry)) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 640)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (let* ((root (ebox--buffer-root-node buffer)) + (dirty (ebox--viewport-dirty-set root)) + (patches (ebox--root-owner-patch-set buffer dirty)) + (source-ids + (ebox--dirty-provenance-node-ids + (plist-get (car patches) :dirty)))) + (should (> (length dirty) 8)) + (should (= (length source-ids) (length dirty))) + (dolist (entry dirty) + (should (member (plist-get entry :node-id) source-ids)))) + (cl-letf (((symbol-function 'ebox--promote-to-patchable-owner) + (lambda (&rest args) + (cl-incf promote-count) + (apply original args))) + ((symbol-function 'ebox--dirty-entry) + (lambda (&rest args) + (cl-incf dirty-entry-count) + (apply original-dirty-entry args)))) + (ebox-rerender-buffer-with-context buffer 320)) + (let ((report (ebox-test--buffer-update-report))) + (should (= promote-count 0)) + (should (= dirty-entry-count 1)) + (should (= (plist-get report :dirty-count) 1)) + (should (= (plist-get report :patch-count) 1)) + (should (equal (plist-get report :owner-ids) + (list (plist-get layout :node-id)))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-rerender-root-owner-allows-line-count-change () + "Root owner rerender should patch even when viewport wrapping changes height." + (ebox-test--reset-runtime-state) + (let* ((content + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (layout (apply + #'ebox-column + (cl-loop for _index below 12 + collect + (ebox-create + :content content + :width 'viewport + :wrap-mode 'word + :border t)))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 120)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (let ((wide-height (with-current-buffer buffer + (count-lines (point-min) (point-max))))) + (ebox-rerender-buffer-with-context buffer 20) + (let ((report (ebox-test--buffer-update-report)) + (narrow-height (with-current-buffer buffer + (count-lines (point-min) (point-max))))) + (should (> narrow-height wide-height)) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should-not (plist-get report :root-rerender))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-root-owner-rerender-skips-partial-slot-probe () + "Whole-line root owner rerenders should not run partial-slot probing." + (ebox-test--reset-runtime-state) + (let* ((content + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (layout (apply + #'ebox-column + (cl-loop for _index below 12 + collect + (ebox-create + :content content + :width 'viewport + :wrap-mode 'word + :border t)))) + (slot-probe-count 0) + (original (symbol-function 'ebox-buffer--rendered-in-existing-slots)) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 120)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (cl-letf (((symbol-function 'ebox-buffer--rendered-in-existing-slots) + (lambda (&rest args) + (cl-incf slot-probe-count) + (apply original args)))) + (ebox-rerender-buffer-with-context buffer 20)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :owner-ids) + (list (plist-get layout :node-id))))) + (should (= slot-probe-count 0))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-owner-rerender-renders-in-target-buffer () + "Owner rerender should compute display signatures in the target buffer." + (ebox-test--reset-runtime-state) + (let* ((node (let ((ebox-viewport-width 240)) + (ebox-build + '(box :content "Viewport text wraps inside the target buffer" + :width (viewport) + :wrap-mode word)))) + (buffer nil) + (other nil) + (render-buffers nil) + (original (symbol-function 'ebox-render))) + (unwind-protect + (progn + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + node))) + (setq other (generate-new-buffer " *ebox-other*")) + (with-current-buffer other + (cl-letf (((symbol-function 'ebox-render) + (lambda (&rest args) + (push (current-buffer) render-buffers) + (apply original args)))) + (ebox-rerender-buffer-with-context buffer 120))) + (should render-buffers) + (should (cl-every (lambda (seen-buffer) + (eq seen-buffer buffer)) + render-buffers))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer)) + (when (and other (buffer-live-p other)) + (kill-buffer other))))) + +(ert-deftest ebox-buffer-render-context-selects-target-buffer () + "Buffer render contexts should evaluate render-sensitive work in target buffers." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-create :content "context target")) + (buffer nil) + (other nil) + seen-buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + node)) + (setq other (generate-new-buffer " *ebox-other*")) + (with-current-buffer other + (ebox--with-buffer-render-context buffer + (setq seen-buffer (current-buffer)))) + (should (eq seen-buffer buffer))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer)) + (when (and other (buffer-live-p other)) + (kill-buffer other))))) + +(ert-deftest ebox-buffer-render-context-preserves-caller-state-binding () + "Render context internals should not capture a caller's STATE variable." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-create :content "context state")) + (buffer nil) + (state 'caller-scroll-state) + seen-state) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") node)) + (ebox--with-buffer-render-context buffer + (setq seen-state state)) + (should (eq seen-state 'caller-scroll-state))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-incremental-rerender-uses-render-gc-threshold () + "Incremental rerenders should run inside the Ebox render GC threshold." + (ebox-test--reset-runtime-state) + (let* ((content + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") + (layout (ebox-create + :content content + :width 'viewport + :wrap-mode 'word)) + (target-threshold (+ gc-cons-threshold 1000000)) + (observed-threshold nil) + (original (symbol-function 'ebox--execute-patch-set)) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 120)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (let ((ebox-render-gc-cons-threshold target-threshold)) + (cl-letf (((symbol-function 'ebox--execute-patch-set) + (lambda (&rest args) + (setq observed-threshold gc-cons-threshold) + (apply original args)))) + (ebox-rerender-buffer-with-context buffer 20))) + (should observed-threshold) + (should (>= observed-threshold target-threshold))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-deferred-render-gc-uses-interactive-burst-threshold () + "Interactive render bursts should stay bounded and collect when settled." + (let ((gc-cons-threshold 1000) + (gc-cons-percentage 0.1) + (ebox-deferred-render-gc-cons-threshold 2000) + (ebox-render-gc-cons-percentage 0.6) + (ebox--deferred-render-gc-state nil) + (ebox--deferred-render-gc-timer nil) + (gc-count 0)) + (unwind-protect + (cl-letf (((symbol-function 'garbage-collect) + (lambda () (cl-incf gc-count)))) + (ebox--deferred-render-gc-enter) + (should (= gc-cons-threshold 2000)) + (should (= gc-cons-percentage 0.6)) + (ebox--deferred-render-gc-restore) + (should (= gc-cons-threshold 1000)) + (should (= gc-cons-percentage 0.1)) + (should (= gc-count 1)) + (ebox--deferred-render-gc-restore) + (should (= gc-count 1))) + (when (timerp ebox--deferred-render-gc-timer) + (cancel-timer ebox--deferred-render-gc-timer))))) + +(ert-deftest ebox-deferred-render-gc-keeps-overlapping-burst-owned () + "A nested idle slice must not restore GC during an active animation." + (let ((gc-cons-threshold 1000) + (gc-cons-percentage 0.1) + (ebox-deferred-render-gc-cons-threshold 4000) + (ebox--deferred-render-gc-state nil) + (ebox--deferred-render-gc-timer nil) + (ebox--deferred-render-gc-depth 0) + scheduled) + (cl-letf (((symbol-function 'run-at-time) + (lambda (&rest _) + (setq scheduled 'restore-timer))) + ((symbol-function 'timerp) + (lambda (value) (eq value 'restore-timer)))) + (ebox--deferred-render-gc-enter) + (ebox--deferred-render-gc-enter) + (should (= ebox--deferred-render-gc-depth 2)) + (ebox--deferred-render-gc-schedule-restore) + (should (= ebox--deferred-render-gc-depth 1)) + (should-not scheduled) + (should (= gc-cons-threshold 4000)) + (ebox--deferred-render-gc-schedule-restore) + (should (eq scheduled 'restore-timer)) + (should (= ebox--deferred-render-gc-depth 0)) + (should (= gc-cons-threshold 4000))))) + +(ert-deftest ebox-deferred-render-gc-default-is-memory-bounded () + "The interactive burst default should scale within finite memory bounds." + (should (eq ebox-deferred-render-gc-cons-threshold 'auto)) + (let ((gc-cons-threshold (* 512 1024 1024)) + (gc-cons-percentage 0.1) + (ebox--physical-memory-bytes (* 16 1024 1024 1024)) + (ebox--deferred-render-gc-state nil) + (ebox--deferred-render-gc-timer nil)) + (unwind-protect + (progn + (ebox--deferred-render-gc-enter) + (should (= gc-cons-threshold (* 512 1024 1024)))) + (ebox--deferred-render-gc-restore)))) + +(ert-deftest ebox-deferred-render-gc-can-raise-an-active-burst-budget () + "Measured layout pressure should only raise an active burst budget." + (let ((gc-cons-threshold 1000) + (ebox--deferred-render-gc-state nil)) + (ebox--deferred-render-gc-raise-threshold 4000) + (should (= gc-cons-threshold 1000)) + (setq ebox--deferred-render-gc-state + '(:gc-cons-threshold 1000 :gc-cons-percentage 0.1)) + (ebox--deferred-render-gc-raise-threshold 4000) + (should (= gc-cons-threshold 4000)) + (ebox--deferred-render-gc-raise-threshold 2000) + (should (= gc-cons-threshold 4000)) + (ebox--deferred-render-gc-raise-threshold nil) + (should (= gc-cons-threshold 4000)))) + +(ert-deftest ebox-deferred-render-gc-can-bound-an-active-burst-budget () + "A specialized burst may lower its raised budget but not the saved setting." + (let ((gc-cons-threshold 5000) + (ebox--deferred-render-gc-state nil)) + (ebox--deferred-render-gc-set-threshold 3000) + (should (= gc-cons-threshold 5000)) + (setq ebox--deferred-render-gc-state + '(:gc-cons-threshold 1000 :gc-cons-percentage 0.1)) + (ebox--deferred-render-gc-set-threshold 3000) + (should (= gc-cons-threshold 3000)) + (ebox--deferred-render-gc-set-threshold 500) + (should (= gc-cons-threshold 1000)) + (ebox--deferred-render-gc-set-threshold nil) + (should (= gc-cons-threshold 1000)))) + +(ert-deftest ebox-visible-root-width-reflows-defer-gc-only-for-root () + "Visible root width reflows should defer GC without affecting local updates." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) + (box :id "child" :content "responsive child")))) + (buffer nil) + root-id child-id + (enter-count 0) + (restore-count 0)) + (unwind-protect + (progn + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") layout))) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (setq child-id (ebox-test--selector-region-id buffer "#child")) + (cl-letf (((symbol-function + 'ebox--buffer-visible-in-graphic-frame-p) + (lambda (_buffer) t)) + ((symbol-function 'ebox--deferred-render-gc-enter) + (lambda () (cl-incf enter-count))) + ((symbol-function + 'ebox--deferred-render-gc-schedule-restore) + (lambda () (cl-incf restore-count)))) + (ebox-rerender-buffer-with-context buffer 160) + (should (= enter-count 1)) + (should (= restore-count 1)) + (with-current-buffer buffer + (ebox-region-update child-id :bgcolor "#223344")) + (should (= enter-count 1)) + (should (= restore-count 1)) + (with-current-buffer buffer + (ebox-region-update root-id :width 180)) + (should (= enter-count 2)) + (should (= restore-count 2)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-patch-planning-skips-line-signatures () + "Owner rerender planning should not compute expensive line signatures." + (ebox-test--reset-runtime-state) + (let* ((layout + (apply + #'ebox-column + (cl-loop for section below 8 + collect + (apply + #'ebox-flex + :flex-flow '(row wrap) + :row-gap 1 + :column-gap '(8) + (cl-loop for index below 4 + collect + (ebox-create + :content (format "S%d-%d" section index) + :width 80 + :height 1)))))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 360)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (let* ((root (ebox--buffer-root-node buffer)) + (dirty (ebox--viewport-dirty-set root)) + (line-signature-count 0) + (original (symbol-function 'ebox--spans-line-signature))) + (should dirty) + (cl-letf (((symbol-function 'ebox--spans-line-signature) + (lambda (&rest args) + (cl-incf line-signature-count) + (apply original args)))) + (ebox--patch-set-from-dirty-set dirty buffer)) + (should (= line-signature-count 0)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-patch-planning-builds-one-shared-line-index () + "Multiple owner rerender planning should share one buffer line index." + (ebox-test--reset-runtime-state) + (let* ((layout + (apply + #'ebox-column + (cl-loop for section below 8 + collect + (apply + #'ebox-flex + :flex-flow '(row wrap) + :row-gap 1 + :column-gap '(8) + (cl-loop for index below 4 + collect + (ebox-create + :content (format "S%d-%d" section index) + :width 80 + :height 1)))))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 360)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (let* ((root (ebox--buffer-root-node buffer)) + (dirty (ebox--viewport-dirty-set root)) + (index-build-count 0) + (original (symbol-function 'ebox--build-region-line-index))) + (should dirty) + (cl-letf (((symbol-function 'ebox--build-region-line-index) + (lambda (&rest args) + (cl-incf index-build-count) + (apply original args)))) + (ebox--patch-set-from-dirty-set dirty buffer)) + (should (= index-build-count 1)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-single-geometry-planning-skips-full-line-index-without-role-table () + "Single geometry updates should not build a full line index after role table invalidation." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id "root" :width (480) + (box :id "target" :content "mixed padding" + :width (220) :height 3 :box-sizing border-box + :padding (0 (8)) :border "#999999") + (box :content "peer" :width (220) :height 3 + :border "#999999")))) + (index-build-count 0)) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#target")) + (original (symbol-function 'ebox--build-region-line-index))) + (ebox--set-buffer-region-role-span-table (current-buffer) nil) + (cl-letf (((symbol-function 'ebox--build-region-line-index) + (lambda (&rest args) + (cl-incf index-build-count) + (apply original args)))) + (ebox-region-update region-id :padding (list 1 '(18) 1 '(18)))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :scoped-index-refresh)) + (should (= index-build-count 0)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-region-set-line-spans-fallback-uses-live-extents () + "Fallback span planning should scan only live extents when indexes are missing." + (ebox-test--reset-runtime-state) + (let* ((before (cl-loop for index below 80 + collect `(box :content ,(format "before %02d" index) + :height 1))) + (after (cl-loop for index below 80 + collect `(box :content ,(format "after %02d" index) + :height 1))) + (layout + (ebox-build + `(column :id "root" :width (480) + ,@before + (box :id "target" :content "target" + :width (220) :height 1 :box-sizing border-box + :padding (0 (8)) :border "#999999") + ,@after))) + (outside-scan-count 0)) + (ebox-test--with-rendered-buffer layout + (let* ((region-id (ebox-test--selector-region-id (current-buffer) + "#target")) + (extents (ebox--live-box-extents region-id)) + range-start + range-end + (original (symbol-function 'ebox--pos-region-ids))) + (should extents) + (save-excursion + (goto-char (marker-position (car extents))) + (setq range-start (line-beginning-position)) + (goto-char (max (point-min) + (1- (marker-position (cdr extents))))) + (setq range-end (line-end-position))) + (ebox--set-buffer-region-role-span-table (current-buffer) nil) + (cl-letf (((symbol-function 'ebox--pos-region-ids) + (lambda (pos) + (unless (and (>= pos range-start) + (< pos range-end)) + (cl-incf outside-scan-count)) + (funcall original pos)))) + (ebox--region-set-line-spans (list region-id))) + (should (= outside-scan-count 0)))))) + +(ert-deftest ebox-box-extents-register-from-property-segments () + "Box extent registration should avoid per-character region-id scans." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Segment extent" + :padding '(0 (8)) + :border t)) + (region-id (car (ebox-region-ids box)))) + (with-temp-buffer + (insert (ebox-render box)) + (let ((ebox--box-extents-segment-scan-min-chars 0)) + (cl-letf (((symbol-function 'ebox--pos-region-ids) + (lambda (&rest _args) + (error "unexpected per-character region scan")))) + (ebox--register-box-extents-in-range (point-min) (point-max)))) + (should (ebox--live-box-extents region-id))))) + +(ert-deftest ebox-box-extents-small-range-uses-position-scan () + "Small extent refreshes should avoid segment scan overhead." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Small extent" + :border t)) + (region-id (car (ebox-region-ids box)))) + (with-temp-buffer + (insert (ebox-render box)) + (let ((ebox--box-extents-segment-scan-min-chars most-positive-fixnum)) + (cl-letf (((symbol-function 'ebox--region-property-segments-in-range) + (lambda (&rest _args) + (error "unexpected segment scan")))) + (ebox--register-box-extents-in-range (point-min) (point-max)))) + (should (ebox--live-box-extents region-id))))) + +(ert-deftest ebox-foreign-region-range-scan-ignores-newline-only-ownership () + "Foreign ownership on separator newlines should not interleave extents." + (with-temp-buffer + (insert (propertize "\n" 'ebox-content 'foreign)) + (should-not + (ebox--foreign-region-in-range-p (point-min) (point-max) 'owner)))) + +(ert-deftest ebox-foreign-region-range-scan-checks-visible-text-after-newline () + "Interval skipping should still detect visible foreign text after newlines." + (with-temp-buffer + (insert (propertize "\nvisible" 'ebox-content 'foreign)) + (should + (ebox--foreign-region-in-range-p (point-min) (point-max) 'owner)))) + +(ert-deftest ebox-scoped-box-extent-refresh-preserves-ancestor-extents () + "Scoped index refresh should not shrink an ancestor to its child span." + (ebox-test--reset-runtime-state) + (with-temp-buffer + (let ((root-id 'extent-root) + (child-id 'extent-child) + child-span) + (insert (propertize "root-before\n" + 'ebox-content-owners (list root-id))) + (let ((start (point))) + (insert (propertize "child\n" + 'ebox-content child-id + 'ebox-content-owners + (list child-id root-id))) + (setq child-span (cons start (point)))) + (insert (propertize "root-after\n" + 'ebox-content-owners (list root-id))) + ;; Exercise both the short position scan and the long-range segment + ;; scan. Both still inspect every owner for interleaving; the allow-set + ;; restricts only which extent records may be published. + (dolist (threshold (list most-positive-fixnum 0)) + (let ((ebox--box-extents-segment-scan-min-chars threshold)) + (ebox--clear-buffer-extents (current-buffer)) + (ebox--set-box-extents root-id (point-min) (point-max)) + (ebox--set-box-extents child-id + (car child-span) (cdr child-span)) + (let* ((root-extents (gethash root-id ebox--box-extents)) + (root-span + (cons (marker-position (car root-extents)) + (marker-position (cdr root-extents))))) + (ebox--refresh-box-extents-for-region-ids-in-spans + (list child-id) (list child-span)) + (should (eq root-extents + (gethash root-id ebox--box-extents))) + (should (equal root-span + (cons + (marker-position (car root-extents)) + (marker-position (cdr root-extents))))) + (let ((refreshed-child + (gethash child-id ebox--box-extents))) + (should refreshed-child) + (should (equal child-span + (cons + (marker-position (car refreshed-child)) + (marker-position + (cdr refreshed-child)))))))))))) + +(ert-deftest ebox-scoped-role-refresh-records-wrapper-only-active-segment () + "Rendered scoped refresh should rebuild visible wrapper-only extents." + (ebox-test--reset-runtime-state) + (with-temp-buffer + (let* ((wrapper-id 'wrapper-only) + (rendered + (propertize "owned" 'ebox-content-owners (list wrapper-id)))) + (insert rendered) + (let ((spans (list (cons (point-min) (point-max))))) + (puthash (current-buffer) + (list :region-role-span-table + (make-hash-table :test 'equal)) + ebox--buffer-render-state-table) + (let* ((refresh + (ebox-buffer-refresh-region-role-spans-for-region-ids + (current-buffer) (list wrapper-id) spans nil + (list (cons rendered spans)))) + (active-segments (plist-get refresh :active-segments)) + (segments (gethash wrapper-id active-segments))) + (should segments) + (should + (equal (sort (copy-sequence segments) + (lambda (left right) (< (car left) (car right)))) + spans)) + (ebox--refresh-box-extents-from-role-spans + (list wrapper-id) spans active-segments) + (let ((extents (gethash wrapper-id ebox--box-extents))) + (should extents) + (should + (equal (cons (marker-position (car extents)) + (marker-position (cdr extents))) + (car spans))))))))) + +(ert-deftest + ebox-scoped-role-refresh-keeps-wrapper-only-interleaved-omitted () + "Active wrapper-only segments should still omit interleaved extents." + (ebox-test--reset-runtime-state) + (with-temp-buffer + (let* ((wrapper-id 'interleaved-wrapper) + (foreign-id 'foreign-child) + (rendered + (concat + (propertize "aa" 'ebox-content-owners (list wrapper-id)) + (propertize "X" 'ebox-content foreign-id) + (propertize "bb" 'ebox-content-owners (list wrapper-id))))) + (insert rendered) + (let ((spans (list (cons (point-min) (point-max))))) + (puthash (current-buffer) + (list :region-role-span-table + (make-hash-table :test 'equal)) + ebox--buffer-render-state-table) + (let* ((refresh + (ebox-buffer-refresh-region-role-spans-for-region-ids + (current-buffer) (list wrapper-id) spans nil + (list (cons rendered spans)))) + (active-segments (plist-get refresh :active-segments))) + (should (= (length (gethash wrapper-id active-segments)) 2)) + (ebox--refresh-box-extents-from-role-spans + (list wrapper-id) spans active-segments) + (should-not (gethash wrapper-id ebox--box-extents))))))) + +(ert-deftest ebox-scoped-runtime-index-refresh-clears-empty-active-extents () + "Scoped refresh should clear broad ids even when no active segment remains." + (ebox-test--reset-runtime-state) + (with-temp-buffer + (insert "stale") + (let ((stale-id 'stale-owner) + (active-segments (make-hash-table :test 'equal))) + (puthash (current-buffer) + (list :region-role-span-table + (make-hash-table :test 'equal)) + ebox--buffer-render-state-table) + (ebox--set-box-extents stale-id (point-min) (point-max)) + (cl-letf (((symbol-function + 'ebox-buffer-refresh-region-role-spans-for-region-ids) + (lambda (&rest _args) + (list :active-segments active-segments)))) + (ebox--refresh-runtime-indexes-for-patch-results + (current-buffer) nil 'span-patch nil + (list :region-ids (list stale-id) + :new-spans (list (cons (point-min) (point-max))) + :old-spans nil + :removed-region-ids nil))) + (should-not (gethash stale-id ebox--box-extents))))) + +(ert-deftest ebox-scoped-role-span-refresh-rebuilds-on-translation-failure () + "Ambiguous scoped refresh should rebuild a legacy integer role-span table." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id "root" :width (480) + (box :id "target" :content "target" :width (220) + :height 1 :border "#999999") + (box :id "peer" :content "peer" :width (220) + :height 1 :border "#999999"))))) + (ebox-test--with-rendered-buffer layout + (let* ((target-id (ebox-test--selector-region-id (current-buffer) + "#target")) + (peer-id (ebox-test--selector-region-id (current-buffer) + "#peer")) + (target-spans + (gethash (ebox-buffer--region-role-key target-id 'content) + (ebox-buffer--region-role-span-table))) + (peer-key (ebox-buffer--region-role-key peer-id 'content)) + (legacy-table (make-hash-table :test 'equal)) + (rebuild-count 0) + (original (symbol-function + 'ebox-buffer--build-region-role-span-table))) + (should target-spans) + (should (gethash peer-key (ebox-buffer--region-role-span-table))) + (maphash + (lambda (key spans) + (when (consp key) + (puthash key + (mapcar (lambda (span) + (cons (marker-position (car span)) + (marker-position (cdr span)))) + spans) + legacy-table))) + (ebox-buffer--region-role-span-table)) + (ebox--set-buffer-region-role-span-table + (current-buffer) legacy-table) + (cl-letf (((symbol-function 'ebox-buffer--build-region-role-span-table) + (lambda (&rest args) + (cl-incf rebuild-count) + (apply original args)))) + (ebox-buffer-refresh-region-role-spans-for-region-ids + (current-buffer) + (list target-id) + target-spans + (append target-spans target-spans))) + (should (= rebuild-count 1)) + (should (ebox-buffer--region-role-span-table)) + (should (gethash peer-key (ebox-buffer--region-role-span-table))))))) + +(ert-deftest ebox-role-span-translation-keeps-left-adjacent-half-open-span () + "A role span ending at replacement start stays on its untouched left side." + (let ((replacements + '((:old-start 20 :old-end 30 + :new-start 20 :new-end 24 :delta -6)))) + (should + (equal (ebox-buffer--translate-role-span-through-replacements + '(10 . 20) replacements) + '(10 . 20))) + (should-not + (ebox-buffer--translate-role-span-through-replacements + '(20 . 25) replacements)) + (should-not + (ebox-buffer--translate-role-span-through-replacements + '(15 . 30) replacements)))) + +(ert-deftest ebox-role-span-translation-index-preserves-enclosing-deltas () + "Indexed translation should preserve spans enclosing multiple replacements." + (let ((replacements + '((:old-start 20 :old-end 30 + :new-start 20 :new-end 24 :delta -6) + (:old-start 50 :old-end 55 + :new-start 44 :new-end 57 :delta 8)))) + (should + (equal (ebox-buffer--translate-role-span-through-replacements + '(10 . 70) replacements) + '(10 . 72))) + (should + (equal (ebox-buffer--translate-role-span-through-replacements + '(30 . 50) replacements) + '(24 . 44))) + (should-not + (ebox-buffer--translate-role-span-through-replacements + '(30 . 55) replacements)))) + +(ert-deftest ebox-marker-role-span-refresh-skips-table-translation () + "Marker-backed role-span tables should not translate every stored span." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id "root" :width (480) + (box :id "target" :content "target" :width (220) + :height 1 :border "#999999") + (box :id "peer" :content "peer" :width (220) + :height 1 :border "#999999"))))) + (ebox-test--with-rendered-buffer layout + (let* ((target-id (ebox-test--selector-region-id (current-buffer) + "#target")) + (peer-id (ebox-test--selector-region-id (current-buffer) + "#peer")) + (target-spans + (gethash (ebox-buffer--region-role-key target-id 'content) + (ebox-buffer--region-role-span-table))) + (peer-key (ebox-buffer--region-role-key peer-id 'content)) + (translate-count 0) + (rebuild-count 0)) + (should target-spans) + (should (markerp (caar target-spans))) + (cl-letf (((symbol-function + 'ebox-buffer--translate-region-role-span-table) + (lambda (&rest _args) + (cl-incf translate-count) + (error "marker-backed table should not translate"))) + ((symbol-function 'ebox-buffer--build-region-role-span-table) + (lambda (&rest _args) + (cl-incf rebuild-count) + (error "marker-backed table should not rebuild")))) + (ebox-buffer-refresh-region-role-spans-for-region-ids + (current-buffer) + (list target-id) + target-spans + (append target-spans target-spans))) + (should (= translate-count 0)) + (should (= rebuild-count 0)) + (should (gethash peer-key (ebox-buffer--region-role-span-table))))))) + +(ert-deftest ebox-scoped-role-span-refresh-translates-enclosing-owner-spans () + "Scoped role-span refresh should keep enclosing owner spans without rebuilding." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id "root" :width (480) + (box :id "target" :content "target" :width (220) + :height 1 :border "#999999"))))) + (ebox-test--with-rendered-buffer layout + (let* ((root-id (ebox-test--selector-region-id (current-buffer) + "#root")) + (target-id (ebox-test--selector-region-id (current-buffer) + "#target")) + (target-spans + (gethash (ebox-buffer--region-role-key target-id 'content) + (ebox-buffer--region-role-span-table))) + (root-owner-key + (ebox-buffer--region-role-key root-id 'content-owner)) + (root-owner-spans + (gethash root-owner-key + (ebox-buffer--region-role-span-table))) + (rebuild-count 0) + (original (symbol-function + 'ebox-buffer--build-region-role-span-table))) + (should target-spans) + (should root-owner-spans) + (cl-letf (((symbol-function 'ebox-buffer--build-region-role-span-table) + (lambda (&rest args) + (cl-incf rebuild-count) + (apply original args)))) + (ebox-buffer-refresh-region-role-spans-for-region-ids + (current-buffer) + (list target-id) + target-spans + target-spans)) + (should (= rebuild-count 0)) + (should (equal root-owner-spans + (gethash root-owner-key + (ebox-buffer--region-role-span-table)))))))) + +(ert-deftest ebox-viewport-root-owner-spans-skip-region-scan () + "Root owner spans should use full buffer lines, not region-property scans." + (ebox-test--reset-runtime-state) + (let* ((layout + (apply + #'ebox-row + (cl-loop for section below 8 + collect + (apply + #'ebox-flex + :flex-flow '(row wrap) + :row-gap 1 + :column-gap '(8) + (cl-loop for index below 4 + collect + (ebox-create + :content (format "S%d-%d" section index) + :width 80 + :height 1)))))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 360)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (let* ((root (ebox--buffer-root-node buffer)) + (dirty (ebox--viewport-dirty-set root)) + (region-scan-count 0) + (original (symbol-function 'ebox--region-set-line-spans))) + (should dirty) + (cl-letf (((symbol-function 'ebox--region-set-line-spans) + (lambda (&rest args) + (cl-incf region-scan-count) + (apply original args)))) + (ebox--patch-set-from-dirty-set dirty buffer)) + (should (= region-scan-count 0)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-rerender-reuses-static-flex-item-renders () + "Static flex item renders should be reused across viewport rerenders." + (ebox-test--reset-runtime-state) + (let* ((layout + (apply + #'ebox-flex + :flex-wrap 'wrap + (cl-loop for index below 12 + collect + (ebox-create + :content (format "Item %02d" index) + :width 80 + :height 1)))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 420)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (ebox-rerender-buffer-with-context buffer 360) + (let ((box-render-count 0) + (original (symbol-function 'ebox--render-box))) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (&rest args) + (cl-incf box-render-count) + (apply original args)))) + (ebox-rerender-buffer-with-context buffer 500)) + (should (<= box-render-count 3)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-rerender-reuses-sized-flex-item-renders () + "Flex item renders with stable computed sizes should be reused." + (ebox-test--reset-runtime-state) + (let* ((layout + (apply + #'ebox-flex + :flex-wrap 'wrap + :height 4 + :align-items 'stretch + (cl-loop for index below 10 + collect + (ebox-create + :content (format "Stretch %02d" index) + :width 80)))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 420)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (ebox-rerender-buffer-with-context buffer 360) + (let ((box-render-count 0) + (original (symbol-function 'ebox--render-box))) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (&rest args) + (cl-incf box-render-count) + (apply original args)))) + (ebox-rerender-buffer-with-context buffer 500)) + (should (<= box-render-count 6)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-rerender-reuses-flex-item-measurements () + "Stable flex item intrinsic measurements should be reused across resizes." + (ebox-test--reset-runtime-state) + (let* ((layout + (apply + #'ebox-flex + :flex-wrap 'wrap + (cl-loop for index below 12 + collect + (ebox-create + :content (format "Measured %02d" index) + :width 80 + :height 1)))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 420)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (ebox-rerender-buffer-with-context buffer 360) + (let ((main-count 0) + (min-count 0) + (original-main (symbol-function 'ebox--flex-string-main)) + (original-min (symbol-function 'ebox--content-min-pixel))) + (cl-letf (((symbol-function 'ebox--flex-string-main) + (lambda (&rest args) + (cl-incf main-count) + (apply original-main args))) + ((symbol-function 'ebox--content-min-pixel) + (lambda (&rest args) + (cl-incf min-count) + (apply original-min args)))) + (ebox-rerender-buffer-with-context buffer 500)) + (should (<= main-count 3)) + (should (<= min-count 3)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-rerender-reuses-static-composite-subtrees () + "Viewport-dependent flex sections should reuse static composite item sources." + (ebox-test--reset-runtime-state) + (let* ((inner-flexes + (cl-loop for index below 6 + collect + (ebox-flex + :width 180 :height 3 + :flex-flow '(row wrap) + :column-gap '(8) + (ebox-create + :content (format "A%d" index) + :width 48 :height 1) + (ebox-create + :content (format "B%d" index) + :width 48 :height 1)))) + (layout + (apply + #'ebox-flex + :flex-flow '(row wrap) + :row-gap 1 + :column-gap '(8) + (mapcar (lambda (inner) + (ebox-column + (ebox-create :content "Static card" + :width 180 + :height 1) + inner)) + inner-flexes))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 320)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (ebox-rerender-buffer-with-context buffer 360) + (let ((static-flex-renders 0) + (original (symbol-function 'ebox--render-flex))) + (cl-letf (((symbol-function 'ebox--render-flex) + (lambda (node) + (when (memq node inner-flexes) + (cl-incf static-flex-renders)) + (funcall original node)))) + (ebox-rerender-buffer-with-context buffer 420)) + (should (= static-flex-renders 0)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-flex-window-reuses-cached-composite-measurements () + "Flex prefix layout should reuse warm measurements for fixed composites." + (ebox-test--reset-runtime-state) + (let* ((layout + (apply + #'ebox-flex + :flex-flow '(row wrap) + :column-gap '(8) + (cl-loop for index below 6 + collect + (ebox-column + (ebox-create :content (format "Card %d" index) + :width 96 :height 1) + (ebox-create :content "Body" :width 96 :height 2))))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 320)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") layout))) + (let* ((root (ebox--buffer-root-node buffer)) + (child (car (plist-get root :children)))) + (ebox--with-buffer-render-context buffer + (should (ebox--flex-window-cheap-item child 'row 0))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-render-cacheability-uses-boolean-viewport-dependency-cache () + "Render cacheability should not enumerate viewport-dependent node ids." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-column + (ebox-create :content "Static" :width 120) + (ebox-create :content "Also static" :width 120))) + (calls 0) + (original (symbol-function 'ebox--viewport-dependent-node-ids))) + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--viewport-dependent-subtree-cache + (make-hash-table :test 'eq))) + (cl-letf (((symbol-function 'ebox--viewport-dependent-node-ids) + (lambda (&rest args) + (cl-incf calls) + (apply original args)))) + (should (ebox--render-cacheable-node-p layout)) + (should (ebox--render-cacheable-node-p layout))) + (should (= calls 0))))) + +(ert-deftest ebox-render-cacheability-rejects-viewport-dependent-wrapper-child () + "Wrapper boxes with viewport-dependent child layout should not reuse static render cache." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-build + '(column :padding 1 + (box :content "Auto child follows viewport" + :wrap-mode word))))) + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--viewport-dependent-subtree-cache + (make-hash-table :test 'eq))) + (should-not (ebox--render-cacheable-node-p node))))) + +(ert-deftest ebox-render-cacheability-allows-definite-width-wrapper-child () + "Definite-width boxes bound descendant viewport-dependent layout." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-build + '(column :width (210) :padding 1 + (box :content "Child wraps inside the card" + :wrap-mode word))))) + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--viewport-dependent-subtree-cache + (make-hash-table :test 'eq))) + (should (ebox--viewport-dependent-subtree-p node)) + (should (ebox--render-cacheable-node-p node))))) + +(ert-deftest ebox-viewport-rerender-reuses-buffer-viewport-dependencies () + "Viewport rerenders should reuse the buffer's viewport dependency list." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-flex :flex-wrap 'wrap + (ebox-create :content "A" :width '(120)) + (ebox-create :content "B" :width '(120)))) + (calls 0) + (axis-scans 0) + (original (symbol-function 'ebox--viewport-dependent-node-ids)) + (original-axes + (symbol-function 'ebox--viewport-dependent-node-id-axes)) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (cl-letf (((symbol-function 'ebox--viewport-dependent-node-id-axes) + (lambda (&rest args) + (cl-incf axis-scans) + (apply original-axes args)))) + (ebox-rerender-buffer-with-context buffer 220)) + (should (= axis-scans 0)) + (cl-letf (((symbol-function 'ebox--viewport-dependent-node-ids) + (lambda (&rest args) + (cl-incf calls) + (apply original args)))) + (ebox-rerender-buffer-with-context buffer 200)) + (should (= calls 0))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-region-width-update-invalidates-viewport-dependencies () + "Width updates can change which boxes participate in viewport resize." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Resizable" :width 80)) + (box-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-rerender-buffer-with-context (current-buffer) 260) + (ebox-region-update box-id :width '(viewport)) + (ebox-rerender-buffer-with-context (current-buffer) 200) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :constraint-source) 'viewport)) + (should (= (plist-get report :dirty-count) 1)))))) + +(ert-deftest ebox-viewport-dependencies-include-wrapper-child-layout () + "Viewport dependency discovery should see lazy child layout under wrapper boxes." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-build + '(column :padding 1 + (box :content "Auto child follows the preview width" + :wrap-mode word)))) + (child (car (ebox--node-children node))) + (ids (let ((ebox-viewport-width 360)) + (ebox--runtime-node-ids node) + (ebox--viewport-dependent-node-ids node)))) + (should ids) + (should (member (plist-get node :node-id) ids)) + (should (member (plist-get child :node-id) ids)))) + +(ert-deftest ebox-viewport-dependencies-stop-at-definite-width-wrapper () + "Definite wrappers should contain descendant viewport dependencies." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-build + '(column :width (210) :padding 1 + (box :content "Auto child wraps inside the fixed card" + :wrap-mode word)))) + (child (car (ebox--node-children node))) + (ids (let ((ebox-viewport-width 360)) + (ebox--runtime-node-ids node) + (ebox--viewport-dependent-node-ids node)))) + (should-not (member (plist-get node :node-id) ids)) + (should-not (member (plist-get child :node-id) ids)))) + +(ert-deftest ebox-viewport-height-dependency-crosses-definite-width-wrapper () + "Definite width should not contain a descendant viewport-height dependency." + (ebox-test--reset-runtime-state) + (let* ((viewport-width 120) + (node (let ((ebox-viewport-width viewport-width) + (ebox-viewport-height 2)) + (ebox-build + '(column :width (120) + (box :id child + :content "One\nTwo\nThree\nFour" + :height (viewport-height) + :overflow scroll))))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width viewport-width) + (ebox-viewport-height 2)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + node))) + (with-current-buffer buffer + (should (= (length (ebox-string-lines + (buffer-substring-no-properties + (point-min) (point-max)))) + 2))) + (let ((report + (ebox-rerender-buffer-with-context + buffer viewport-width 4))) + (should-not (eq (plist-get report :strategy) 'no-op))) + (with-current-buffer buffer + (should (= (length (ebox-string-lines + (buffer-substring-no-properties + (point-min) (point-max)))) + 4)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-dependency-axes-classify-both-dimensions () + "A node may depend on width, height, or both viewport axes." + (ebox-test--reset-runtime-state) + (let* ((width-only (ebox-create :content "Width" :width '(viewport))) + (height-only (ebox-create :content "Height" + :width '(120) + :height '(viewport-height))) + (both (ebox-create :content "Both" + :width '(viewport) + :height '(viewport-height))) + (layout (ebox-column width-only height-only both))) + (let ((ebox-viewport-width 360) + (ebox-viewport-height 12) + (ebox--viewport-dependent-node-ids-cache + (make-hash-table :test 'eq)) + (ebox--viewport-height-dependent-subtree-cache + (make-hash-table :test 'eq))) + (ebox--runtime-node-ids layout) + (pcase-let ((`(,width-ids . ,height-ids) + (ebox--viewport-dependent-node-id-axes layout))) + (should (member (plist-get width-only :node-id) width-ids)) + (should-not (member (plist-get width-only :node-id) height-ids)) + (should-not (member (plist-get height-only :node-id) width-ids)) + (should (member (plist-get height-only :node-id) height-ids)) + (should (member (plist-get both :node-id) width-ids)) + (should (member (plist-get both :node-id) height-ids)))))) + +(ert-deftest ebox-viewport-width-rerender-skips-height-only-root () + "Width-only viewport rerender should not dirty height-only roots." + (ebox-test--reset-runtime-state) + (let* ((node (let ((ebox-viewport-height 4)) + (ebox-build + '(column :width (120) :height (viewport-height) + (box :content "Static" :width (120)))))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + node))) + (let ((report (ebox-rerender-buffer-with-context buffer 300))) + (should (eq (plist-get report :strategy) 'no-op)) + (should (= (plist-get report :dirty-count) 0)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-dependencies-keep-only-vertical-axis-under-fixed-width () + "Fixed width should retain vertical dependencies without exposing width-only ones." + (ebox-test--reset-runtime-state) + (let* ((width-only + (ebox-create :content "Width" :width '(viewport))) + (ordinary-height-nodes + (cl-loop for (property value) + in '((:height (viewport-height)) + (:min-height (- (viewport-height) 1)) + (:max-height (+ (viewport-height) 1))) + collect + (apply #'ebox-create + :content "Ordinary" + (list property value)))) + (flex-height-nodes + (cl-loop for (property value) + in '((:height (viewport-height)) + (:min-height (- (viewport-height) 1)) + (:max-height (+ (viewport-height) 1))) + collect + (apply #'ebox-flex + (append + (list property value) + (list (ebox-create :content "Flex")))))) + (layout + (apply #'ebox-column + (append (list width-only) + ordinary-height-nodes + flex-height-nodes))) + (node (ebox-create :width '(210) + :wrap-mode nil + :ebox-content-node layout)) + (ids (let ((ebox-viewport-width 360) + (ebox-viewport-height 12)) + (ebox--runtime-node-ids node) + (ebox--viewport-dependent-node-ids node)))) + (should-not (member (plist-get width-only :node-id) ids)) + (dolist (vertical-node (append ordinary-height-nodes flex-height-nodes)) + (should (member (plist-get vertical-node :node-id) ids))))) + +(ert-deftest ebox-viewport-dependencies-include-direct-flex-basis () + "Viewport dependency discovery should include direct child flex-basis props." + (ebox-test--reset-runtime-state) + (let* ((child (ebox-create :content "A" :width 80 + :flex-basis '(viewport))) + (node (ebox-flex :width 300 + child + (ebox-create :content "B" :width 80))) + (ids (let ((ebox-viewport-width 360)) + (ebox--runtime-node-ids node) + (ebox--viewport-dependent-node-ids node)))) + (should (member (plist-get child :node-id) ids)))) + +(ert-deftest ebox-viewport-dependencies-ignore-flex-item-wrapper-auto-width () + "Flex item wrappers should not be dirty only because their own width is auto." + (ebox-test--reset-runtime-state) + (let* ((child (ebox-create :content "A" :width 80)) + (item (ebox-flex-item child)) + (node (ebox-flex :width 240 item)) + (ids (let ((ebox-viewport-width 360)) + (ebox--runtime-node-ids node) + (ebox--viewport-dependent-node-ids node)))) + (should-not (member (plist-get item :node-id) ids)) + (should-not (member (plist-get child :node-id) ids)))) + +(ert-deftest ebox-viewport-dependencies-include-flex-item-viewport-basis () + "Flex item wrappers should stay dirty when their flex-basis uses viewport." + (ebox-test--reset-runtime-state) + (let* ((child (ebox-create :content "A" :width 80)) + (item (ebox-flex-item child :flex-basis '(viewport))) + (node (ebox-flex :width 240 item)) + (ids (let ((ebox-viewport-width 360)) + (ebox--runtime-node-ids node) + (ebox--viewport-dependent-node-ids node)))) + (should (member (plist-get item :node-id) ids)))) + +(ert-deftest ebox-buffer-viewport-dependency-cache-invalidates-after-display-style-change () + "Changing width/display-sensitive style should invalidate viewport dependency cache." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Resizable" :width 80)) + (box-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-rerender-buffer-with-context (current-buffer) 320) + (ebox-region-update box-id :width '(viewport)) + (let ((state (ebox--buffer-render-state (current-buffer)))) + (should-not (plist-get state :viewport-dependent-node-ids-ready))) + (ebox-rerender-buffer-with-context (current-buffer) 240) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :constraint-source) 'viewport)) + (should (= (plist-get report :dirty-count) 1)))))) + +(ert-deftest ebox-initial-render-primes-static-composite-subtree-cache () + "The first viewport resize should reuse cache populated by initial render." + (ebox-test--reset-runtime-state) + (let* ((inner-flexes + (cl-loop for index below 6 + collect + (ebox-flex + :width 180 :height 3 + :flex-flow '(row wrap) + :column-gap '(8) + (ebox-create + :content (format "A%d" index) + :width 48 :height 1) + (ebox-create + :content (format "B%d" index) + :width 48 :height 1)))) + (layout + (apply + #'ebox-flex + :flex-flow '(row wrap) + :row-gap 1 + :column-gap '(8) + (mapcar (lambda (inner) + (ebox-column + (ebox-create :content "Static card" + :width 180 + :height 1) + inner)) + inner-flexes))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 320)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (let ((static-flex-renders 0) + (original (symbol-function 'ebox--render-flex))) + (cl-letf (((symbol-function 'ebox--render-flex) + (lambda (node) + (when (memq node inner-flexes) + (cl-incf static-flex-renders)) + (funcall original node)))) + (ebox-rerender-buffer-with-context buffer 420)) + (should (= static-flex-renders 0)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-resize-invalidates-snapshot-details-lazily () + "Viewport resize should not recapture every lightweight snapshot eagerly." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(column :width (viewport) + ,@(cl-loop for idx below 24 + collect + `(box :id ,(format "resize-snapshot-%02d" idx) + :content ,(format "row %02d" idx) + :width (viewport) + :height 1))))) + (node-count (length (ebox--runtime-node-ids layout))) + buffer + (snapshot-count 0) + (original (symbol-function 'ebox--node-layout-snapshot))) + (unwind-protect + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (cl-letf (((symbol-function 'ebox--node-layout-snapshot) + (lambda (&rest args) + (cl-incf snapshot-count) + (apply original args)))) + (ebox-rerender-buffer-with-context buffer 320)) + (should (< snapshot-count node-count))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-resize-invalidates-extents-lazily () + "Viewport resize should not rescan extents before a later update needs them." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(column :width (viewport) + ,@(cl-loop for idx below 24 + collect + `(box :id ,(format "resize-extents-%02d" idx) + :content ,(format "row %02d" idx) + :width (viewport) + :height 1))))) + buffer + target-id + (register-count 0) + (original-register + (symbol-function 'ebox--register-box-extents-in-range))) + (unwind-protect + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq target-id + (ebox-test--selector-region-id + buffer "#resize-extents-12")) + (cl-letf (((symbol-function + 'ebox--register-box-extents-in-range) + (lambda (&rest args) + (cl-incf register-count) + (apply original-register args)))) + (ebox-rerender-buffer-with-context buffer 320)) + (should (= register-count 0)) + (with-current-buffer buffer + (ebox-region-update target-id :content "updated after resize") + (should (string-match-p "updated after resize" + (buffer-substring-no-properties + (point-min) (point-max)))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-dependent-node-ids-are-render-pass-memoized () + "Viewport dependency discovery should not recurse twice for the same node." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-flex + :width 240 + (ebox-column + (ebox-create :content "A" :width 80) + (ebox-create :content "B" :width 80)))) + (node-count (length (ebox--runtime-node-ids layout))) + (width-check-count 0) + (height-check-count 0) + (original-width + (symbol-function 'ebox--node-direct-viewport-width-dependent-p)) + (original-height + (symbol-function 'ebox--node-direct-viewport-height-dependent-p)) + (ebox--viewport-dependent-node-ids-cache + (make-hash-table :test 'eq))) + (cl-letf (((symbol-function + 'ebox--node-direct-viewport-width-dependent-p) + (lambda (&rest args) + (cl-incf width-check-count) + (apply original-width args))) + ((symbol-function + 'ebox--node-direct-viewport-height-dependent-p) + (lambda (&rest args) + (cl-incf height-check-count) + (apply original-height args)))) + (ebox--viewport-dependent-node-ids layout) + (ebox--viewport-dependent-node-ids layout)) + (should (= width-check-count node-count)) + (should (= height-check-count node-count)))) + +(ert-deftest ebox-render-cache-node-signature-is-render-pass-memoized () + "Render cache signatures should not recurse twice for the same node." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-flex + :width 240 + (ebox-column + (ebox-create :content "A" :width 80) + (ebox-create :content "B" :width 80)))) + (node-count (length (ebox--runtime-node-ids layout))) + (signature-count 0) + (original (symbol-function 'ebox--render-cache-plist-signature)) + (ebox--render-cache-signature-cache + (make-hash-table :test 'eq))) + (cl-letf (((symbol-function 'ebox--render-cache-plist-signature) + (lambda (&rest args) + (cl-incf signature-count) + (apply original args)))) + (ebox--render-cache-node-signature layout) + (ebox--render-cache-node-signature layout)) + (should (= signature-count node-count)))) + +(ert-deftest ebox-render-cache-value-signature-preserves-dotted-pairs () + "Native values such as sparse keymaps may contain dotted pairs." + (let ((value '(keymap (mouse-1 . ebox-test-activate) + (13 . ebox-test-activate)))) + (should + (equal (ebox--render-cache-value-signature value) + value)))) + +(ert-deftest ebox-render-cache-signature-includes-rendered-region-identity () + "Cached render strings should never cross distinct region identities." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "Stable" :width 80)) + (right (copy-tree left))) + (plist-put left :node-id 1) + (plist-put right :node-id 1) + (plist-put left :region-id 11) + (plist-put right :region-id 12) + (should-not + (equal (ebox--render-cache-node-signature left) + (ebox--render-cache-node-signature right))))) + +(ert-deftest ebox-render-transaction-snapshots-display-signature () + "One render transaction should sample its display signature once." + (ebox-test--reset-runtime-state) + (let ((node (ebox-create :content "A" :width 80)) + (calls 0)) + (cl-letf (((symbol-function 'ebox--display-signature) + (lambda () + (list 'display (cl-incf calls))))) + (ebox--with-validated-display-cache + (ebox--render-cache-node-signature node) + (ebox--render-cache-node-signature node))) + (should (= calls 1)))) + +(ert-deftest ebox-render-transaction-nesting-reuses-display-signature () + "A nested render transaction should reuse the outer display snapshot." + (ebox-test--reset-runtime-state) + (let ((node (ebox-create :content "A" :width 80)) + (calls 0)) + (cl-letf (((symbol-function 'ebox--display-signature) + (lambda () + (list 'display (cl-incf calls))))) + (ebox--with-validated-display-cache + (ebox--with-validated-display-cache + (ebox--render-cache-node-signature node)))) + (should (= calls 1)))) + +(ert-deftest ebox-render-transactions-refresh-display-signature () + "Separate render transactions should sample display state independently." + (ebox-test--reset-runtime-state) + (let ((node (ebox-create :content "A" :width 80)) + (calls 0) + signatures) + (cl-letf (((symbol-function 'ebox--display-signature) + (lambda () + (list 'display (cl-incf calls))))) + (dotimes (_ 2) + (push + (ebox--with-validated-display-cache + (car (ebox--render-cache-node-signature node))) + signatures))) + (should (= calls 2)) + (should-not (equal (car signatures) (cadr signatures))))) + +(ert-deftest ebox-current-display-signature-is-live-outside-transaction () + "The internal accessor should sample live state outside a transaction." + (ebox-test--reset-runtime-state) + (let ((calls 0) + signatures) + (cl-letf (((symbol-function 'ebox--display-signature) + (lambda () + (list 'display (cl-incf calls))))) + (setq signatures + (list (ebox--current-display-signature) + (ebox--current-display-signature)))) + (should (= calls 2)) + (should-not (equal (car signatures) (cadr signatures))))) + +(ert-deftest ebox-public-display-signature-stays-live-in-render-transaction () + "The public display signature should bypass a render transaction snapshot." + (ebox-test--reset-runtime-state) + (let ((calls 0) + signatures) + (cl-letf (((symbol-function 'ebox--display-signature) + (lambda () + (list 'display (cl-incf calls))))) + (setq signatures + (ebox--with-validated-display-cache + (list (ebox-display-signature) + (ebox-display-signature))))) + (should (= calls 3)) + (should-not (equal (car signatures) (cadr signatures))))) + +(ert-deftest ebox-clear-cache-samples-live-display-in-render-transaction () + "Clearing caches should store live display state inside a transaction." + (ebox-test--reset-runtime-state) + (let ((calls 0)) + (puthash '(stale) 1 ebox--string-pixel-width-cache) + (aset ebox--string-pixel-width-cache-ring 0 '(stale)) + (setq ebox--string-pixel-width-cache-ring-index 1) + (cl-letf (((symbol-function 'ebox--display-signature) + (lambda () + (list 'display (cl-incf calls))))) + (ebox--with-validated-display-cache + (ebox-clear-cache))) + (should (= calls 2)) + (should (= (hash-table-count ebox--string-pixel-width-cache) 0)) + (should (= ebox--string-pixel-width-cache-ring-index 0)) + (should (equal ebox--display-signature-cache '(display 2))))) + +(ert-deftest ebox-render-cache-signature-keeps-viewport-axes-with-snapshot () + "Display snapshots should not collapse width or height cache dimensions." + (ebox-test--reset-runtime-state) + (let ((node (ebox-create :content "A" + :width '(viewport) + :height '(viewport-height))) + signatures) + (cl-letf (((symbol-function 'ebox--display-signature) + (lambda () '(display stable)))) + (ebox--with-validated-display-cache + (dolist (viewport '((80 10) (90 10) (90 11))) + (let ((ebox-viewport-width (car viewport)) + (ebox-viewport-height (cadr viewport))) + (push (ebox--render-cache-node-signature node) + signatures))))) + (setq signatures (nreverse signatures)) + (should (equal (mapcar #'car signatures) + '((display stable) (display stable) (display stable)))) + (should-not (equal (nth 0 signatures) (nth 1 signatures))) + (should-not (equal (nth 1 signatures) (nth 2 signatures))))) + +(ert-deftest ebox-render-cache-signature-ignores-unrelated-viewport-height () + "Width-only subtree signatures should survive unrelated height changes." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-column + (ebox-create :content "A" :width '(viewport)) + (ebox-create :content "B" :width 80))) + (left (let ((ebox-viewport-width 320) + (ebox-viewport-height 10)) + (ebox--render-cache-node-signature node))) + (right (let ((ebox-viewport-width 320) + (ebox-viewport-height 11)) + (ebox--render-cache-node-signature node)))) + (should (equal left right)))) + +(ert-deftest ebox-buffer-height-dependency-cache-invalidates-mutated-path () + "Persistent height dependencies should survive siblings and track mutations." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) + (box :id "child" :content "child" :width (80))))) + buffer) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") layout)) + (let* ((state (ebox--buffer-render-state buffer)) + (cache + (plist-get state + :viewport-height-dependent-subtree-cache)) + (child-id + (plist-get + (car (ebox-selector-query-buffer buffer "#child")) + :node-id)) + (child (ebox--buffer-runtime-node buffer child-id)) + (missing (make-symbol "missing"))) + (ebox--with-buffer-render-context buffer + (should-not (ebox--viewport-height-dependent-subtree-p child))) + (should-not (eq (gethash child cache missing) missing)) + (ebox-selector-update-buffer buffer "#root" :width '(260)) + (should-not (eq (gethash child cache missing) missing)) + (ebox-selector-update-buffer + buffer "#child" :height '(viewport-height)) + (should (eq (gethash child cache missing) missing)) + (let ((low (let ((ebox-viewport-height 4)) + (ebox--render-cache-node-signature child))) + (high (let ((ebox-viewport-height 5)) + (ebox--render-cache-node-signature child)))) + (should-not (equal low high))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-buffer-render-signatures-persist-across-transactions () + "Unchanged runtime body signatures should survive buffer render transactions." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-flex + :width 240 + (ebox-column + (ebox-create :content "A" :width 80) + (ebox-create :content "B" :width 80)))) + (node-count (length (ebox--runtime-node-ids layout))) + (signature-count 0) + (original (symbol-function 'ebox--render-cache-plist-signature)) + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (when-let ((cache + (plist-get (ebox--buffer-render-state buffer) + :render-signature-cache))) + (clrhash cache)) + (cl-letf (((symbol-function 'ebox--render-cache-plist-signature) + (lambda (&rest args) + (cl-incf signature-count) + (apply original args)))) + (ebox--with-buffer-render-context buffer + (ebox--render-cache-node-body-signature layout)) + (ebox--with-buffer-render-context buffer + (ebox--render-cache-node-body-signature layout))) + (should (= signature-count node-count))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-buffer-render-signature-invalidates-mutated-path () + "Runtime mutation should not reuse a stale ancestor body signature." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "Before" :width 80)) + (right (ebox-create :content "Stable" :width 80)) + (layout (ebox-column left right)) + (left-region-id (car (ebox-region-ids left))) + old-root-signature + new-root-signature + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (let ((runtime-root (ebox--buffer-root-node buffer))) + (setq old-root-signature + (ebox--with-buffer-render-context buffer + (ebox--render-cache-node-body-signature runtime-root)))) + (with-current-buffer buffer + (ebox-region-update left-region-id :content "After") + (should (equal (ebox-get left :content) "Before")) + (setq new-root-signature + (ebox--with-buffer-render-context buffer + (ebox--render-cache-node-body-signature + (ebox--buffer-root-node buffer)))) + (should-not (equal old-root-signature new-root-signature)) + (should + (string-match-p + "After" + (buffer-substring-no-properties (point-min) (point-max)))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-buffer-render-signature-invalidation-retains-sibling () + "Invalidating one runtime path should retain unaffected sibling signatures." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "Left" :width 80)) + (right (ebox-create :content "Right" :width 80)) + (layout (ebox-column left right)) + (left-region-id (car (ebox-region-ids left))) + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (let* ((root-id (ebox--ensure-node-id layout)) + (left-node-id (ebox--ensure-node-id left)) + (right-node-id (ebox--ensure-node-id right)) + (runtime-root (ebox--buffer-runtime-node buffer root-id)) + (runtime-left (ebox--buffer-runtime-node buffer left-node-id)) + (runtime-right (ebox--buffer-runtime-node buffer right-node-id))) + (ebox--with-buffer-render-context buffer + (ebox--render-cache-node-body-signature runtime-root)) + (let* ((cache (ebox--buffer-render-signature-cache buffer)) + (root-signature (gethash runtime-root cache)) + (left-signature (gethash runtime-left cache)) + (right-signature (gethash runtime-right cache))) + (with-current-buffer buffer + (ebox-region-update left-region-id :content "Changed")) + (should (equal (ebox-get left :content) "Left")) + (should-not (eq root-signature (gethash runtime-root cache))) + (should-not (eq left-signature (gethash runtime-left cache))) + (should (eq right-signature (gethash runtime-right cache))) + (should root-signature) + (should left-signature) + (should right-signature) + (with-current-buffer buffer + (should + (ebox-test--buffer-visually-matches-runtime-render-p)))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-buffer-render-signature-invalidates-direct-scroll-path () + "Direct runtime scrolling should invalidate the changed node-to-root path." + (ebox-test--reset-runtime-state) + (let* ((scroll-box + (ebox-build + '(box :id "scroll" :height 2 :overflow scroll + (column + (box :content "row-1" :height 1) + (box :content "row-2" :height 1) + (box :content "row-3" :height 1))))) + (stable (ebox-create :content "Stable" :height 1)) + (layout (ebox-column scroll-box stable)) + (scroll-region-id (car (ebox-region-ids scroll-box))) + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (let* ((root-id (ebox--ensure-node-id layout)) + (scroll-node-id (ebox--ensure-node-id scroll-box)) + (stable-node-id (ebox--ensure-node-id stable)) + (runtime-root (ebox--buffer-runtime-node buffer root-id)) + (runtime-scroll (ebox--buffer-runtime-node buffer scroll-node-id)) + (runtime-stable (ebox--buffer-runtime-node buffer stable-node-id))) + (ebox--with-buffer-render-context buffer + (ebox--render-cache-node-body-signature runtime-root)) + (let* ((cache (ebox--buffer-render-signature-cache buffer)) + (root-signature (gethash runtime-root cache)) + (scroll-signature (gethash runtime-scroll cache)) + (stable-signature (gethash runtime-stable cache))) + (with-current-buffer buffer + (should (ebox--scroll-region-by scroll-region-id 1))) + (should (equal (ebox-get scroll-box :scroll-offset) 0)) + (should-not (gethash runtime-root cache)) + (should-not (gethash runtime-scroll cache)) + (should (eq stable-signature (gethash runtime-stable cache))) + (should root-signature) + (should scroll-signature) + (should stable-signature) + (should (= (plist-get (ebox--scroll-get-state scroll-region-id) + :scroll-offset) + 1))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-render-cache-signature-ignores-viewport-for-static-column () + "Static column signatures should not churn only because outer viewport changed." + (ebox-test--reset-runtime-state) + (let ((node (ebox-column + (ebox-create :content "A" :width 80) + (ebox-create :content "B" :width 80)))) + (ebox--runtime-node-ids node) + (let ((left (let ((ebox-viewport-width 320)) + (ebox--render-cache-node-signature node))) + (right (let ((ebox-viewport-width 640)) + (ebox--render-cache-node-signature node)))) + (should (equal left right))))) + +(ert-deftest ebox-render-cache-plist-signature-avoids-append-churn () + "Render cache plist signatures should be built linearly." + (ebox-test--reset-runtime-state) + (let ((node (ebox-create :content "Signature" :width 80 :border t)) + (original-append (symbol-function 'append)) + (append-calls 0)) + (cl-letf (((symbol-function 'append) + (lambda (&rest args) + (cl-incf append-calls) + (apply original-append args)))) + (ebox--render-cache-plist-signature node)) + (should (= append-calls 0)))) + +(ert-deftest ebox-preformatted-exact-content-skips-justify-measurement () + "Exact-width preformatted content should not be remeasured for left justify." + (ebox-test--reset-runtime-state) + (let* ((content (ebox-lines-join + (list (ebox-pixel-space 80) + (ebox-pixel-space 80)))) + (box (ebox-create :content content :width 80 :wrap-mode nil)) + (measure-count 0) + (original (symbol-function 'ebox--string-pixel-width))) + (plist-put box :ebox-content-width-exact-p t) + (cl-letf (((symbol-function 'ebox--string-pixel-width) + (lambda (&rest args) + (cl-incf measure-count) + (apply original args)))) + (ebox--format-content box)) + (should (= measure-count 0)))) + +(ert-deftest ebox-uniform-stack-content-skips-parent-justify-measurement () + "A target-width stack should not be measured again by its parent box." + (ebox-test--reset-runtime-state) + (let* ((child (ebox-column + (ebox-create :content "A" :width '(viewport)) + (ebox-create :content "B" :width '(viewport)))) + (parent (ebox-create :width '(viewport) :wrap-mode nil + :ebox-content-node child)) + (justify-count 0) + (original (symbol-function 'ebox--lines-justify))) + (let ((ebox-viewport-width 120)) + (cl-letf (((symbol-function 'ebox--lines-justify) + (lambda (&rest args) + (cl-incf justify-count) + (apply original args)))) + (ebox-render parent))) + ;; The two leaf boxes still justify their own text. The parent does not + ;; add a third full-stack pass. + (should (= justify-count 2)))) + +(ert-deftest ebox-uniform-content-skips-noop-word-and-char-formatting () + "Exact uniform lines should bypass proven no-op word/char formatting." + (ebox-test--reset-runtime-state) + (dolist (wrap-mode '(word char)) + (let* ((box (ebox-create :content "" :width '(120) + :wrap-mode wrap-mode :text-align 'left)) + (pixel-width (ebox--content-pixel box)) + (line (propertize (ebox-pixel-space pixel-width) + 'face '(:foreground "#123456"))) + (content (concat line "\n" line)) + (wrap-count 0) + (justify-count 0) + (original-wrap (symbol-function 'ebox--wrap-text)) + (original-justify (symbol-function 'ebox--lines-justify))) + (ebox--record-rendered-uniform-width content pixel-width) + (cl-letf (((symbol-function 'ebox--wrap-text) + (lambda (&rest arguments) + (cl-incf wrap-count) + (apply original-wrap arguments))) + ((symbol-function 'ebox--lines-justify) + (lambda (&rest arguments) + (cl-incf justify-count) + (apply original-justify arguments)))) + (should (eq (ebox--format-content-string box content) content))) + (should (= wrap-count 0)) + (should (= justify-count 0))))) + +(ert-deftest ebox-uniform-content-keeps-semantic-formatting-paths () + "KP, non-left alignment and width mismatch must retain formatting." + (ebox-test--reset-runtime-state) + (dolist (case '((kp left 120) + (word center 120) + (char left 119))) + (let* ((box (ebox-create :content "" :width '(120) + :wrap-mode (nth 0 case) + :text-align (nth 1 case))) + (content (ebox-pixel-space 120)) + (wrap-count 0) + (original-wrap (symbol-function 'ebox--wrap-text))) + (ebox--record-rendered-uniform-width content (nth 2 case)) + (cl-letf (((symbol-function 'ebox--wrap-text) + (lambda (&rest arguments) + (cl-incf wrap-count) + (apply original-wrap arguments)))) + (ebox--format-content-string box content)) + (should (> wrap-count 0))))) + +(ert-deftest ebox-transparent-preformatted-box-fast-path-is-exact () + "Transparent exact-width wrappers should match the generic box pipeline." + (ebox-test--reset-runtime-state) + (let* ((child-a (ebox-create :content "A" :width '(viewport) :border t + :region-id 'child-a)) + (child-b (ebox-create :content "B" :width '(viewport) :border t + :region-id 'child-b)) + (parent (ebox-create :width '(viewport) :wrap-mode nil + :region-id 'parent + :ebox-content-node + (ebox-column child-a child-b))) + generic fast) + (let ((ebox-viewport-width 120) + (ebox--transparent-preformatted-box-fast-path-disabled t)) + (setq generic (ebox-render parent))) + (clrhash ebox--rendered-uniform-width-table) + (let ((ebox-viewport-width 120)) + (setq fast (ebox-render parent))) + (should (equal-including-properties fast generic)))) + +(ert-deftest ebox-flat-preformatted-box-fast-path-is-exact () + "One-pass exact wrappers should preserve generic chrome and ownership." + (ebox-test--reset-runtime-state) + (let* ((box + (ebox-create + :content "" :width '(140) :wrap-mode nil :region-id 'wrapper + :padding-left-pixel 7 :padding-right-pixel 5 + :padding-top-height 1 :padding-bottom-height 2 + :border-left-pixel 2 :border-left-color "#AA0000" + :border-right-pixel 3 :border-right-color "#00AA00" + :border-top-p t :border-top-color "#0000AA" + :border-bottom-p t :border-bottom-color "#AA00AA" + :color "#111111" :bgcolor "#EEEEEE")) + (content-width (ebox--content-pixel box)) + (full-line (ebox-pixel-space content-width)) + (content (concat full-line "\n\n" full-line)) + (fast-path-calls 0) + (original + (symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)) + generic fast) + (plist-put box :content content) + (plist-put box :ebox-content-width-exact-p t) + (let ((ebox--flat-preformatted-box-fast-path-disabled t)) + (setq generic (ebox-render box))) + (clrhash ebox--region-box-table) + (clrhash ebox--scroll-global-state) + (cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines) + (lambda (&rest args) + (cl-incf fast-path-calls) + (apply original args)))) + (setq fast (ebox-render box))) + (should (= fast-path-calls 1)) + (should (equal-including-properties fast generic)))) + +(ert-deftest ebox-flat-preformatted-fixed-hidden-box-fast-path-is-exact () + "Exact fixed-height hidden wrappers should preserve generic output." + (ebox-test--reset-runtime-state) + (let* ((box + (ebox-create + :content "" :width '(140) :height 6 :box-sizing 'border-box + :overflow 'hidden :wrap-mode nil :region-id 'fixed-wrapper + :padding-left-pixel 7 :padding-right-pixel 5 + :padding-top-height 1 :padding-bottom-height 2 + :border-left-pixel 2 :border-left-color "#AA0000" + :border-right-pixel 3 :border-right-color "#00AA00" + :border-top-p t :border-top-color "#0000AA" + :border-bottom-p t :border-bottom-color "#AA00AA" + :color "#111111" :bgcolor "#EEEEEE")) + (content-width (ebox--content-pixel box)) + (full-line (ebox-pixel-space content-width)) + (content (concat full-line "\n\n" full-line)) + (fast-path-calls 0) + (original + (symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)) + generic fast) + (plist-put box :content content) + (plist-put box :ebox-content-width-exact-p t) + (let ((ebox--flat-preformatted-box-fast-path-disabled t)) + (setq generic (ebox-render box))) + (clrhash ebox--region-box-table) + (clrhash ebox--scroll-global-state) + (cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines) + (lambda (&rest arguments) + (cl-incf fast-path-calls) + (apply original arguments)))) + (setq fast (ebox-render box))) + (should (= fast-path-calls 1)) + (should (eq (gethash 'fixed-wrapper ebox--region-box-table) box)) + (should-not (gethash 'fixed-wrapper ebox--scroll-global-state)) + (should (equal-including-properties fast generic)))) + +(ert-deftest ebox-flat-preformatted-fixed-box-rejects-layout-work () + "Fixed wrappers should stay generic when clipping or alignment is required." + (ebox-test--reset-runtime-state) + (dolist (case '(("two\nlines" hidden top) + ("one\ntwo\nthree\nfour" hidden top) + ("one\ntwo\nthree" scroll top) + ("one\ntwo\nthree" hidden center))) + (let* ((box + (ebox-create + :content (nth 0 case) :width '(120) :height 5 + :box-sizing 'border-box :overflow (nth 1 case) + :vertical-align (nth 2 case) :wrap-mode nil)) + (calls 0) + (original + (symbol-function 'ebox--window-render-flat-wrapper-chunk-lines))) + (plist-put box :ebox-content-width-exact-p t) + (cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines) + (lambda (&rest arguments) + (cl-incf calls) + (apply original arguments)))) + (ebox-render box)) + (should (= calls 0))))) + +(ert-deftest ebox-scroll-span-template-indexes-only-live-scroll-owners () + "Prepared root metadata should not scan or retain non-scroll owners." + (ebox-test--reset-runtime-state) + (let* ((direct + (propertize "AA" 'ebox-content 'direct-scroll + 'ebox-content-idx 3)) + (ordinary + (propertize "BB" 'ebox-content 'ordinary + 'ebox-content-idx 0)) + (owned + (concat + (propertize "C" 'ebox-content 'child + 'ebox-content-idx 0 + 'ebox-content-owner 'owner-scroll + 'face '(:foreground "red")) + (propertize "C" 'ebox-content 'child + 'ebox-content-idx 0 + 'ebox-content-owner 'owner-scroll + 'face '(:foreground "blue")))) + (rendered (concat direct ordinary "\n" owned)) + template) + (puthash 'direct-scroll (list :scroll-offset 0) + ebox--scroll-global-state) + (puthash 'owner-scroll (list :scroll-offset 0) + ebox--scroll-global-state) + (setq template (ebox--build-scroll-content-span-template rendered)) + (should (equal (gethash 'direct-scroll template) + '((3 0 . 2)))) + (should (equal (gethash 'owner-scroll template) + '((0 5 . 7)))) + (should-not (gethash 'ordinary template)) + (should-not (gethash 'child template)))) + +(ert-deftest ebox-overflowing-stack-content-still-uses-parent-formatting () + "An overflowing child stack must not publish uniform parent-width metadata." + (ebox-test--reset-runtime-state) + (let* ((child (ebox-column + (ebox-create :content "A" :width 140) + (ebox-create :content "B" :width 140))) + (parent (ebox-create :width 120 :wrap-mode nil + :ebox-content-node child)) + (justify-count 0) + (original (symbol-function 'ebox--lines-justify)) + rendered) + (let ((ebox-viewport-width 120)) + (cl-letf (((symbol-function 'ebox--lines-justify) + (lambda (&rest args) + (cl-incf justify-count) + (apply original args)))) + (setq rendered (ebox-render parent)))) + (should (= justify-count 3)) + (should (= (ebox--string-max-pixel-width rendered) 140)))) + +(ert-deftest ebox-render-caches-lazy-box-content-within-render-pass () + "A lazy child layout should be rendered once per box/context in one pass." + (ebox-test--reset-runtime-state) + (let* ((child (ebox-column + (ebox-create :content "A" :width 80) + (ebox-create :content "B" :width 80))) + (parent (ebox-create :width 'max-content :padding 2)) + (render-count 0) + (original (symbol-function 'ebox--render-node-as-box-content))) + (plist-put parent :ebox-content-node child) + (cl-letf (((symbol-function 'ebox--render-node-as-box-content) + (lambda (&rest args) + (cl-incf render-count) + (apply original args)))) + (ebox-render parent)) + (should (= render-count 1)))) + +(ert-deftest ebox-buffer-update-report-includes-dirty-and-patch-summary () + "Update reports should show dirty and patch-set summaries." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Report" :width 80 + :border t :border-color "red")) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-region-update region-id :border-color "blue") + (let ((report (ebox-test--buffer-update-report))) + (should (plist-member report :dirty-count)) + (should (plist-member report :patch-count)) + (should (plist-member report :patch-ops)) + (should (equal (plist-get report :dirty-keys) + '(:border-top-color :border-right-color + :border-bottom-color :border-left-color))))))) + +(ert-deftest ebox-buffer-update-report-is-buffer-local () + "Updating one rendered buffer should not replace another buffer's report." + (ebox-test--reset-runtime-state) + (let* ((first-box (ebox-create :content "First" :width '(80))) + (second-box (ebox-create :content "Second" :width '(80))) + (first-id (car (ebox-region-ids first-box))) + first-buffer second-buffer) + (unwind-protect + (progn + (setq first-buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-report-first*") + first-box)) + (setq second-buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-report-second*") + second-box)) + (ebox-test--set-buffer-update-report + second-buffer '(:strategy second-prior :owner-ids (second))) + (with-current-buffer first-buffer + (let ((report (ebox-region-update first-id :content "First!"))) + (should (equal report + (ebox-buffer-update-report first-buffer))) + (should-not (equal report + (ebox-buffer-update-report + second-buffer))) + (should (equal (ebox-buffer-update-report second-buffer) + '(:strategy second-prior + :owner-ids (second))))))) + (dolist (buffer (list first-buffer second-buffer)) + (when (buffer-live-p buffer) + (kill-buffer buffer)))))) + +(ert-deftest ebox-buffer-update-report-returns-defensive-copy () + "The public accessor should not return mutable render-state storage." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Copy" :width '(80))) + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-report-copy*") + box)) + (ebox-test--set-buffer-update-report + buffer '(:strategy paint-patch :owner-ids (1 2))) + (let ((report (ebox-buffer-update-report buffer))) + (should (equal report '(:strategy paint-patch :owner-ids (1 2)))) + (plist-put report :strategy 'mutated) + (setcar (plist-get report :owner-ids) 99)) + (should (equal (ebox-buffer-update-report buffer) + '(:strategy paint-patch :owner-ids (1 2))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-buffer-update-report-user-errors-without-rendered-buffer () + "The public accessor should reject missing, dead, and unrendered buffers." + (ebox-test--reset-runtime-state) + (let ((dead (generate-new-buffer " *ebox-report-dead*")) + (unrendered (generate-new-buffer " *ebox-report-unrendered*"))) + (unwind-protect + (progn + (kill-buffer dead) + (should-error (ebox-buffer-update-report nil) :type 'user-error) + (should-error (ebox-buffer-update-report dead) :type 'user-error) + (should-error (ebox-buffer-update-report unrendered) + :type 'user-error)) + (when (buffer-live-p unrendered) + (kill-buffer unrendered))))) + +(ert-deftest ebox-region-update-return-matches-buffer-update-report () + "A direct region update should return the report stored on its buffer." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Return" :width '(80) + :color "#111111")) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (let ((report (ebox-region-update region-id :color "#222222"))) + (should (equal report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'paint-patch)))))) + +(ert-deftest ebox-region-update-no-op-replaces-stale-buffer-report () + "A direct unchanged update should publish its own no-op report." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "No-op" :width '(80) + :color "#111111")) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (let ((prior '(:strategy paint-patch :owner-ids (prior)))) + (ebox-test--set-buffer-update-report (current-buffer) prior) + (let ((report + (ebox-region-update region-id :color "#111111"))) + (should (eq (plist-get report :strategy) 'no-op)) + (should (= (plist-get report :dirty-count) 0)) + (should (= (plist-get report :patch-count) 0)) + (should (equal report (ebox-test--buffer-update-report))) + (should-not (equal report prior))))))) + +(ert-deftest ebox-batched-region-update-keeps-prior-report-until-flush () + "Batched region updates should return nil and publish only on flush." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Batch" :width '(80) + :color "#111111")) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (let ((buffer (current-buffer)) + (prior '(:strategy prior :owner-ids (prior)))) + (ebox-test--set-buffer-update-report buffer prior) + (ebox-incremental-begin-batch buffer) + (should-not (ebox-region-update region-id :color "#333333")) + (should (equal (ebox-buffer-update-report buffer) prior)) + (let ((flush-report (ebox-incremental-flush buffer))) + (should flush-report) + (should (equal flush-report + (ebox-buffer-update-report buffer))) + (should (eq (plist-get flush-report :strategy) + 'paint-patch))))))) + +(ert-deftest ebox-batch-flush-stores-report-on-flushed-buffer-only () + "Empty and non-empty flushes should update only the target buffer report." + (ebox-test--reset-runtime-state) + (let* ((first-box (ebox-create :content "First" :width '(80) + :color "#111111")) + (second-box (ebox-create :content "Second" :width '(80) + :color "#111111")) + (second-id (car (ebox-region-ids second-box))) + first-buffer second-buffer) + (unwind-protect + (progn + (setq first-buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-flush-first*") + first-box)) + (setq second-buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-flush-second*") + second-box)) + (ebox-test--set-buffer-update-report + first-buffer '(:strategy first-prior)) + (ebox-test--set-buffer-update-report + second-buffer '(:strategy second-prior)) + (ebox-incremental-begin-batch first-buffer) + (let ((first-report (ebox-incremental-flush first-buffer))) + (should (eq (plist-get first-report :strategy) 'no-op)) + (should (equal (ebox-buffer-update-report first-buffer) + first-report)) + (should (equal (ebox-buffer-update-report second-buffer) + '(:strategy second-prior))) + (with-current-buffer second-buffer + (ebox-incremental-begin-batch second-buffer) + (should-not + (ebox-region-update second-id :color "#444444"))) + (let ((second-report (ebox-incremental-flush second-buffer))) + (should (eq (plist-get second-report :strategy) 'paint-patch)) + (should (equal (ebox-buffer-update-report second-buffer) + second-report)) + (should (equal (ebox-buffer-update-report first-buffer) + first-report))))) + (dolist (buffer (list first-buffer second-buffer)) + (when (buffer-live-p buffer) + (kill-buffer buffer)))))) + +(ert-deftest ebox-render-to-buffer-does-not-switch-current-buffer () + (ebox-test--reset-runtime-state) + (let ((origin (current-buffer)) + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + (ebox-create :content "Hello" :width 40))) + (should (eq (current-buffer) origin)) + (should (buffer-live-p buffer)) + (with-current-buffer buffer + (should ebox-buffer-mode) + (should buffer-read-only))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-render-to-buffer-keeps-existing-local-map-as-parent () + (ebox-test--reset-runtime-state) + (let* ((buffer-name (generate-new-buffer-name " *ebox-test*")) + (buffer (get-buffer-create buffer-name)) + (parent-map (make-sparse-keymap))) + (define-key parent-map (kbd "x") #'ignore) + (unwind-protect + (with-current-buffer buffer + (use-local-map parent-map) + (ebox-render-to-buffer buffer-name + (ebox-create :content "Hello" :width 40)) + (should (eq (current-local-map) parent-map)) + (should (eq (lookup-key (current-local-map) (kbd "x")) #'ignore))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-render-to-buffer-defers-extents-and-snapshots () + "Initial buffer render should leave expensive indexes to lazy accessors." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(column :id "root" :width (viewport) + ,@(cl-loop for idx below 12 + collect + `(box :id ,(format "lazy-initial-%02d" idx) + :content ,(format "row %02d" idx) + :width (viewport) + :height 1))))) + buffer + (register-count 0) + (snapshot-count 0) + (runtime-node-ids-count 0) + (original-register + (symbol-function 'ebox--register-box-extents-in-range)) + (original-snapshot + (symbol-function 'ebox--node-layout-snapshot)) + (original-runtime-node-ids + (symbol-function 'ebox--runtime-node-ids))) + (unwind-protect + (let ((ebox-viewport-width 240)) + (cl-letf (((symbol-function + 'ebox--register-box-extents-in-range) + (lambda (&rest args) + (cl-incf register-count) + (apply original-register args))) + ((symbol-function 'ebox--node-layout-snapshot) + (lambda (&rest args) + (cl-incf snapshot-count) + (apply original-snapshot args))) + ((symbol-function 'ebox--runtime-node-ids) + (lambda (&rest args) + (cl-incf runtime-node-ids-count) + (apply original-runtime-node-ids args)))) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (should (= register-count 0)) + (should (= snapshot-count 0)) + (should (= runtime-node-ids-count 0)) + (let* ((root-node-id (plist-get layout :node-id)) + (target-id (ebox-test--selector-region-id + buffer "#lazy-initial-06"))) + (should (ebox--layout-snapshot buffer root-node-id)) + (should (ebox--live-box-extents target-id)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-region-buffer-ignores-killed-render-buffers () + "Dead render buffers should not break later incremental lookups." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Dead" :width 40)) + (region-id (car (ebox-region-ids box))) + (buffer (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + box))) + (kill-buffer buffer) + (should-not (ebox--region-buffer region-id)) + (should-not (ebox--buffer-render-state buffer)))) + +(ert-deftest ebox-buffer-kill-clears-region-runtime-caches () + "Killing an ebox buffer should clear region-scoped runtime cache entries." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A\nB\nC" :width 80 :height 1 + :overflow 'scroll)) + (region-id (car (ebox-region-ids box))) + (buffer (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + box))) + (should (gethash region-id ebox--region-box-table)) + (should (ebox--scroll-get-state region-id)) + (kill-buffer buffer) + (should-not (gethash region-id ebox--region-box-table)) + (should-not (ebox--scroll-get-state region-id)))) + +(ert-deftest ebox-buffer-kill-discards-buffer-owned-runtime-state () + "Killing an Ebox buffer should discard every buffer-keyed runtime entry." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Lifecycle" :color "#111111")) + (region-id (car (ebox-region-ids box))) + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") box)) + + ;; A non-batched update creates a real cache report. + (ebox-region-update region-id :color "#222222") + + ;; A later update remains pending in the explicit batch. + (ebox-incremental-begin-batch buffer) + (ebox-region-update region-id :background-color "#eeeeee") + + (should (gethash buffer ebox--buffer-render-state-table)) + (should (gethash buffer ebox-cache--buffer-report-table)) + (should (gethash buffer ebox-incremental--batch-table)) + (should (ebox-buffer-update-report buffer)) + (should (= (ebox-incremental-pending-count buffer) 1)) + + (kill-buffer buffer) + (should-not (gethash buffer ebox--buffer-render-state-table)) + (should-not (gethash buffer ebox-cache--buffer-report-table)) + (should-not (gethash buffer ebox-incremental--batch-table)) + (should-error (ebox-buffer-update-report buffer) + :type 'user-error)) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer)) + (when buffer + (remhash buffer ebox--buffer-render-state-table) + (remhash buffer ebox-cache--buffer-report-table) + (remhash buffer ebox-incremental--batch-table))))) + +(ert-deftest ebox-concat-content-update-preserves-neighbor-box () + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "A\nB" :width 60)) + (right (ebox-create :content "R1\nR2\nR3" :width 80)) + (layout (ebox-concat left right)) + (ids (ebox-region-ids layout)) + (left-id (nth 0 ids)) + (right-id (nth 1 ids))) + (ebox-test--with-rendered-buffer layout + (should-not (ebox--live-box-extents left-id)) + (ebox-region-update left-id :content "AA\nBB\nCC") + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "CC" plain)) + (should (string-match-p "R3" plain))) + (should (equal (ebox-region-ids layout) ids)) + (should (gethash right-id ebox--region-box-table))))) + +(ert-deftest ebox-geometry-update-rerenders-root-once-safely () + (ebox-test--reset-runtime-state) + (let* ((top (ebox-create :content "Top" :width 60)) + (left (ebox-create :content "L" :width 40)) + (right (ebox-create :content "R" :width 40)) + (layout (ebox-stack top (ebox-concat left right))) + (ids (ebox-region-ids layout)) + (left-id (nth 1 ids))) + (ebox-test--with-rendered-buffer layout + (ebox-region-update left-id :width '(120)) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "Top" plain)) + (should (string-match-p "L" plain)) + (should (string-match-p "R" plain))) + (should (equal (ebox-region-ids layout) ids))))) + +(ert-deftest ebox-border-color-update-patches-without-full-root-rerender () + "A pure border color change should patch owned spans, not rebuild the root." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "L" :width 40 + :border t :border-color "red")) + (right (ebox-create :content "R" :width 40 + :border t :border-color "green")) + (layout (ebox-concat left right)) + (left-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (let ((full-rerenders + (ebox-test--count-full-rerenders + (ebox-region-update left-id :border-color "blue")))) + (should (= full-rerenders 0))) + (let ((border-face (get-text-property (point-min) 'face))) + (should (ebox-test--face-has-key-value-p border-face + :overline "blue"))) + (should (string-match-p + "R" + (buffer-substring-no-properties (point-min) (point-max))))))) + +(ert-deftest ebox-flex-row-border-color-update-patches-region-role () + "Paint-only updates inside flex rows should not fall back to root rerender." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(flex :flex-flow (row wrap) :column-gap (12) + :padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2" + (column + (box :id left :content "Left" :width (120) + :padding (0 (8)) :border "#D97757") + (box :content "Body" :width (120) + :padding (0 (8)) :border "#D97757" + :border-top-p nil)) + (column + (box :id right :content "Right" :width (120) + :padding (0 (8)) :border "#5E7F6A") + (box :content "Body" :width (120) + :padding (0 (8)) :border "#5E7F6A" + :border-top-p nil))))) + (left-id (plist-get + (car (ebox-selector-query-all layout "#left")) + :region-id))) + (ebox-test--with-rendered-buffer layout + (let ((full-rerenders + (ebox-test--count-full-rerenders + (ebox-region-update left-id :border-bottom-color "blue")))) + (should (= full-rerenders 0))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'paint-patch)) + (should-not (plist-get report :root-rerender))) + (let (bottom-face) + (save-excursion + (goto-char (point-min)) + (while (and (not bottom-face) (< (point) (point-max))) + (when (equal (get-text-property (point) 'ebox-bb) left-id) + (setq bottom-face (get-text-property (point) 'face))) + (forward-char 1))) + (should (ebox-test--face-has-key-value-p + bottom-face :underline '(:position t :color "blue"))))))) + +(ert-deftest ebox-paint-update-synthesizes-missing-horizontal-border-surface () + "Adding a missing top/bottom paint surface should patch from region anchors." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(flex :flex-flow (row wrap) :column-gap (12) + :padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2" + (column + (box :id left :content "Left" :width (120) + :padding (0 (8)) :border "#D97757" + :border-bottom-p nil) + (box :content "Body" :width (120) + :padding (0 (8)) :border "#D97757" + :border-top-p nil)) + (column + (box :content "Right" :width (120) + :padding (0 (8)) :border "#5E7F6A" + :border-bottom-p nil) + (box :content "Body" :width (120) + :padding (0 (8)) :border "#5E7F6A" + :border-top-p nil))))) + (left-id (plist-get + (car (ebox-selector-query-all layout "#left")) + :region-id))) + (ebox-test--with-rendered-buffer layout + (should-not (text-property-search-forward 'ebox-bb left-id t)) + (let ((full-rerenders + (ebox-test--count-full-rerenders + (ebox-region-update left-id + :border-bottom-p t + :border-bottom-color "blue")))) + (should (= full-rerenders 0))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'paint-patch)) + (should-not (plist-get report :root-rerender))) + (let (bottom-face) + (save-excursion + (goto-char (point-min)) + (while (and (not bottom-face) (< (point) (point-max))) + (when (equal (get-text-property (point) 'ebox-bb) left-id) + (setq bottom-face (get-text-property (point) 'face))) + (forward-char 1))) + (should (ebox-test--face-has-key-value-p + bottom-face :underline '(:position t :color "blue"))))))) + +(ert-deftest ebox-horizontal-border-removal-preserves-other-border-colors () + "Removing top/bottom border surfaces must not corrupt other border faces." + (ebox-test--reset-runtime-state) + (dolist (case '((:border-top-p bt (bb bl br)) + (:border-bottom-p bb (bt bl br)))) + (let* ((property (nth 0 case)) + (removed-role (nth 1 case)) + (kept-roles (nth 2 case)) + (layout (ebox-create :content "Border" + :width 80 + :padding '(0 (8)) + :border "#D97757")) + (region-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (cl-labels ((face-at-role + (role) + (let ((prop (alist-get role ebox-region-types)) + face) + (save-excursion + (goto-char (point-min)) + (while (and (not face) (< (point) (point-max))) + (when (equal (get-text-property (point) prop) + region-id) + (setq face (get-text-property (point) 'face))) + (forward-char 1))) + face))) + (ebox-region-update region-id property nil) + (should-not (face-at-role removed-role)) + (dolist (role kept-roles) + (should + (ebox-test--border-face-has-color-p + (face-at-role role) role "#D97757")))))))) + +(ert-deftest ebox-side-border-color-patch-toggles-default-paint-exactly () + "Side color patches should match default and explicit full-render paint." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-create + :content "Border" :width 80 + :border-left-pixel 2 :border-left-color "#AA0000" + :border-right-pixel 2 :border-right-color "#AA0000")) + (region-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (ebox-region-update region-id + :border-left-color nil + :border-right-color nil) + (dolist (role '(bl br)) + (let ((face + (ebox-test--face-at-property + (buffer-string) (alist-get role ebox-region-types)))) + (should + (ebox-test--face-has-key-value-p face :inverse-video t)) + (should-not (ebox-test--face-has-key-p face :background)))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (ebox-region-update region-id + :border-left-color "#00AA00" + :border-right-color "#00AA00") + (dolist (role '(bl br)) + (let ((face + (ebox-test--face-at-property + (buffer-string) (alist-get role ebox-region-types)))) + (should-not (ebox-test--face-has-key-p face :inverse-video)) + (should + (ebox-test--face-has-key-value-p + face :background "#00AA00")))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-region-update-reuses-buffer-render-viewport-context () + "Incremental rerender should use the viewport from the original buffer render." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-flex :padding '(0 4) :border "#64748B" + (ebox-create :content "Flex Child" :width '(120) + :border t))) + (region-id (car (ebox--node-all-region-ids layout))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (with-current-buffer buffer + (let ((full-rerenders + (ebox-test--count-full-rerenders + (ebox-region-update region-id + :border-color "#2563EB")))) + (should (= full-rerenders 0))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'paint-patch)) + (should (eq (plist-get report :owner-type) 'flex)) + (should (equal (plist-get report :patch-ops) + '(paint-patch)))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-rerender-buffer-with-context-resolves-runtime-viewport () + "Runtime rerender should resolve viewport-sized boxes with the new context." + (ebox-test--reset-runtime-state) + (let* ((node (let ((ebox-viewport-width 240)) + (ebox-build + '(box :content "Viewport" :width (viewport) :border t)))) + buffer) + (unwind-protect + (progn + (should (eq (plist-get node :width) 'viewport)) + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + node))) + (with-current-buffer buffer + (should (= (ebox--string-pixel-width + (buffer-substring (point-min) (line-end-position))) + 240))) + (ebox-rerender-buffer-with-context buffer 160) + (with-current-buffer buffer + (should (= (ebox--string-pixel-width + (buffer-substring (point-min) (line-end-position))) + 160)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-rerender-buffer-with-context-resolves-runtime-viewport-height () + "Runtime rerender should resolve viewport-height boxes with the new context." + (ebox-test--reset-runtime-state) + (let* ((node (let ((ebox-viewport-height 2)) + (ebox-build + '(box :content "One\nTwo\nThree\nFour" + :width (120) + :height (viewport-height) + :overflow scroll)))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-height 2)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + node))) + (with-current-buffer buffer + (should (= (length (ebox-string-lines + (buffer-substring-no-properties + (point-min) (point-max)))) + 2))) + (ebox-rerender-buffer-with-context buffer 120 3) + (with-current-buffer buffer + (should (= (length (ebox-string-lines + (buffer-substring-no-properties + (point-min) (point-max)))) + 3)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-rerender-buffer-with-context-rebuilds-nested-scroll-extents () + "Viewport-height rerenders should keep fixed rails outside the scroll box." + (ebox-test--reset-runtime-state) + (let* ((node (let ((ebox-viewport-width 120) + (ebox-viewport-height 12)) + (ebox-build + '(column :id "outer" + :width (viewport) :height (viewport-height) + (box :id "top" :content "" :height 2) + (box :id "root" :height (- (viewport-height) 4) + :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 "F" :height 1) + (box :content "G" :height 1) + (box :content "H" :height 1) + (box :content "I" :height 1) + (box :content "J" :height 1))) + (box :id "bottom" :content "" :height 2))))) + buffer) + (unwind-protect + (progn + (let ((ebox-viewport-width 120) + (ebox-viewport-height 12)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + node))) + (ebox-rerender-buffer-with-context buffer 120 20) + (with-current-buffer buffer + (let* ((root-id (plist-get + (car (ebox-selector-query-buffer buffer "#root")) + :region-id)) + (top-id (plist-get + (car (ebox-selector-query-buffer buffer "#top")) + :region-id)) + (extents (ebox--live-box-extents root-id))) + (should extents) + (should (= (length (ebox-string-lines + (buffer-substring-no-properties + (point-min) (point-max)))) + 20)) + (should (= (line-number-at-pos (car extents)) 3)) + (should (= (count-lines (car extents) (cdr extents)) 16)) + (save-excursion + (goto-char (car extents)) + (should (member root-id + (get-text-property + (point) 'ebox-content-owners))) + (should-not (member top-id + (get-text-property + (point) 'ebox-content-owners))))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-stable-content-update-patches-without-full-root-rerender () + "A content update with unchanged rendered geometry should stay local." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "Alpha" :width 80 :height 1)) + (right (ebox-create :content "Right" :width 80 :height 1)) + (layout (ebox-concat left right)) + (left-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (let ((full-rerenders + (ebox-test--count-full-rerenders + (ebox-region-update left-id :content "Beta")))) + (should (= full-rerenders 0))) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "Beta" plain)) + (should-not (string-match-p "Alpha" plain)) + (should (string-match-p "Right" plain)))))) + +(ert-deftest ebox-nested-stable-content-update-patches-leaf-owner () + "Stable content updates inside nested layouts should not patch the root." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(flex :flex-flow (row wrap) :column-gap (12) + :padding (0 (12)) :border "#999999" + (column :width (210) :border "#999999" + (box :content "Title" :width (190)) + (box :id target :content "before\nsame" + :width (190) :height 3 + :padding (0 (8)) :border "#999999") + (box :content "Metric" :width (190))) + (column :width (210) :border "#999999" + (box :content "Other" :width (190)) + (box :content "Still here" :width (190)))))) + (target (car (ebox-selector-query-all layout "#target"))) + (target-id (plist-get target :region-id)) + (target-node-id (plist-get target :node-id))) + (ebox-test--with-rendered-buffer layout + (let ((snapshot (ebox--ensure-layout-snapshot-spans + (current-buffer) target-node-id))) + (should snapshot) + (should (plist-get snapshot :buffer-spans))) + (ebox-region-update target-id :content "after\nsame") + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (equal (plist-get report :owner-ids) (list target-node-id))) + (should (< (plist-get report :span-count) 10)) + (should-not (plist-get report :full-rerender))) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "after" plain)) + (should-not (string-match-p "before" plain)) + (should (string-match-p "Still here" plain)))))) + +(ert-deftest ebox-flex-contained-geometry-update-does-not-promote-to-root () + "Stable flex-contained geometry changes should stop before root." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(column :id root :width (520) :padding (1 (18)) + (flex :id matrix :flex-flow (row wrap) + :column-gap (12) :row-gap 1 :padding (0 (12)) + :border "#999999" + (column :width (210) :border "#999999" + (box :content "Title" :width (190)) + (box :id target :content "width target\nsame height" + :width (160) :height 3 + :padding (0 (8)) :border "#999999") + (box :content "Metric" :width (190))) + (column :width (210) :border "#999999" + (box :content "Other" :width (190)) + (box :content "Still here" :width (190))))))) + (target (car (ebox-selector-query-all layout "#target"))) + (target-id (plist-get target :region-id))) + (ebox-test--with-rendered-buffer layout + (let ((root-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox-region-update target-id :width '(190)) + (let ((report (ebox-test--buffer-update-report))) + (should-not (plist-get report :full-rerender)) + (should-not (member root-id (plist-get report :owner-ids))) + (should (memq (plist-get report :strategy) + '(span-patch owner-rerender))) + (should (< (plist-get report :span-count) 40))))))) + +(defun ebox-test--nested-width-containment-layout (&optional height) + "Return a nested flex width-update fixture with optional fixed HEIGHT." + (let* ((target + (ebox-create :content "width target\nready to grow" + :width '(190) :height 3 :padding '(0 (10)) + :border "#999999")) + (owner + (apply #'ebox-flex + (append + (list :width '(300) :padding '(0 (10)) + :border "#999999" :justify-content 'center + :align-items 'center) + (when height (list :height height)) + (list target)))) + (card + (ebox-column + (ebox-create :content "Width" :width '(300) :height 2) + owner + (ebox-create :content "The target stays in this card" + :width '(300) :height 3) + (ebox-create :content "Metric" :width '(300) :height 4))) + (peer + (ebox-column + (ebox-create :content "Peer" :width '(300) :height 2) + (ebox-create :content "Stable peer" :width '(300) :height 5) + (ebox-create :content "Footer" :width '(300) :height 7))) + (layout + (ebox-flex :width '(720) :flex-flow '(row wrap) + :column-gap '(12) card peer))) + (list :layout layout :target target :owner owner))) + +(ert-deftest ebox-fixed-flex-context-contains-child-width-update () + "Fixed flex contexts should patch their five lines, not the whole card." + (ebox-test--reset-runtime-state) + (let* ((fixture (ebox-test--nested-width-containment-layout 5)) + (layout (plist-get fixture :layout)) + (target (plist-get fixture :target)) + (owner (plist-get fixture :owner)) + (target-region-id (car (ebox-region-ids target))) + (owner-node-id (ebox--ensure-node-id owner))) + (ebox-test--with-rendered-buffer layout + (ebox-region-update target-region-id :width '(280)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (equal (plist-get report :owner-ids) + (list owner-node-id))) + (should (eq (plist-get report :owner-type) 'flex)) + (should (= (plist-get report :span-count) 5)) + (should-not (plist-get report :root-rerender)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-contained-partial-flex-content-change-preserves-fixed-slot () + "A fixed partial flex should update without disturbing adjacent siblings." + (ebox-test--reset-runtime-state) + (let* ((mark (ebox-create :key 'mark :host-ref 'mark + :content "○ OFF" :width 'max-content + :overflow 'hidden)) + (label (ebox-create :key 'label :content " Power off" + :host-ref 'label :width 'max-content + :overflow 'hidden)) + (switch (ebox-create :key 'switch :host-ref 'switch + :width 'max-content :wrap-mode nil + :overflow 'hidden + :ebox-content-node (ebox-row mark label))) + (owner (ebox-flex :width '(496) :min-width '(496) + :max-width '(496) :height 1 :min-height 1 + :max-height 1 :overflow 'hidden + :bgcolor "#F7F8FA" :wrap-mode nil + :flex-flow '(column nowrap) switch)) + (caption (ebox-create :content "switch" :width '(170) + :min-width '(170) :max-width '(170) + :height 1 :min-height 1 :max-height 1)) + (outer (ebox-flex :width '(680) :min-width '(680) + :max-width '(680) :height 1 :min-height 1 + :max-height 1 :overflow 'hidden + :bgcolor "#F7F8FA" :wrap-mode nil + :column-gap '(14) :flex-flow '(row nowrap) + caption owner)) + (footer (ebox-create :content "FOOTER" :width '(680) :height 1)) + (layout (ebox-column outer footer)) + (mark-node-id (ebox--ensure-node-id mark)) + (label-node-id (ebox--ensure-node-id label)) + (owner-node-id (ebox--ensure-node-id owner)) + (caption-region-id (car (ebox-region-ids caption))) + (footer-region-id (car (ebox-region-ids footer)))) + (plist-put owner :key 'owner) + (plist-put owner :host-ref 'owner) + (plist-put outer :key 'outer) + (plist-put outer :host-ref 'outer) + (ebox-test--with-rendered-buffer layout + (should (ebox--contained-partial-flex-owner-p + (current-buffer) owner-node-id)) + (let ((candidate (ebox-candidate-begin (current-buffer)))) + (ebox-candidate-replace + candidate mark-node-id + (ebox-create :key 'mark :host-ref 'mark + :content " ON ● " :width 'max-content + :overflow 'hidden)) + (ebox-candidate-replace + candidate label-node-id + (ebox-create :key 'label :host-ref 'label + :content " Much longer power on state" + :width 'max-content + :overflow 'hidden)) + (ebox-commit (current-buffer) candidate)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :owner-ids) (list owner-node-id))) + (should (eq (plist-get report :owner-type) 'flex)) + (should (plist-get report :slot-preserving)) + (should (= (plist-get report :span-count) 1)) + (should-not (memq caption-region-id + (plist-get report :affected-region-ids))) + (should-not (memq footer-region-id + (plist-get report :affected-region-ids))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (should + (ebox-test--buffer-propertized-matches-runtime-render-p)))))) + +(ert-deftest ebox-contained-partial-flex-structure-change-preserves-fixed-slot () + "A fixed partial flex should own bounded descendant structure changes." + (ebox-test--reset-runtime-state) + (let* ((input (ebox-create :key 'input :content "query" + :overflow 'hidden)) + (content + (ebox-create :key 'content :host-ref 'content + :width 'max-content :overflow 'hidden + :ebox-content-node (ebox-column input))) + (owner (ebox-flex :width '(180) :height 2 :overflow 'hidden + :flex-flow '(column nowrap) content)) + (caption (ebox-create :content "search" :width '(80) :height 2)) + (outer (ebox-row caption owner)) + (footer (ebox-create :content "FOOTER" :width '(260) :height 1)) + (layout (ebox-column outer footer)) + (content-node-id (ebox--ensure-node-id content)) + (owner-node-id (ebox--ensure-node-id owner)) + (caption-region-id (car (ebox-region-ids caption))) + (footer-region-id (car (ebox-region-ids footer)))) + (plist-put owner :key 'owner) + (plist-put owner :host-ref 'owner) + (ebox-test--with-rendered-buffer layout + (let ((candidate (ebox-candidate-begin (current-buffer)))) + (ebox-candidate-replace + candidate content-node-id + (ebox-create + :key 'content :host-ref 'content + :width 'max-content :overflow 'hidden + :ebox-content-node + (ebox-column + (ebox-create :key 'input :content "query" :overflow 'hidden) + (ebox-create :key 'option :content "Apple" :overflow 'hidden)))) + (ebox-commit (current-buffer) candidate)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :owner-ids) (list owner-node-id))) + (should (eq (plist-get report :owner-type) 'flex)) + (should (plist-get report :slot-preserving)) + (should (= (plist-get report :span-count) 2)) + (should-not (memq caption-region-id + (plist-get report :affected-region-ids))) + (should-not (memq footer-region-id + (plist-get report :affected-region-ids))) + (should + (ebox-test--buffer-propertized-matches-runtime-render-p)))))) + +(ert-deftest ebox-contained-flex-publication-span-rejects-foreign-sibling () + "A declared slot must not expand through a sibling's direct content." + (ebox-test--reset-runtime-state) + (let* ((target (ebox-create :content "A" :overflow 'hidden)) + (owner (ebox-flex :width '(180) :height 1 :overflow 'hidden + :flex-flow '(column nowrap) target)) + (sibling (ebox-create :content "RIGHT" :width '(80) :height 1)) + (layout (ebox-flex :width '(260) :height 1 + :flex-flow '(row nowrap) owner sibling)) + (target-region-id (car (ebox-region-ids target))) + (owner-node-id (ebox--ensure-node-id owner)) + (sibling-region-id (car (ebox-region-ids sibling)))) + (ebox-test--with-rendered-buffer layout + (let* ((snapshot + (ebox--ensure-layout-snapshot-spans + (current-buffer) owner-node-id)) + (spans (plist-get snapshot :buffer-spans)) + (expanded + (ebox-buffer--contained-flex-publication-spans + (current-buffer) owner-node-id owner spans)) + (foreign-position (1- (cdar expanded)))) + (should (ebox--contained-partial-flex-owner-p + (current-buffer) owner-node-id)) + (let ((inhibit-read-only t)) + (put-text-property foreign-position (1+ foreign-position) + 'ebox-content sibling-region-id) + (unwind-protect + (should-not + (ebox-buffer--contained-flex-publication-spans + (current-buffer) owner-node-id owner spans)) + (put-text-property foreign-position (1+ foreign-position) + 'ebox-content nil)))) + (ebox-region-update + target-region-id :content + "This content stays inside its parent's proven fallback") + (let ((report (ebox-test--buffer-update-report))) + (should (equal (plist-get report :owner-ids) + (list owner-node-id))) + (should + (ebox-test--buffer-propertized-matches-runtime-render-p)))))) + +(ert-deftest ebox-auto-height-flex-context-keeps-ancestor-width-fallback () + "Auto-height flex contexts must not contain descendant width reflow." + (ebox-test--reset-runtime-state) + (let* ((fixture (ebox-test--nested-width-containment-layout)) + (layout (plist-get fixture :layout)) + (target (plist-get fixture :target)) + (owner (plist-get fixture :owner)) + (target-region-id (car (ebox-region-ids target))) + (owner-node-id (ebox--ensure-node-id owner))) + (ebox-test--with-rendered-buffer layout + (should-not (ebox--definite-containment-formatting-context-p + (current-buffer) owner-node-id)) + (ebox-region-update target-region-id :width '(280)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should-not (equal (plist-get report :owner-ids) + (list owner-node-id))) + (should (eq (plist-get report :owner-type) 'stack)) + (should-not (plist-get report :root-rerender)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-intrinsic-constrained-flex-context-keeps-ancestor-fallback () + "Intrinsic min/max constraints must disable definite containment." + (dolist (max-width '(min-content (fit-content 260))) + (ebox-test--reset-runtime-state) + (let* ((fixture (ebox-test--nested-width-containment-layout 5)) + (layout (plist-get fixture :layout)) + (target (plist-get fixture :target)) + (owner (plist-get fixture :owner)) + (owner-box (plist-get owner :box)) + (target-region-id (car (ebox-region-ids target))) + (owner-node-id (ebox--ensure-node-id owner))) + (ebox-put owner-box :max-width max-width) + (ebox-test--with-rendered-buffer layout + (should-not (ebox--definite-containment-formatting-context-p + (current-buffer) owner-node-id)) + (ebox-region-update target-region-id :width '(280)) + (let ((report (ebox-test--buffer-update-report))) + (should-not (equal (plist-get report :owner-ids) + (list owner-node-id))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-visible-overflow-flex-context-keeps-ancestor-fallback () + "A fixed context must not contain an unowned visible-overflow suffix." + (ebox-test--reset-runtime-state) + (let* ((fixture (ebox-test--nested-width-containment-layout 5)) + (layout (plist-get fixture :layout)) + (target (plist-get fixture :target)) + (owner (plist-get fixture :owner)) + (target-region-id (car (ebox-region-ids target))) + (owner-node-id (ebox--ensure-node-id owner))) + (ebox-put target :content "line 1\nline 2\nline 3\nline 4") + (ebox-put target :height 2) + (ebox-put target :overflow 'visible) + (ebox-test--with-rendered-buffer layout + (should-not (ebox--definite-containment-formatting-context-p + (current-buffer) owner-node-id)) + (ebox-region-update target-region-id :width '(280)) + (let ((report (ebox-test--buffer-update-report))) + (should-not (equal (plist-get report :owner-ids) + (list owner-node-id))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-visible-overflow-flex-wrapper-disables-width-containment () + "A visible-overflow flex wrapper must not contain child width changes." + (ebox-test--reset-runtime-state) + (let* ((target + (ebox-create :content "child" :width '(190) + :height 5 :min-height 5)) + (owner + (ebox-flex :width '(300) :height 2 :overflow 'visible + :align-items 'flex-start target)) + (layout + (ebox-stack owner + (ebox-create :content "footer" :width '(300) + :height 2))) + (owner-node-id (ebox--ensure-node-id owner))) + (ebox-test--with-rendered-buffer layout + (should (= (ebox-string-height (ebox-render owner)) 5)) + (should-not (ebox--definite-containment-formatting-context-p + (current-buffer) owner-node-id))))) + +(ert-deftest ebox-fixed-flex-context-contains-child-block-size-update () + "A fixed flex should contain a child block-size change in its five lines." + (ebox-test--reset-runtime-state) + (let* ((fixture (ebox-test--nested-width-containment-layout 5)) + (layout (plist-get fixture :layout)) + (target (plist-get fixture :target)) + (target-region-id (car (ebox-region-ids target))) + (owner-node-id + (ebox--ensure-node-id (plist-get fixture :owner)))) + (ebox-test--with-rendered-buffer layout + (let ((root-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox-region-update target-region-id :height 4) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (equal (plist-get report :owner-ids) + (list owner-node-id))) + (should (eq (plist-get report :owner-type) 'flex)) + (should (= (plist-get report :span-count) 5)) + (should-not (member root-id (plist-get report :owner-ids)))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-fixed-flex-own-width-change-skips-containment-span-probe () + "A fixed flex must not contain a change to its own outer width." + (ebox-test--reset-runtime-state) + (let* ((fixture (ebox-test--nested-width-containment-layout 5)) + (layout (plist-get fixture :layout)) + (owner (plist-get fixture :owner)) + (owner-region-id (car (ebox-region-ids owner))) + (span-patch-calls 0) + (original-span-patch + (symbol-function 'ebox-buffer-apply-span-patch))) + (ebox-test--with-rendered-buffer layout + (cl-letf (((symbol-function 'ebox-buffer-apply-span-patch) + (lambda (&rest args) + (cl-incf span-patch-calls) + (apply original-span-patch args)))) + (ebox-region-update owner-region-id :width '(320))) + (should (= span-patch-calls 0)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-flex-main-footprint-change-skips-flex-span-probe () + "Flex main footprint changes should not render a large flex owner twice." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(column :id root :width (520) :padding (1 (18)) + (flex :id matrix :flex-flow (row wrap) + :column-gap (12) :row-gap 1 :padding (0 (12)) + :border "#999999" + (column :width (210) :border "#999999" + (box :content "Title" :width (190)) + (box :id target :content "width target\nsame height" + :width (160) :height 3 + :padding (0 (8)) :border "#999999") + (box :content "Metric" :width (190))) + (column :width (210) :border "#999999" + (box :content "Other" :width (190)) + (box :content "Still here" :width (190))))))) + (target (car (ebox-selector-query-all layout "#target"))) + (target-id (plist-get target :region-id)) + (span-patch-calls 0) + (original-span-patch (symbol-function 'ebox-buffer-apply-span-patch))) + (ebox-test--with-rendered-buffer layout + (cl-letf (((symbol-function 'ebox-buffer-apply-span-patch) + (lambda (&rest args) + (cl-incf span-patch-calls) + (apply original-span-patch args)))) + (ebox-region-update target-id + :width '(210) + :min-width '(150) + :max-width '(260))) + (let ((report (ebox-test--buffer-update-report))) + (should (= span-patch-calls 0)) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should-not (plist-get report :root-rerender)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(defun ebox-test--flex-line-width-owner-layout (&optional count width) + "Return a wide flex layout whose first row has a nested width target." + (let* ((count (or count 12)) + (resolved-width (if (eq width :auto) + nil + (or width '(2600)))) + (target + (ebox-create :id "target" :content "width target\nmoves sibling layout" + :width '(160) :height 3 :padding '(0 (8)) + :border "#999999")) + (first + (ebox-column + (ebox-create :content "Title" :width '(190)) + target + (ebox-create :content "Metric" :width '(190) :height 4)))) + (apply #'ebox-flex + (append (list :flex-flow '(row wrap) + :column-gap '(12) + :row-gap 1 + :padding '(0 (12)) + :border "#999999") + (when resolved-width + (list :width resolved-width)) + (list first) + (cl-loop for index from 1 below count + collect + (ebox-column + (ebox-create + :content (format "Title %02d" index) + :width '(190)) + (ebox-create :content "Peer\nsame row" + :width '(190) :height 3 + :padding '(0 (8)) + :border "#999999") + (ebox-create :content "Metric" + :width '(190) :height 4))))))) + +(ert-deftest ebox-flex-line-rerender-report-exposes-fast-path () + "Reports should expose when an owner rerender only patched one flex row." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--flex-line-width-owner-layout))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))) + (ebox-region-update region-id + :width '(210) + :min-width '(150) + :max-width '(260)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (eq (plist-get report :owner-type) 'flex)) + (should (plist-get report :flex-line-rerender)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-flex-line-rerender-skips-auto-width-owner () + "Auto-width flex owners should fall back instead of slicing a moving suffix." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--flex-line-width-owner-layout 12 :auto))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))) + (ebox-region-update region-id + :width '(210) + :min-width '(150) + :max-width '(260)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (eq (plist-get report :owner-type) 'flex)) + (should-not (plist-get report :flex-line-rerender)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-flex-line-rerender-avoids-all-child-span-snapshots () + "Flex row rerender should locate and render one row without all-child fanout." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--flex-line-width-owner-layout 16))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) + (unrelated-child-id + (ebox--ensure-node-id + (car (last (ebox--node-children layout))))) + (span-snapshot-calls 0) + (measure-calls 0) + (original (symbol-function 'ebox--ensure-layout-snapshot-spans)) + (original-measure (symbol-function 'ebox--flex-measure-source))) + (cl-letf (((symbol-function 'ebox--ensure-layout-snapshot-spans) + (lambda (&rest args) + (cl-incf span-snapshot-calls) + (apply original args))) + ((symbol-function 'ebox--flex-measure-source) + (lambda (&rest args) + (cl-incf measure-calls) + (apply original-measure args)))) + (ebox-region-update region-id + :width '(210) + :min-width '(150) + :max-width '(260))) + (let ((report (ebox-test--buffer-update-report))) + (should (plist-get report :flex-line-rerender)) + (should (<= span-snapshot-calls 20)) + (should (< measure-calls 16)) + (should + (plist-get + (ebox--ensure-layout-snapshot-spans + (current-buffer) unrelated-child-id) + :buffer-spans)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(defun ebox-test--dynamic-update-row-layout () + "Return a small flex-row layout shaped like the dynamic update matrix." + (ebox-build + '(column :id root :width (1200) :padding (1 (18)) + (flex :id matrix :flex-flow (row wrap) + :column-gap (12) :row-gap 1 :padding (0 (12)) + :border "#999999" + (column :width (210) :border "#999999" + (box :content "Reference\nshort" :width (190)) + (box :id scroll :content "line 1\nline 2\nline 3\nline 4" + :width (190) :height 2 :padding (0 (8)) + :border "#999999" :overflow scroll) + (box :content "Metric" :width (190) :height 4)) + (column :width (210) :border "#999999" + (box :content "Height\nowner" :width (190)) + (box :id height :content "height target\nexpands block" + :width (190) :height 2 :padding (0 (8)) + :border "#999999") + (box :content "Metric" :width (190) :height 4)) + (column :width (210) :border "#999999" + (box :content "Margin\nslot stable" :width (190)) + (box :id margin :content "margin target\nparent placement" + :width (170) :height 3 :padding (0 (8)) + :border "#999999") + (box :content "Metric" :width (190) :height 4)) + (column :width (210) :border "#999999" + (box :content "Overflow\nowner" :width (190)) + (box :id overflow :content "overflow target\nextra line\nanother line" + :width (190) :height 2 :padding (0 (8)) + :border "#999999" :overflow scroll) + (box :content "Metric" :width (190) :height 4)) + (column :width (210) :border "#999999" + (box :content "Tall peer\nkeeps row slot" :width (190)) + (box :content "tall\npeer\nsets\nline\ncross" :width (190) + :height 5 :padding (0 (8)) :border "#999999") + (box :content "Metric" :width (190) :height 4)))))) + +(defun ebox-test--dynamic-update-tight-row-layout () + "Return a flex-row layout whose target growth exceeds its old item slot." + (ebox-build + '(column :id root :width (720) + (box :content "Header" :width (720)) + (column :id panel :width (720) :border "#999999" + (flex :id matrix :flex-flow (row wrap) + :column-gap (12) :row-gap 1 :padding (0 (12)) + :border "#999999" + (column :width (210) :border "#999999" + (box :content "Height\nowner" :width (190)) + (box :id height :content "height target\nexpands block" + :width (190) :height 2 :padding (0 (8)) + :border "#999999") + (box :content "Metric" :width (190) :height 2)) + (column :width (210) :border "#999999" + (box :content "Peer" :width (190)) + (box :content "peer\nsame row" :width (190) + :height 2 :padding (0 (8)) :border "#999999") + (box :content "Metric" :width (190) :height 2))))))) + +(defun ebox-test--selector-region-id (buffer selector) + "Return the first editable region id matching SELECTOR in BUFFER." + (plist-get (car (ebox-selector-query-buffer buffer selector)) :region-id)) + +(defun ebox-test--visual-line-signature (line) + "Return LINE's visible glyph positions and total pixel width." + (let ((pixel 0) + glyphs) + (cl-loop for index below (length line) + for next = (1+ index) + for char = (aref line index) + for width = (ebox--substring-pixel-width line index next) + do (progn + (unless (eq char ?\s) + (push (cons pixel char) glyphs)) + (setq pixel (+ pixel width)))) + (list :width pixel :glyphs (nreverse glyphs)))) + +(defun ebox-test--rendered-visually-equal-p (actual expected) + "Return non-nil when ACTUAL and EXPECTED have the same visible layout." + (let ((actual-lines (ebox-string-lines actual)) + (expected-lines (ebox-string-lines expected)) + (ok t)) + (setq ok (= (length actual-lines) (length expected-lines))) + (while (and ok actual-lines expected-lines) + (setq ok (equal (ebox-test--visual-line-signature (car actual-lines)) + (ebox-test--visual-line-signature (car expected-lines))) + actual-lines (cdr actual-lines) + expected-lines (cdr expected-lines))) + ok)) + +(defun ebox-test--buffer-visually-matches-runtime-render-p () + "Return non-nil when current buffer visually equals its runtime root render." + (let* ((ebox--scroll-global-state (make-hash-table :test 'equal)) + (ebox--region-box-table (make-hash-table :test 'equal)) + (expected + (ebox--with-buffer-render-context (current-buffer) + (ebox-render (ebox--buffer-root-node (current-buffer)))))) + (ebox-test--rendered-visually-equal-p + (buffer-substring (point-min) (point-max)) + expected))) + +(defun ebox-test--buffer-propertized-matches-runtime-render-p () + "Return non-nil when the current buffer exactly matches a runtime render." + (let* ((ebox--scroll-global-state (make-hash-table :test 'equal)) + (ebox--region-box-table (make-hash-table :test 'equal)) + (expected + (ebox--with-buffer-render-context (current-buffer) + (ebox-render (ebox--buffer-root-node (current-buffer)))))) + (equal-including-properties + (buffer-substring (point-min) (point-max)) expected))) + +(ert-deftest ebox-scroll-offset-update-uses-scroll-fast-path () + "Programmatic scroll-offset updates should replace visible lines without render." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-test--dynamic-update-row-layout)) + render-calls) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#scroll")) + (original-render (symbol-function 'ebox-render))) + (setq render-calls 0) + (cl-letf (((symbol-function 'ebox-render) + (lambda (&rest args) + (cl-incf render-calls) + (apply original-render args)))) + (ebox-region-update region-id :scroll-offset 1)) + (let ((report (ebox-test--buffer-update-report)) + (plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (= render-calls 0)) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :scroll-fast-path)) + (should (string-match-p "line 2" plain)) + (should-not (string-match-p "line 1" plain))))))) + +(ert-deftest ebox-dynamic-update-hot-paths-use-region-owner-index () + "Paint, geometry, scroll, and batch updates should avoid region tree walks." + (ebox-test--reset-runtime-state) + (let* ((paint (ebox-create :content "Paint" :width '(80) + :color "#111111")) + (geometry (ebox-create :content "Geometry" :width '(80))) + (scroll (ebox-create :content "line 1\nline 2\nline 3" + :width '(80) :height 1 :overflow 'scroll)) + (batch (ebox-create :content "Batch" :width '(80) + :color "#111111")) + (flex (ebox-flex :width '(120) :padding 1 :color "#111111" + (ebox-create :content "Flex" :width '(80)))) + (flex-wrapper (plist-get flex :box)) + (paint-id (ebox--ensure-region-id paint)) + (geometry-id (ebox--ensure-region-id geometry)) + (scroll-id (ebox--ensure-region-id scroll)) + (batch-id (ebox--ensure-region-id batch)) + (flex-wrapper-id (ebox--ensure-region-id flex-wrapper)) + (layout (ebox-column paint geometry scroll batch flex))) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (region-node-table (ebox--buffer-region-node-table buffer)) + (original-path + (symbol-function 'ebox--node-path-to-region)) + (recursive-calls 0)) + (cl-letf (((symbol-function 'ebox--node-path-to-region) + (lambda (&rest args) + (cl-incf recursive-calls) + (apply original-path args)))) + (ebox-region-update paint-id :color "#DC2626") + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'paint-patch)) + (should (= recursive-calls 0)) + + (ebox-region-update geometry-id :width '(100)) + (should (memq :width + (plist-get (ebox-test--buffer-update-report) :dirty-keys))) + (should (= recursive-calls 0)) + + (ebox-region-update scroll-id :scroll-offset 1) + (should (plist-get (ebox-test--buffer-update-report) :scroll-fast-path)) + (should (= recursive-calls 0)) + + (ebox-region-update flex-wrapper-id :color "#7C3AED") + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'paint-patch)) + (should (equal (car (plist-get (ebox-test--buffer-update-report) :owner-ids)) + (ebox--ensure-node-id flex))) + (should (= recursive-calls 0)) + + (ebox-incremental-begin-batch buffer) + (ebox-region-update batch-id :color "#2563EB") + (ebox-incremental-flush buffer) + (should (= (plist-get (ebox-test--buffer-update-report) :flush-count) 1)) + (should (= recursive-calls 0)) + + (ebox--refresh-buffer-layout-snapshots buffer) + (should (= recursive-calls 0))) + (should (eq (ebox--buffer-region-node-table buffer) + region-node-table)))))) + +(ert-deftest ebox-scroll-offset-fast-path-passes-old-offset () + "Programmatic scroll updates should preserve the old offset for sliding." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-row-layout)) + seen-old-offset + (forced-window-updates 0)) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#scroll"))) + (cl-letf (((symbol-function + 'ebox--scroll-replace-rendered-content-window) + (lambda (_state _region-id old-offset) + (setq seen-old-offset old-offset) + (list (cons (point-min) (point-min))))) + ((symbol-function 'force-window-update) + (lambda (&optional _object) + (cl-incf forced-window-updates)))) + (ebox-region-update region-id :scroll-offset 1)) + (should (= seen-old-offset 0)) + (should (= forced-window-updates 0)))))) + +(ert-deftest ebox-scroll-offset-fast-path-uses-content-markers () + "Scroll updates should replace cached visible content spans directly." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-row-layout))) + (ebox-test--with-rendered-buffer layout + (let* ((region-id (ebox-test--selector-region-id (current-buffer) + "#scroll")) + (state (ebox--scroll-get-state region-id)) + (scan-replace-count 0) + (original-replace (symbol-function 'ebox--region-replace-line))) + (should (plist-get state :content-span-markers)) + (cl-letf (((symbol-function 'ebox--region-replace-line) + (lambda (&rest args) + (cl-incf scan-replace-count) + (apply original-replace args)))) + (ebox-region-update region-id :scroll-offset 1)) + (let ((report (ebox-test--buffer-update-report))) + (should (plist-get report :scroll-fast-path)) + (should (= scan-replace-count 0))))))) + +(ert-deftest ebox-scroll-offset-fast-path-preserves-line-display-properties () + "Scroll updates must not spread one alignment space across the new line." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id scroll + :content "第 1 行\n第 2 行\n第 3 行\n第 4 行" + :width (230) :height 2 :padding (0 (10)) + :border "#9CA3AF" :bgcolor "#F8FAFC" + :color "#3D3A35" :text-align center + :overflow scroll :scroll-offset 0)))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#scroll")) + forced-buffer + (nondestructive-replacements 0) + (original-replace-region-contents + (symbol-function 'replace-region-contents))) + (cl-letf (((symbol-function 'replace-region-contents) + (lambda (&rest args) + (cl-incf nondestructive-replacements) + (apply original-replace-region-contents args))) + ((symbol-function 'force-window-update) + (lambda (&optional object) + (setq forced-buffer object)))) + (ebox-region-update region-id :scroll-offset 2)) + (should (plist-get (ebox-test--buffer-update-report) :scroll-fast-path)) + (should (> nondestructive-replacements 0)) + (should-not forced-buffer) + (should-not + (cl-loop for pos from (point-min) below (point-max) + thereis (and (not (eq (char-after pos) ?\s)) + (get-text-property pos 'display)))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-scroll-offset-fast-path-requires-stable-marker-lengths () + "Scroll marker patches should not shift later buffer positions." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id scroll :content + "x\nsubstantially longer visible line" + :width (180) :height 1 :overflow scroll + :scroll-offset 0)))) + (ebox-test--with-rendered-buffer layout + (let* ((region-id (ebox-test--selector-region-id (current-buffer) + "#scroll")) + (state (ebox--scroll-get-state region-id)) + (lines (plist-get state :content-lines))) + (should (not (= (length (nth 0 lines)) + (length (nth 1 lines))))) + (ebox-region-update region-id :scroll-offset 1) + (let ((report (ebox-test--buffer-update-report))) + (should-not (plist-get report :scroll-fast-path)) + (let ((state (ebox-scroll-state region-id))) + (should (= (plist-get state :scroll-offset) 1)) + (should-not (plist-get state :lazy-scroll-prefix-dirty)) + (should-not + (plist-get state :lazy-scroll-window-refresh-required))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-nested-scroll-offset-transaction-replays-outer-cache () + "Nested scroll updates should atomically survive outer cache replay." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id root :width (180) :height 2 :overflow scroll + (column + (box :id inner :width (180) :height 2 :overflow scroll + :content "inner 0\ninner 1\ninner 2\ninner 3") + (box :content "outer 0\nouter 1" :height 2))))) + root-id inner-id) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root") + inner-id (ebox-test--selector-region-id (current-buffer) "#inner")) + (ebox-region-update inner-id :scroll-offset 1) + (let ((report (ebox-test--buffer-update-report)) + (state (ebox-scroll-state inner-id)) + (plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (= (plist-get state :scroll-offset) 1)) + (should (string-match-p "inner 1" plain)) + (should (string-match-p "inner 2" plain)) + (should-not (string-match-p "inner 0" plain))) + (should (ebox--scroll-region-by root-id 2 48)) + (should (string-match-p + "outer 0" + (buffer-substring-no-properties (point-min) (point-max)))) + (should (ebox--scroll-region-by root-id -2 48)) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "inner 1" plain)) + (should (string-match-p "inner 2" plain)) + (should-not (string-match-p "inner 0" plain))) + (should (= (plist-get (ebox-scroll-state inner-id) :scroll-offset) 1)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-scroll-content-window-declines-horizontal-gaps () + "Bulk scroll replacement should leave padded line gaps to marker fallback." + (ebox-test--reset-runtime-state) + (with-temp-buffer + (let ((region-id 7001) + starts ends state + (marker-replacements 0)) + (dolist (entry '(("AA" "x" "PP\n") ("AA" "y" "PP"))) + (insert (car entry)) + (push (point) starts) + (insert (propertize (cadr entry) + 'ebox-content region-id + 'ebox-content-idx (1- (length starts)))) + (push (point) ends) + (insert (caddr entry))) + (setq starts (nreverse starts) + ends (nreverse ends)) + (let ((markers + (apply #'vector + (cl-mapcar (lambda (start end) + (cons (copy-marker start nil) + (copy-marker end t))) + starts ends)))) + (setq state (list :region-id region-id + :buffer (current-buffer) + :content-span-markers markers)) + (unwind-protect + (progn + (puthash region-id state ebox--scroll-global-state) + (should-not (ebox--scroll-content-window state 2)) + (let ((before-size (buffer-size)) + (original + (symbol-function 'ebox--scroll-replace-content-marker))) + (cl-letf (((symbol-function 'ebox--scroll-replace-content-marker) + (lambda (&rest args) + (cl-incf marker-replacements) + (apply original args)))) + (should (ebox--scroll-update-content + region-id '("m" "n")))) + (should (= marker-replacements 2)) + (should (= (buffer-size) before-size)) + (should (equal (buffer-substring-no-properties + (point-min) (point-max)) + "AAmPP\nAAnPP")))) + (remhash region-id ebox--scroll-global-state) + (dotimes (idx (length markers)) + (let ((span (aref markers idx))) + (set-marker (car span) nil) + (set-marker (cdr span) nil)))))))) + +(ert-deftest ebox-scroll-child-layout-window-patches-without-rerender () + "Scroll boxes made from child layouts should patch the fixed viewport." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :height 2 :overflow scroll + (column + (box :id "a" :content "A" :height 1) + (box :id "b" :content "B" :height 1) + (box :id "c" :content "C" :height 1) + (box :id "d" :content "D" :height 1))))) + (root-id (car (ebox-region-ids layout))) + rerender-called) + (ebox-test--with-rendered-buffer layout + (let* ((state (ebox--scroll-get-state root-id)) + (markers (ebox--scroll-content-marker-spans + (plist-get state :content-span-markers)))) + (should (= (plist-get state :content-height) 2)) + (should (= (length markers) 2))) + (cl-letf (((symbol-function 'ebox--rerender-box) + (lambda (&rest _args) + (setq rerender-called t) + nil)) + ((symbol-function 'ebox--scroll-replace-rendered-window) + (lambda (&rest _args) + (error "unexpected rendered-window fallback")))) + (should (ebox--scroll-region-by root-id 1))) + (should-not rerender-called) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (let ((plain (buffer-substring-no-properties + (point-min) (point-max)))) + (should (= (length (ebox-string-lines plain)) 2)) + (should (string-match-p "B" plain)) + (should (string-match-p "C" plain)) + (should-not (string-match-p "A" plain)))))) + +(ert-deftest ebox-scroll-child-layout-window-keeps-viewport-padding-and-border () + "Fast scroll window patches should keep scroll box padding and border." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :height 4 :box-sizing border-box + :overflow scroll + :padding-top 1 :padding-bottom 1 + :border-top-p t :border-top-color "red" + :border-bottom-p t :border-bottom-color "blue" + (column + (box :id "a" :content "A" :height 1) + (box :id "b" :content "B" :height 1) + (box :id "c" :content "C" :height 1) + (box :id "d" :content "D" :height 1))))) + (root-id (car (ebox-region-ids layout))) + rerender-called) + (ebox-test--with-rendered-buffer layout + (should (plist-get (ebox--scroll-get-state root-id) + :rendered-content-lines)) + (cl-letf (((symbol-function 'ebox--rerender-box) + (lambda (&rest _args) + (setq rerender-called t) + nil))) + (should (ebox--scroll-region-by root-id 1))) + (should-not rerender-called) + (should (equal (get-text-property (point-min) 'ebox-bt) + root-id)) + (save-excursion + (goto-char (point-min)) + (forward-line 3) + (should (equal (get-text-property (point) 'ebox-bb) + root-id))) + (should (equal (get-text-property (point-min) 'ebox-pt) + root-id)) + (save-excursion + (goto-char (point-min)) + (forward-line 3) + (should (equal (get-text-property (point) 'ebox-pb) + root-id))) + (let ((plain (buffer-substring-no-properties + (point-min) (point-max)))) + (should (= (length (ebox-string-lines plain)) 4)) + (should (string-match-p "B" plain)) + (should (string-match-p "C" plain)) + (should-not (string-match-p "A" plain)))))) + +(ert-deftest ebox-flex-row-height-update-rerenders-slot-owner-not-root () + "Flex row item height changes should patch the old row slot, not the root." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-row-layout))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#height")) + (root-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox-region-update region-id + :height 4 :min-height 3 :max-height 6 + :padding-top 1 :padding-bottom 1) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should-not (plist-get report :root-rerender)) + (should-not (member root-id (plist-get report :owner-ids))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-flex-row-height-growth-rerenders-flex-ancestor-not-root () + "Flex row item growth beyond its old slot should use a flex ancestor fallback." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-tight-row-layout))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#height")) + (root-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox-region-update region-id + :height 4 :min-height 3 :max-height 6 + :padding-top 1 :padding-bottom 1) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should-not (plist-get report :root-rerender)) + (should-not (member root-id (plist-get report :owner-ids))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-flex-row-inline-margin-update-uses-slot-span-patch () + "Inline margin changes inside a stable flex row slot should stay span-local." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-row-layout))) + (ebox-test--with-rendered-buffer layout + (let ((height-id (ebox-test--selector-region-id (current-buffer) + "#height")) + (region-id (ebox-test--selector-region-id (current-buffer) + "#margin")) + (root-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox-region-update height-id + :height 4 :min-height 3 :max-height 6 + :padding-top 1 :padding-bottom 1) + (ebox-region-update region-id + :margin (list 0 '(8) 0 '(8)) + :margin-inline '((8) (8)) + :margin-block '(0 0) + :margin-top 0 :margin-right '(8) + :margin-bottom 0 :margin-left '(8)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (eq (plist-get report :owner-type) 'box)) + (should (plist-get report :slot-preserving)) + (should-not (plist-get report :root-rerender)) + (should-not (member root-id (plist-get report :owner-ids))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-border-box-padding-impact-stays-internal-fragment () + "Fixed border-box padding changes should remain an internal fragment impact." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id root :width (480) + (flex :id row :flex-flow (row nowrap) :column-gap (12) + (box :id padding :content "padding target" + :width (190) :height 3 :box-sizing border-box + :padding (0 (8)) :border "#999999") + (box :content "peer" :width (190) :height 3 + :border "#999999")))))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#padding"))) + (ebox-region-update region-id :padding-left (list 24)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (member 'internal-fragment + (plist-get report :impact-vector))) + (should-not (member 'parent-layout + (plist-get report :impact-vector))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-border-box-padding-shorthand-uses-one-local-span-patch () + "Mixed block/inline padding shorthand should not promote to a root rerender." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id root :width (480) + (flex :id row :flex-flow (row nowrap) :column-gap (12) + (box :id padding :content "padding target" + :width (190) :height 4 :box-sizing border-box + :padding (0 (8)) :border "#999999") + (box :content "peer" :width (190) :height 4 + :border "#999999")))))) + (ebox-test--with-rendered-buffer layout + (let* ((match (car (ebox-selector-query-buffer + (current-buffer) "#padding"))) + (node-id (ebox--ensure-node-id (plist-get match :node))) + (region-id (plist-get match :region-id)) + (mixed-entry + (list :node-id node-id :dirty-kind 'geometry + :changed-keys + '(:padding-top-height :padding-right-pixel + :padding-bottom-height :padding-left-pixel)))) + (should + (ebox--dirty-entry-border-box-internal-span-p + (current-buffer) mixed-entry)) + (ebox-region-update region-id :padding '(2 (20) 0 (8))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (equal (plist-get report :patch-ops) '(span-patch))) + (should-not (plist-get report :root-rerender)) + (should (member 'internal-fragment + (plist-get report :impact-vector))) + (should (member 'external-footprint-proof + (plist-get report :impact-vector))) + (should-not (member 'parent-layout + (plist-get report :impact-vector))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-border-box-padding-span-proof-rejects-overfull-height () + "Border-box padding cannot claim a fixed footprint without a content line." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id padding :content "padding target" + :width (190) :height 4 :box-sizing border-box + :padding 2 :border "#999999")))) + (ebox-test--with-rendered-buffer layout + (let* ((match (car (ebox-selector-query-buffer + (current-buffer) "#padding"))) + (node-id (ebox--ensure-node-id (plist-get match :node)))) + (let ((entry + (list :node-id node-id :dirty-kind 'geometry + :changed-keys + '(:padding-top-height :padding-right-pixel + :padding-bottom-height :padding-left-pixel)))) + (should-not + (ebox--dirty-entry-border-box-internal-span-p + (current-buffer) entry))))))) + +(ert-deftest ebox-overflow-impact-keeps-conservative-owner-rerender () + "Overflow changes should stay conservative until clipping proof exists." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-row-layout))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#overflow"))) + (ebox-region-update region-id :overflow 'hidden) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (member 'clipping (plist-get report :impact-vector))) + (should (member 'external-footprint + (plist-get report :impact-vector))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-owner-rerender-preserves-missing-wrapper-context () + "Nested owner rerenders should retain outer paint and surface metadata." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-row-layout))) + (ebox-put layout :color "#1F2328") + (ebox-put layout :bgcolor "#FAF7F0") + (ebox-put layout :surface-properties + '(ebox-test-role outer-wrapper)) + (ebox-put (plist-get (car (ebox--node-children layout)) :box) + :bgcolor "#FBF9FF") + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id + (current-buffer) "#overflow"))) + (ebox-region-update region-id :overflow 'visible) + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'owner-rerender)) + (goto-char (point-min)) + (search-forward "overflow target") + (let ((face (get-text-property (match-beginning 0) 'face))) + (should (ebox-test--face-has-key-value-p + face :foreground "#1F2328")) + (should (ebox-test--face-has-key-value-p + face :background "#FBF9FF")) + (should + (eq (get-text-property + (match-beginning 0) 'ebox-test-role) + 'outer-wrapper))))))) + +(ert-deftest ebox-owner-rerender-preserves-descendant-region-ownership () + "Recursive owner rerenders should keep each descendant's stable region id." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id "ownership-parent" :width (240) :padding 1 + (box :id "ownership-child" :content "child ownership" + :width (120) :height 2 :border "#2563EB"))))) + (ebox-test--with-rendered-buffer layout + (let ((parent-id (ebox-test--selector-region-id + (current-buffer) "#ownership-parent")) + (child-id (ebox-test--selector-region-id + (current-buffer) "#ownership-child"))) + (should (text-property-any + (point-min) (point-max) 'ebox-bl child-id)) + ;; This test targets recursive rendering ownership. Supply the + ;; contiguous owner extent directly so scanner eligibility is an + ;; independent concern. + (ebox--set-box-extents parent-id (point-min) (point-max)) + (ebox--rerender-box parent-id) + (should (text-property-any + (point-min) (point-max) 'ebox-bl child-id)) + (should (text-property-any + (point-min) (point-max) 'ebox-content child-id)) + (should-not (text-property-any + (point-min) (point-max) 'ebox-bl parent-id)))))) + +(ert-deftest ebox-visible-scroll-content-update-preserves-wrapper-paint () + "Visible cached content patches should preserve nested wrapper paint." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id "root" :height 1 :overflow scroll + (column + (box :id "wrapper" :height 1 :overflow scroll + :bgcolor "#E2E8F0" + (column + (box :id "target" :content "old" :width (80) :height 1 + :border "#64748B" :bgcolor "#1D4ED8") + (box :content "inner tail" :height 1))) + (box :content "outer tail" :height 1)))))) + (ebox-test--with-rendered-buffer layout + (let ((root-id (ebox-test--selector-region-id + (current-buffer) "#root")) + (wrapper-id (ebox-test--selector-region-id + (current-buffer) "#wrapper")) + (target-id (ebox-test--selector-region-id + (current-buffer) "#target")) + (owner-set-scans 0)) + (should (plist-get (ebox-scroll-state root-id) + :rendered-content-lines)) + (should (plist-get (ebox-scroll-state wrapper-id) + :rendered-content-lines)) + (let ((original + (symbol-function 'ebox--string-content-owner-set))) + (cl-letf (((symbol-function 'ebox--string-content-owner-set) + (lambda (&rest args) + (cl-incf owner-set-scans) + (apply original args)))) + (ebox-region-update target-id :content "new"))) + (let* ((report (ebox-test--buffer-update-report)) + (border-pos (text-property-any + (point-min) (point-max) 'ebox-bl target-id)) + (content-pos (text-property-any + (point-min) (point-max) + 'ebox-content target-id)) + (border-face (and border-pos + (get-text-property border-pos 'face))) + (content-face (and content-pos + (get-text-property content-pos 'face))) + (content-face-specs + (if (and (listp content-face) + (keywordp (car content-face))) + (list content-face) + content-face)) + (effective-content-background + (cl-loop for spec in content-face-specs + when (and (listp spec) + (plist-member spec :background)) + return (plist-get spec :background)))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :scroll-state-patch)) + (should (plist-get report :scroll-rendered-range-patch)) + (should (= (plist-get report :span-count) 2)) + ;; This is a nested span patch, not a complete root replacement. + ;; The one owner scan is required to preserve wrapper paint. + (should (= owner-set-scans 1)) + (should border-pos) + (should content-pos) + (should (ebox-test--face-has-key-value-p + border-face :background "#E2E8F0")) + (should (member wrapper-id + (get-text-property + border-pos 'ebox-content-owners))) + (should (ebox-test--face-has-key-value-p + content-face :background "#E2E8F0")) + (should (equal effective-content-background "#1D4ED8")) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-contiguous-replacement-preserves-common-wrapper-paint () + "Contiguous fallback strings should retain common wrapper paint." + (ebox-test--reset-runtime-state) + (let* ((wrapper (ebox-create :content "" :color "#334155" + :bgcolor "#E2E8F0")) + (wrapper-id (car (ebox-region-ids wrapper)))) + (puthash wrapper-id wrapper ebox--region-box-table) + (with-temp-buffer + (insert (propertize "old-a" 'ebox-content-owners (list wrapper-id))) + (insert "\n") + (insert (propertize "old-b" 'ebox-content-owners (list wrapper-id))) + (insert "\n") + (let* ((spans (save-excursion + (goto-char (point-min)) + (list (cons (line-beginning-position) + (line-end-position)) + (progn + (forward-line 1) + (cons (line-beginning-position) + (line-end-position)))))) + (new-spans (ebox--replace-line-spans + spans "new-a\nnew-b\nnew-c"))) + (should (equal (buffer-string) "new-a\nnew-b\nnew-c\n")) + (should (= (length new-spans) 3)) + (dolist (text '("new-a" "new-b" "new-c")) + (goto-char (point-min)) + (search-forward text) + (let* ((pos (match-beginning 0)) + (face (get-text-property pos 'face))) + (should (equal (get-text-property + pos 'ebox-content-owners) + (list wrapper-id))) + (should (ebox-test--face-has-key-value-p + face :foreground "#334155")) + (should (ebox-test--face-has-key-value-p + face :background "#E2E8F0")))))))) + +(ert-deftest ebox-contiguous-replacement-rejects-noncommon-wrapper-paint () + "Contiguous fallback should not spread paint across ownerless spans." + (ebox-test--reset-runtime-state) + (let* ((wrapper (ebox-create :content "" :color "#334155" + :bgcolor "#E2E8F0")) + (wrapper-id (car (ebox-region-ids wrapper)))) + (puthash wrapper-id wrapper ebox--region-box-table) + (with-temp-buffer + (insert (propertize "owned" 'ebox-content-owners (list wrapper-id))) + (insert "\nownerless\n") + (let* ((spans (save-excursion + (goto-char (point-min)) + (list (cons (line-beginning-position) + (line-end-position)) + (progn + (forward-line 1) + (cons (line-beginning-position) + (line-end-position)))))) + (new-spans (ebox--replace-line-spans + spans "new-a\nnew-b\nnew-c"))) + (should (= (length new-spans) 3)) + (goto-char (point-min)) + (search-forward "new-c") + (let ((pos (match-beginning 0))) + (should-not (get-text-property pos 'ebox-content-owner)) + (should-not (get-text-property pos 'ebox-content-owners)) + (should-not (get-text-property pos 'face))))))) + +(ert-deftest ebox-replacement-boundary-scans-owner-runs-inside-long-span () + "A long replacement span should classify owner runs below its boundary." + (with-temp-buffer + (insert (propertize "aaabbbccc" 'ebox-content-owners '(outer))) + (let ((start (point-min)) + (end (point-max))) + (add-text-properties + (+ start 3) (+ start 6) + '(ebox-content-owners (old-leaf old-boundary outer))) + (add-text-properties + (+ start 6) end + '(ebox-content-owners (other-leaf other-boundary outer))) + (let ((owners + (ebox-buffer--owners-inside-replacement-boundary + (list (cons start end)) '(old-boundary)))) + (should (member 'old-leaf owners)) + (should (member 'old-boundary owners)) + (should-not (member 'outer owners)) + (should-not (member 'other-leaf owners)) + (should-not (member 'other-boundary owners)))))) + +(ert-deftest ebox-contiguous-wrapper-preservation-keeps-horizontal-borders () + "Contiguous line replacement should retain wrapper top and bottom borders." + (ebox-test--reset-runtime-state) + (with-temp-buffer + (let* ((owner 'outer-wrapper) + (ebox--region-box-table (make-hash-table :test 'equal)) + first-start first-end second-start second-end) + (puthash owner + '(:border-top-color "#ff0000" + :border-bottom-color "#0000ff") + ebox--region-box-table) + (setq first-start (point)) + (insert (propertize "old top" 'ebox-content-owners (list owner) + 'ebox-bt owner)) + (setq first-end (point)) + (insert "\n") + (setq second-start (point)) + (insert (propertize "old bottom" 'ebox-content-owners (list owner) + 'ebox-bb owner)) + (setq second-end (point)) + (let* ((result + (ebox--string-with-preserved-wrapper-owners + (list (cons first-start first-end) + (cons second-start second-end)) + "new top\nnew middle\nnew bottom")) + (middle-start (string-match "new middle" result)) + (bottom-start (string-match "new bottom" result))) + (should (equal (get-text-property 0 'ebox-bt result) owner)) + (should-not (get-text-property middle-start 'ebox-bb result)) + (should (equal (get-text-property bottom-start 'ebox-bb result) owner)) + (should (equal (get-text-property middle-start 'ebox-content-owners result) + (list owner))) + (should + (ebox-test--face-has-key-value-p + (get-text-property 0 'face result) :overline "#ff0000")) + (should + (ebox-test--face-has-key-value-p + (get-text-property bottom-start 'face result) + :underline '(:position t :color "#0000ff"))))))) + +(ert-deftest ebox-contiguous-wrapper-expansion-splits-horizontal-borders () + "Expanding one old line should keep top and bottom paint on opposite edges." + (ebox-test--reset-runtime-state) + (with-temp-buffer + (let* ((owner 'single-line-wrapper) + (ebox--region-box-table (make-hash-table :test 'equal)) + (start (point))) + (puthash owner + '(:border-top-color "#ff0000" + :border-bottom-color "#0000ff") + ebox--region-box-table) + (insert (propertize "old" 'ebox-content-owners (list owner) + 'ebox-bt owner 'ebox-bb owner)) + (let* ((result + (ebox--string-with-preserved-wrapper-owners + (list (cons start (point))) "new top\nnew bottom")) + (bottom-start (string-match "new bottom" result)) + (top-face (get-text-property 0 'face result)) + (bottom-face (get-text-property bottom-start 'face result))) + (should (equal (get-text-property 0 'ebox-bt result) owner)) + (should-not (get-text-property 0 'ebox-bb result)) + (should (equal (get-text-property bottom-start 'ebox-bb result) owner)) + (should-not (get-text-property bottom-start 'ebox-bt result)) + (should (ebox-test--face-has-key-p top-face :overline)) + (should-not (ebox-test--face-has-key-p top-face :underline)) + (should (ebox-test--face-has-key-p bottom-face :underline)) + (should-not (ebox-test--face-has-key-p bottom-face :overline)))))) + +(ert-deftest ebox-overflow-impact-skips-span-patch-owner-probe () + "Clipping changes should not spend time proving impossible span patches." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-row-layout))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#overflow")) + (span-probe-count 0) + (original (symbol-function 'ebox--span-patchable-owner-p))) + (cl-letf (((symbol-function 'ebox--span-patchable-owner-p) + (lambda (&rest args) + (cl-incf span-probe-count) + (apply original args)))) + (ebox-region-update region-id :overflow 'hidden)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (member 'clipping (plist-get report :impact-vector))) + (should (= span-probe-count 0)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-overflow-impact-skips-patchable-owner-promotion () + "Clipping changes should not pre-prove conservative owner candidates." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-row-layout))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#overflow")) + (promote-count 0) + (original (symbol-function 'ebox--promote-to-patchable-owner))) + (cl-letf (((symbol-function 'ebox--promote-to-patchable-owner) + (lambda (&rest args) + (cl-incf promote-count) + (apply original args)))) + (ebox-region-update region-id :overflow 'visible)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (member 'clipping (plist-get report :impact-vector))) + (should (= promote-count 0)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-flex-row-visible-overflow-update-rerenders-slot-owner-not-root () + "Visible overflow that fits the existing flex row slot should not rerender root." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-row-layout))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#overflow")) + (root-id (ebox--buffer-root-node-id (current-buffer))) + (full-snapshot-refresh-count 0) + (original-refresh + (symbol-function 'ebox--refresh-buffer-layout-snapshots))) + (cl-letf (((symbol-function 'ebox--refresh-buffer-layout-snapshots) + (lambda (&rest args) + (cl-incf full-snapshot-refresh-count) + (apply original-refresh args)))) + (ebox-region-update region-id :overflow 'visible)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should-not (plist-get report :root-rerender)) + (should-not (member root-id (plist-get report :owner-ids))) + (should (plist-get report :scoped-index-refresh)) + (should (= full-snapshot-refresh-count 0)) + (let* ((node (ebox--buffer-region-render-owner-node + (current-buffer) region-id)) + (snapshot (ebox--ensure-layout-snapshot-spans + (current-buffer) (ebox--ensure-node-id node)))) + (should (ebox--layout-snapshot-spans-p snapshot)) + (should (eq (plist-get (plist-get snapshot :style-signature) + :overflow) + 'visible))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-overflow-reversal-removes-visible-suffix () + "Clipping a visible target should replace the ancestor-owned old suffix." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id root :width (760) + (flex :id matrix :width (600) :height 5 + :flex-flow (row nowrap) + (box :id card :width (300) + (box :id overflow + :content "overflow target\nextra line\nanother line" + :width (280) :height 2 :overflow scroll)) + (box :id peer :content "P\nQ\nR\nS\nT" + :width (300) :height 5)))))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#overflow")) + (root-id (ebox--buffer-root-node-id (current-buffer))) + coverage-owner-id) + (let ((full-rerenders + (ebox-test--count-full-rerenders + (ebox-region-update region-id :overflow 'visible) + (let ((report (ebox-test--buffer-update-report))) + (setq coverage-owner-id + (car (plist-get report :owner-ids))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should coverage-owner-id) + (should-not (equal coverage-owner-id root-id)) + (should (eq (plist-get report :owner-type) 'flex)) + (should (= (plist-get report :span-count) 5)) + (should-not (plist-get report :root-rerender)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))) + (ebox--clear-layout-snapshots (current-buffer)) + (ebox-region-update region-id :overflow 'hidden) + (let ((report (ebox-test--buffer-update-report)) + (plain (buffer-substring-no-properties + (point-min) (point-max)))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :owner-ids) + (list coverage-owner-id))) + (should (= (plist-get report :span-count) 5)) + (should-not (plist-get report :root-rerender)) + (should-not (string-match-p "another line" plain)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + (should (= full-rerenders 0))))))) + +(ert-deftest ebox-overflow-reversal-skips-layout-only-ancestor () + "Old visible suffix coverage should skip ancestors without wrapper ownership." + (ebox-test--reset-runtime-state) + (let* ((target (ebox-create :content "A\nB\nC" :height 1 + :overflow 'visible)) + (region-id (car (ebox-region-ids target))) + (inner (ebox-stack target (ebox-create :content "TAIL"))) + (layout (ebox-stack inner (ebox-create :content "ROOT TAIL"))) + (inner-id (ebox--ensure-node-id inner)) + (root-id (ebox--ensure-node-id layout))) + (ebox-test--with-rendered-buffer layout + (ebox--clear-layout-snapshots (current-buffer)) + (ebox-region-update region-id :overflow 'hidden) + (let ((report (ebox-test--buffer-update-report)) + (plain (buffer-substring-no-properties + (point-min) (point-max)))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should-not (member inner-id (plist-get report :owner-ids))) + (should (equal (plist-get report :owner-ids) (list root-id))) + (should-not (string-match-p "[BC]" plain)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-overflow-reversal-skips-visible-wrapper-ancestor () + "A visible wrapper cannot cover its own old unowned overflow suffix." + (ebox-test--reset-runtime-state) + (let* ((target (ebox-create :content "A\nB\nC" :height 1 + :overflow 'visible)) + (region-id (car (ebox-region-ids target))) + (wrapper (ebox-create :ebox-content-node target :width '(100) + :height 1 :overflow 'visible)) + (layout (ebox-stack wrapper (ebox-create :content "TAIL"))) + (wrapper-id (ebox--ensure-node-id wrapper)) + (root-id (ebox--ensure-node-id layout))) + (ebox-test--with-rendered-buffer layout + (ebox--clear-layout-snapshots (current-buffer)) + (ebox-region-update region-id :overflow 'hidden) + (let ((report (ebox-test--buffer-update-report)) + (plain (buffer-substring-no-properties + (point-min) (point-max)))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should-not (member wrapper-id (plist-get report :owner-ids))) + (should (equal (plist-get report :owner-ids) (list root-id))) + (should-not (string-match-p "[BC]" plain)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-overflow-coverage-crosses-enclosing-visible-wrapper () + "Coverage should climb above an enclosing visible wrapper's old footprint." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id root :width (100) + (box :id outer :width (100) :height 2 :overflow visible + (box :id inner :width (100) + (box :id target :content "A\nB\nC" :height 1 + :overflow visible))) + (box :content "TAIL"))))) + (ebox-test--with-rendered-buffer layout + (let* ((region-id (ebox-test--selector-region-id + (current-buffer) "#target")) + (target-node (ebox--buffer-region-render-owner-node + (current-buffer) region-id)) + (outer-node (plist-get (car (ebox-selector-query-buffer + (current-buffer) "#outer")) + :node)) + (root-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox--clear-layout-snapshots (current-buffer)) + (ebox-region-update region-id :overflow 'hidden) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (ebox-get (ebox-fragment-style-source-node target-node) + :overflow) + 'hidden)) + (should (eq (ebox-get (ebox-fragment-style-source-node outer-node) + :overflow) + 'visible)) + (should (equal (plist-get report :owner-ids) (list root-id))) + (should-not (plist-get report :flex-line-rerender)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-overflow-coverage-root-flex-replaces-whole-footprint () + "Root flex fallback should bypass line rerender for visible overflow changes." + (dolist (transition '((scroll visible) (visible hidden))) + (ebox-test--reset-runtime-state) + (let* ((old-overflow (car transition)) + (new-overflow (cadr transition)) + (layout + (ebox-build + `(flex :id root :width (200) + (box :id target :content "A\nB\nC" :width (80) :height 1 + :overflow ,old-overflow) + (box :id peer :content "P" :width (80) :height 1))))) + (ebox-test--with-rendered-buffer layout + (let* ((region-id (ebox-test--selector-region-id + (current-buffer) "#target")) + (target-node (ebox--buffer-region-render-owner-node + (current-buffer) region-id)) + (root-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox--clear-layout-snapshots (current-buffer)) + (ebox-region-update region-id :overflow new-overflow) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (ebox-get (ebox-fragment-style-source-node target-node) + :overflow) + new-overflow)) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :owner-ids) (list root-id))) + (should-not (plist-get report :flex-line-rerender)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))))) + +(ert-deftest ebox-overflow-coverage-promotes-partial-flex-item () + "Visible overflow changes should not preserve a flex item's stale cross size." + (dolist (transition '((scroll visible) (visible hidden))) + (ebox-test--reset-runtime-state) + (let* ((old-overflow (car transition)) + (new-overflow (cadr transition)) + (layout + (ebox-build + `(flex :id root :width (220) + (box :id card :width (100) + (box :id target :content "A\nB\nC" :width (80) :height 1 + :overflow ,old-overflow)) + (box :id peer :content "P" :width (80) :height 1))))) + (ebox-test--with-rendered-buffer layout + (let* ((region-id (ebox-test--selector-region-id + (current-buffer) "#target")) + (target-node (ebox--buffer-region-render-owner-node + (current-buffer) region-id)) + (card-id (plist-get (car (ebox-selector-query-buffer + (current-buffer) "#card")) + :node-id)) + (root-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox--clear-layout-snapshots (current-buffer)) + (ebox-region-update region-id :overflow new-overflow) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (ebox-get (ebox-fragment-style-source-node target-node) + :overflow) + new-overflow)) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should-not (member card-id (plist-get report :owner-ids))) + (should (equal (plist-get report :owner-ids) (list root-id))) + (should-not (plist-get report :flex-line-rerender)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))))) + +(ert-deftest ebox-overflow-coverage-batch-preserves-old-style () + "A batch should carry pre-mutation overflow into coverage planning." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id root :width (100) + (box :id wrapper :width (100) + (column + (box :id target :content "A\nB\nC" :height 1 + :overflow visible) + (box :content "TAIL"))) + (box :content "ROOT TAIL"))))) + (ebox-test--with-rendered-buffer layout + (let* ((region-id (ebox-test--selector-region-id + (current-buffer) "#target")) + (target-node (ebox--buffer-region-render-owner-node + (current-buffer) region-id)) + (wrapper-id (plist-get (car (ebox-selector-query-buffer + (current-buffer) "#wrapper")) + :node-id))) + (ebox--clear-layout-snapshots (current-buffer)) + (ebox-incremental-begin-batch (current-buffer)) + (ebox-region-update region-id :overflow 'hidden) + (let* ((state (ebox-incremental--batch-state (current-buffer))) + (pending (car (plist-get state :pending)))) + (should (equal (plist-get pending :old-style-values) + '(:overflow visible)))) + (ebox-incremental-flush (current-buffer)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (ebox-get (ebox-fragment-style-source-node target-node) + :overflow) + 'hidden)) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :owner-ids) (list wrapper-id))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-overflow-coverage-multi-owner-batch-normalizes-scoped-snapshots () + "Multiple owner replacements normalize shifted spans before scoped refresh." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id root :width (760) + (flex :id top-matrix :width (600) :height 5 + :flex-flow (row nowrap) + (box :id top-card :width (300) + (box :id top-target + :content "top A\nsubstantially longer top overflow\ntop C" + :width (280) :height 2 :overflow scroll)) + (box :content "top P\ntop Q\ntop R\ntop S\ntop T" + :width (300) :height 5)) + (flex :id bottom-matrix :width (600) :height 5 + :flex-flow (row nowrap) + (box :id bottom-card :width (300) + (box :id bottom-target + :content "bottom A\nbottom overflow\nbottom C" + :width (280) :height 2 :overflow scroll)) + (box :content "bottom P\nbottom Q\nbottom R\nbottom S\nbottom T" + :width (300) :height 5)))))) + (ebox-test--with-rendered-buffer layout + (let ((top-region-id + (ebox-test--selector-region-id (current-buffer) "#top-target")) + (bottom-region-id + (ebox-test--selector-region-id + (current-buffer) "#bottom-target")) + (full-snapshot-refresh-count 0) + (original-refresh + (symbol-function 'ebox--refresh-buffer-layout-snapshots))) + (ebox-incremental-begin-batch (current-buffer)) + (ebox-region-update top-region-id :overflow 'visible) + (ebox-region-update bottom-region-id :overflow 'visible) + (cl-letf (((symbol-function 'ebox--refresh-buffer-layout-snapshots) + (lambda (&rest args) + (cl-incf full-snapshot-refresh-count) + (apply original-refresh args)))) + (ebox-incremental-flush (current-buffer))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (= (plist-get report :patch-count) 2)) + (should (plist-get report :scoped-index-refresh)) + (should (= full-snapshot-refresh-count 0)) + (ebox--set-buffer-region-role-span-table (current-buffer) nil) + (ebox-buffer-refresh-region-role-spans (current-buffer)) + (dolist (owner-id (plist-get report :owner-ids)) + (let* ((node (ebox--buffer-runtime-node + (current-buffer) owner-id)) + (snapshot-spans + (plist-get (ebox--ensure-layout-snapshot-spans + (current-buffer) owner-id) + :buffer-spans)) + (actual-spans + (ebox--region-set-line-spans + (ebox--node-all-region-ids node)))) + (should (equal snapshot-spans actual-spans)))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))) + (ebox-region-update bottom-region-id :overflow 'hidden) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-span-patch-refreshes-descendant-snapshots () + "Ancestor span patches should keep descendant buffer span snapshots current." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-row-layout))) + (ebox-test--with-rendered-buffer layout + (let* ((handle (car (ebox-selector-query-buffer + (current-buffer) "#margin"))) + (region-id (plist-get handle :region-id)) + (node-id (plist-get handle :node-id)) + (dense-index-builds 0) + (eager-descendant-span-refreshes 0) + (original-index-builder + (symbol-function 'ebox--build-region-line-index-for-spans)) + (original-descendant-refresh + (symbol-function + 'ebox--capture-layout-snapshot-spans-subtree))) + (cl-letf (((symbol-function 'ebox--build-region-line-index-for-spans) + (lambda (&rest args) + (cl-incf dense-index-builds) + (apply original-index-builder args))) + ((symbol-function + 'ebox--capture-layout-snapshot-spans-subtree) + (lambda (&rest args) + (cl-incf eager-descendant-span-refreshes) + (apply original-descendant-refresh args)))) + (ebox-region-update region-id + :margin (list 0 '(8) 0 '(8)) + :margin-inline '((8) (8)) + :margin-block '(0 0) + :margin-top 0 :margin-right '(8) + :margin-bottom 0 :margin-left '(8))) + (let* ((report (ebox-test--buffer-update-report)) + (snapshot (ebox--ensure-layout-snapshot-spans + (current-buffer) node-id)) + (snapshot-spans (plist-get snapshot :buffer-spans)) + (actual-spans (ebox--region-set-line-spans + (list region-id)))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (= dense-index-builds 0)) + (should (= eager-descendant-span-refreshes 0)) + (should (equal snapshot-spans actual-spans)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-sequential-sibling-span-patches-invalidate-stale-coordinates () + "Text-changing sibling patches should not reuse translated snapshot spans." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id root :width (120) + (box :id a :content "A" :width (120) :height 1) + (box :id b :content "B" :width (120) :height 1))))) + (ebox-test--with-rendered-buffer layout + (let* ((a-handle (car (ebox-selector-query-buffer + (current-buffer) "#a"))) + (b-handle (car (ebox-selector-query-buffer + (current-buffer) "#b"))) + (a-region-id (plist-get a-handle :region-id)) + (b-region-id (plist-get b-handle :region-id)) + (b-node-id (plist-get b-handle :node-id)) + sibling-snapshot-current-p + reports) + (ebox-region-update b-region-id :content "BB") + (push (ebox-test--buffer-update-report) reports) + (ebox-region-update a-region-id :content "AAAA") + (push (ebox-test--buffer-update-report) reports) + (let* ((snapshot (ebox--layout-snapshot + (current-buffer) b-node-id)) + (snapshot-spans (plist-get snapshot :buffer-spans)) + (actual-spans (ebox--region-set-line-spans + (list b-region-id)))) + (setq sibling-snapshot-current-p + (or (not (plist-member snapshot :buffer-spans)) + (equal snapshot-spans actual-spans)))) + (ebox-region-update b-region-id :content "CCC") + (push (ebox-test--buffer-update-report) reports) + (dolist (report reports) + (should (eq (plist-get report :strategy) 'span-patch))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (should sibling-snapshot-current-p))))) + +(ert-deftest ebox-flex-direct-item-inline-margin-rerenders-flex-owner () + "Direct flex item margin changes should patch the flex line owner." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(flex :id matrix :width (260) :flex-flow (row nowrap) + :column-gap (0) + (box :id target :content "A" :width (80) :height 1 + :border "#999999") + (box :id peer :content "B" :width (80) :height 1 + :border "#999999"))))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#target"))) + (ebox-region-update region-id + :margin-left '(30) + :margin-right '(30)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (eq (plist-get report :owner-type) 'flex)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-flex-child-width-expansion-reflows-ancestor-after-span-fallback () + "Failed promoted span patches must fall back to a layout-safe ancestor." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(flex :id matrix :width (680) :flex-flow (row wrap) + :column-gap (12) :row-gap 1 + (column :width (210) :border "#999999" + (box :content "Title A" :width (190)) + (box :id target :content "width target\nmoves sibling layout" + :width (160) :height 3 + :padding (0 (8)) :border "#999999")) + (column :width (210) :border "#999999" + (box :content "Title B" :width (190)) + (box :content "Still here" :width (190))) + (column :width (210) :border "#999999" + (box :content "Title C" :width (190)) + (box :content "Third card" :width (190))))))) + (ebox-test--with-rendered-buffer layout + (let* ((match (car (ebox-selector-query-buffer + (current-buffer) "#target"))) + (region-id (plist-get match :region-id))) + (ebox-region-update region-id :width '(250)) + (let* ((patched (buffer-string)) + (full (ebox--with-buffer-render-context (current-buffer) + (ebox-render + (ebox--buffer-root-node (current-buffer)))))) + (should (string= (substring-no-properties patched) + (substring-no-properties full)))))))) + +(ert-deftest ebox-flex-shrunk-definite-item-content-update-keeps-current-slot () + "A definite 520x4 item may update inside its published shrunk slot." + (ebox-test--reset-runtime-state) + (let ((ebox-viewport-width 500) + (layout + (ebox-build + '(flex :id root :width (500) :height 4 + :flex-flow (row nowrap) :align-items flex-start + (box :id fixed :content "peer" :width (320) :height 4 + :flex-grow 0 :flex-shrink 0) + (box :id target :content "READY" :width (520) :height 4 + :overflow hidden :wrap-mode word))))) + (ebox-test--with-rendered-buffer layout + (let* ((match (car (ebox-selector-query-buffer + (current-buffer) "#target"))) + (region-id (plist-get match :region-id)) + (node-id (plist-get match :node-id)) + (node (ebox--buffer-runtime-node (current-buffer) node-id)) + (snapshot (ebox--ensure-layout-snapshot-details + (current-buffer) node-id)) + (old-widths + (plist-get (plist-get snapshot :span-footprint-signature) + :line-pixel-widths))) + (should (= (length old-widths) 4)) + (should (< (apply #'max old-widths) 520)) + ;; Flex measurement may have rendered a temporary sized copy. Keep + ;; this regression focused on the current-slot update contract. + (puthash region-id node ebox--region-box-table) + (ebox-region-update region-id :content "DONE") + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (equal (plist-get report :owner-ids) (list node-id))) + (should-not (plist-get report :full-rerender))) + (should (string-match-p + "DONE" + (buffer-substring-no-properties (point-min) (point-max)))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-flex-auto-size-content-update-refuses-current-slot () + "Auto-sized content must reflow instead of being forced into its old slot." + (ebox-test--reset-runtime-state) + (let ((ebox-viewport-width 300) + (layout + (ebox-build + '(flex :id root :width (300) :height 1 + :flex-flow (row nowrap) :align-items flex-start + (box :id target :content "A" :width auto :height 1 + :overflow hidden :wrap-mode word) + (box :id peer :content "P" :width (40) :height 1 + :flex-grow 0 :flex-shrink 0))))) + (ebox-test--with-rendered-buffer layout + (let* ((match (car (ebox-selector-query-buffer + (current-buffer) "#target"))) + (region-id (plist-get match :region-id)) + (node-id (plist-get match :node-id)) + (node (ebox--buffer-runtime-node (current-buffer) node-id)) + (snapshot (ebox--ensure-layout-snapshot-details + (current-buffer) node-id))) + (puthash region-id node ebox--region-box-table) + (should-not + (ebox--render-node-in-current-flex-slot + (current-buffer) node-id snapshot '(:content))) + (ebox-region-update region-id :content "HELLO") + (let ((report (ebox-test--buffer-update-report))) + (should-not + (and (eq (plist-get report :strategy) 'span-patch) + (equal (plist-get report :owner-ids) (list node-id))))) + (should (string-match-p + "HELLO" + (buffer-substring-no-properties (point-min) (point-max)))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-buffer-update-report-describes-span-patch () + "The update report should expose the actual minimal patch strategy." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Trace" :width 80 :height 1)) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-region-update region-id :content "Patch") + (let ((report (ebox-test--buffer-update-report))) + (should (equal (plist-get report :region-id) region-id)) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (eq (plist-get report :owner-type) 'box)) + (should (eq (plist-get report :full-rerender) nil)) + (should (> (plist-get report :span-count) 0)) + (should (equal (plist-get report :old-signature) + (plist-get report :new-signature))) + (should (plist-get report :old-span-footprint-signature)) + (should (plist-get report :old-external-footprint-signature)) + (should (equal (plist-get report :old-span-footprint-signature) + (plist-get report :new-span-footprint-signature))) + (should (equal (plist-get report :old-external-footprint-signature) + (plist-get report :new-external-footprint-signature))))))) + +(ert-deftest ebox-buffer-update-report-describes-owner-rerender-fallback () + "The update report should expose geometry owner rerender fallback." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "L" :width 40)) + (right (ebox-create :content "R" :width 40)) + (layout (ebox-concat left right)) + (region-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (ebox-region-update region-id :width '(120)) + (let ((report (ebox-test--buffer-update-report))) + (should (equal (plist-get report :region-id) region-id)) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (eq (plist-get report :owner-type) 'concat)) + (should (eq (plist-get report :full-rerender) nil)))))) + +(ert-deftest ebox-region-update-stable-geometry-uses-span-patch () + "Geometry updates with stable span footprint should use the L2 planner path." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Align" :width '(120) :height 1 + :text-align 'left)) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-region-update region-id :text-align 'center) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (equal (plist-get report :patch-ops) '(span-patch))) + (should (equal (plist-get report :dirty-kinds) '(geometry))) + (should (eq (plist-get report :owner-type) 'box)) + (should-not (plist-get report :full-rerender)))))) + +(ert-deftest ebox-region-update-parent-layout-change-keeps-owner-rerender () + "Geometry updates that move siblings should not be forced into span-patch." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "L" :width '(40))) + (right (ebox-create :content "R" :width '(40))) + (layout (ebox-concat left right)) + (region-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (ebox-region-update region-id :width '(120)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :patch-ops) '(owner-rerender))) + (should (eq (plist-get report :owner-type) 'concat)) + (should-not (plist-get report :full-rerender)))))) + +(ert-deftest ebox-region-update-inline-child-stable-geometry-uses-span-patch () + "Stable internal changes inside an inline child should not rerender siblings." + (ebox-test--reset-runtime-state) + (let* ((left (ebox-create :content "Left" :width '(80) :height 1 + :text-align 'left)) + (right (ebox-create :content "Right" :width '(80) :height 1)) + (layout (ebox-concat left right)) + (left-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (ebox-region-update left-id :text-align 'center) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (equal (plist-get report :patch-ops) '(span-patch))) + (should (eq (plist-get report :owner-type) 'box)) + (should (plist-get report :old-parent-slot-signature)) + (should (equal (plist-get report :old-parent-slot-signature) + (plist-get report :new-parent-slot-signature))) + (should-not (plist-get report :full-rerender))) + (should (string-match-p + "Right" + (buffer-substring-no-properties (point-min) (point-max))))))) + +(ert-deftest ebox-region-update-border-box-inline-change-uses-span-patch () + "Border-box left/right spacing can be internal when outer width is fixed." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Pad" :width '(120) :height 1 + :box-sizing 'border-box)) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-region-update region-id :padding-left-pixel 8) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (equal (plist-get report :patch-ops) '(span-patch))) + (should-not (plist-get report :full-rerender)))))) + +(ert-deftest ebox-region-update-content-box-inline-change-rerenders-owner () + "Content-box left/right spacing changes the parent-facing outer width." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Pad" :width '(120) :height 1 + :box-sizing 'content-box)) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-region-update region-id :padding-left-pixel 8) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :patch-ops) '(owner-rerender))) + (should-not (plist-get report :full-rerender)))))) + +(ert-deftest ebox-flex-item-box-sizing-update-rerenders-contained-flex () + "Changing a flex item's box model should rerun its parent's placement." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id root :width (760) + (flex :id outer :width (600) :height 5 :flex-flow (row nowrap) + (box :id card :width (300) + (flex :id inner :width (300) :height 5 + :padding (0 (10)) :border "#6F6A95" + :justify-content center :align-items center + (box :id target :content "A\nB" + :width (230) :height 3 :padding (0 (10)) + :border "#999999" :box-sizing content-box))) + (box :id peer :content "P\nQ\nR\nS\nT" + :width (300) :height 5)))))) + (ebox-test--with-rendered-buffer layout + (let* ((region-id + (ebox-test--selector-region-id (current-buffer) "#target")) + (target-node + (ebox--buffer-region-render-owner-node + (current-buffer) region-id)) + (flex-id + (ebox--runtime-parent-id + (current-buffer) (ebox--ensure-node-id target-node))) + (root-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox-region-update region-id :box-sizing 'border-box) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :owner-ids) (list flex-id))) + (should (eq (plist-get report :owner-type) 'flex)) + (should (= (plist-get report :span-count) 5)) + (should (plist-get report :slot-preserving)) + (should-not (member root-id (plist-get report :owner-ids))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-flex-item-block-size-updates-use-fixed-containment-owner () + "Block-size updates should skip flex-slot proof below fixed containment." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id root :width (760) + (flex :id outer :width (600) :height 5 :flex-flow (row nowrap) + (box :id card :width (300) + (flex :id inner :width (300) :height 5 + :padding (0 (10)) :border "#6F6A95" + :justify-content center :align-items center + (box :id target :content "A\nB\nC\nD" + :width (230) :height 2 :overflow scroll + :padding (0 (10)) :border "#999999"))) + (box :id peer :content "P\nQ\nR\nS\nT" + :width (300) :height 5)))))) + (ebox-test--with-rendered-buffer layout + (let* ((region-id + (ebox-test--selector-region-id (current-buffer) "#target")) + (target-node + (ebox--buffer-region-render-owner-node + (current-buffer) region-id)) + (flex-id + (ebox--runtime-parent-id + (current-buffer) (ebox--ensure-node-id target-node))) + (root-id (ebox--buffer-root-node-id (current-buffer))) + (slot-proof-count 0) + (original-slot-proof + (symbol-function 'ebox--flex-item-slot-footprint-safe-p))) + (cl-letf (((symbol-function 'ebox--flex-item-slot-footprint-safe-p) + (lambda (&rest args) + (cl-incf slot-proof-count) + (apply original-slot-proof args)))) + (dolist (change '((:height 4) (:min-height 3) (:max-height 1))) + (ebox-region-update region-id (car change) (cadr change)) + (let ((report (ebox-test--buffer-update-report))) + (should (memq (plist-get report :strategy) + '(span-patch owner-rerender))) + (should (equal (plist-get report :owner-ids) (list flex-id))) + (should (eq (plist-get report :owner-type) 'flex)) + (should (= (plist-get report :span-count) 5)) + (should-not (member root-id (plist-get report :owner-ids))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + (should (= slot-proof-count 0)))))) + +(ert-deftest ebox-region-update-span-candidate-falls-back-on-wrap-change () + "A span candidate should degrade to owner rerender when footprint changes." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "abcdefghijklmnopqrstuvwxyz" + :width '(120) + :box-sizing 'border-box + :wrap-mode 'char)) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-region-update region-id :padding-left-pixel 100) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :patch-ops) '(owner-rerender))) + (should-not (plist-get report :full-rerender)))))) + +(ert-deftest ebox-region-update-matrix-covers-all-box-longhands () + "The update tests should classify every non-content box longhand." + (let ((covered (append (ebox-test--region-update-case-properties + ebox-test--region-update-paint-cases) + (ebox-test--region-update-case-properties + ebox-test--region-update-geometry-cases)))) + (should-not (cl-set-difference ebox--region-update-longhand-props + covered + :test #'eq)) + (should-not (cl-set-difference covered + ebox--region-update-longhand-props + :test #'eq)) + (should-not (cl-set-difference ebox--paint-style-signature-keys + (ebox-test--region-update-case-properties + ebox-test--region-update-paint-cases) + :test #'eq)))) + +(ert-deftest ebox-region-update-all-paint-longhands-use-paint-patch () + "Every paint longhand should patch text properties without replacing spans." + (dolist (case ebox-test--region-update-paint-cases) + (pcase-let ((`(,property ,value ,expected) case)) + (ert-info ((format "property %S" property)) + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Paint" :width '(120) + :height 1)) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (let ((replace-count 0) + (original-replace (symbol-function + 'ebox--replace-line-spans))) + (cl-letf (((symbol-function 'ebox--replace-line-spans) + (lambda (&rest args) + (cl-incf replace-count) + (apply original-replace args)))) + (let ((full-rerenders + (ebox-test--count-full-rerenders + (ebox-region-update region-id property value)))) + (should (= full-rerenders 0)))) + (should (= replace-count 0))) + (let ((runtime-box + (gethash region-id (ebox--buffer-region-box-table + (current-buffer))))) + (should (eq (gethash region-id ebox--region-box-table) + runtime-box)) + (should-not (eq runtime-box box)) + (should (equal (ebox-get runtime-box property) expected))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'paint-patch)) + (should (equal (plist-get report :patch-ops) + '(paint-patch))) + (should (equal (plist-get report :dirty-kinds) '(paint))) + (should-not (plist-get report :full-rerender))))))))) + +(ert-deftest ebox-region-update-all-geometry-longhands-use-minimal-strategy () + "Every geometry longhand should use its expected minimal non-root strategy." + (dolist (case ebox-test--region-update-geometry-cases) + (pcase-let ((`(,property ,value ,expected ,strategy) case)) + (ert-info ((format "property %S" property)) + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Alpha\nBeta\nGamma\nDelta" + :width '(160) + :height 2 + :overflow 'scroll + :wrap-mode 'word)) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (let ((full-rerenders + (ebox-test--count-full-rerenders + (ebox-region-update region-id property value)))) + (should (= full-rerenders 0))) + (let ((runtime-box + (gethash region-id (ebox--buffer-region-box-table + (current-buffer))))) + (should (eq (gethash region-id ebox--region-box-table) + runtime-box)) + (should-not (eq runtime-box box)) + (should (equal (ebox-get runtime-box property) expected))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) strategy)) + (should (equal (plist-get report :patch-ops) + (list strategy))) + (should (equal (plist-get report :dirty-kinds) '(geometry))) + (should (eq (plist-get report :owner-type) 'box)) + (should-not (plist-get report :full-rerender))))))))) + +(ert-deftest ebox-bgcolor-update-mutates-box-and-keeps-content () + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Color" :width 60 :bgcolor "#111111")) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-region-update region-id :bgcolor "#222222") + (should (equal (ebox-get box :bgcolor) "#111111")) + (should (equal (ebox-get (gethash region-id + (ebox--buffer-region-box-table + (current-buffer))) + :bgcolor) + "#222222")) + (should (string-match-p + "Color" + (buffer-substring-no-properties (point-min) (point-max))))))) + +(ert-deftest ebox-background-color-creation-matches-bgcolor-updates () + "Background color spellings should share one engine longhand and paint path." + (ebox-test--reset-runtime-state) + (let* ((background-box + (ebox-create :content "Color" :width 60 + :background-color "#111111")) + (bgcolor-box + (ebox-create :content "Color" :width 60 :bgcolor "#111111")) + (background-face + (ebox-test--first-content-face (ebox-render background-box))) + (bgcolor-face + (ebox-test--first-content-face (ebox-render bgcolor-box)))) + (should (equal (ebox-get background-box :bgcolor) "#111111")) + (should (equal (ebox-get bgcolor-box :bgcolor) "#111111")) + (should-not (plist-member background-box :background-color)) + (should (equal background-face bgcolor-face)) + (should (ebox-test--face-has-key-value-p + background-face :background "#111111")) + (let ((region-id (car (ebox-region-ids background-box)))) + (ebox-test--with-rendered-buffer background-box + (ebox-region-update region-id :background-color "#222222") + (let ((runtime-box + (gethash region-id (ebox--buffer-region-box-table + (current-buffer))))) + (should (equal (ebox-get background-box :bgcolor) "#111111")) + (should (equal (ebox-get runtime-box :bgcolor) "#222222")) + (should-not (plist-member runtime-box :background-color))) + (should-not (plist-member background-box :background-color)) + (should (ebox-test--face-has-key-value-p + (ebox-test--first-content-face (buffer-string)) + :background "#222222")) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'paint-patch)) + (should (equal (plist-get report :patch-ops) '(paint-patch))) + (should (equal (plist-get report :dirty-kinds) '(paint))) + (should-not (plist-get report :full-rerender))))))) + +(ert-deftest ebox-vertical-align-middle-alias-matches-center () + "Creation and updates should normalize middle to center before rendering." + (ebox-test--reset-runtime-state) + (let ((middle (ebox-create :content "X" :height 3 + :vertical-align 'middle))) + (should (equal (ebox--expand-property :vertical-align 'middle) + '(:vertical-align center))) + (should (eq (ebox-get middle :vertical-align) 'center)) + (should (= (cl-position-if + (lambda (line) + (string-match-p "X" (substring-no-properties line))) + (ebox-string-lines (ebox-render middle))) + 1)) + (let* ((updated (ebox-create :content "X" :height 3 + :vertical-align 'top)) + (region-id (car (ebox-region-ids updated)))) + (ebox-test--with-rendered-buffer updated + (goto-char (point-min)) + (search-forward "X") + (should (= (line-number-at-pos (match-beginning 0)) 1)) + (ebox-region-update region-id :vertical-align 'middle) + (should (eq (ebox-get updated :vertical-align) 'top)) + (should (eq (ebox-get (gethash region-id + (ebox--buffer-region-box-table + (current-buffer))) + :vertical-align) + 'center)) + (goto-char (point-min)) + (search-forward "X") + (should (= (line-number-at-pos (match-beginning 0)) 2)) + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'span-patch)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-update-reuses-shorthand-property-expansion () + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Box" :width 60)) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-region-update region-id + :padding '(1 2) + :border '(2 "#abcdef") + :margin-inline 3) + (let ((runtime-box + (gethash region-id (ebox--buffer-region-box-table + (current-buffer))))) + (should (= (ebox-get box :padding-top-height) 0)) + (should (= (ebox-get runtime-box :padding-top-height) 1)) + (should (= (ebox-get runtime-box :padding-right-pixel) + (* 2 (ebox--space-pixel-width)))) + (should (= (ebox-get runtime-box :border-left-pixel) + (* 2 (ebox--space-pixel-width)))) + (should (equal (ebox-get runtime-box :border-right-color) "#abcdef")) + (should (= (ebox-get runtime-box :margin-left-pixel) + (* 3 (ebox--space-pixel-width)))))))) + +(ert-deftest ebox-region-update-adds-absent-shorthand-properties-incrementally () + "Shorthand updates should locate the same box and patch without root rebuilds." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Absent properties" :width '(160))) + (region-id (car (ebox-region-ids box))) + (ids-before (ebox-region-ids box))) + (ebox-test--with-rendered-buffer box + (let ((full-rerenders + (ebox-test--count-full-rerenders + (ebox-region-update region-id + :padding '(1 2) + :border '(1 "#abcdef") + :margin-inline 3)))) + (should (= full-rerenders 0))) + (let ((runtime-box + (gethash region-id (ebox--buffer-region-box-table + (current-buffer))))) + (should (eq (gethash region-id ebox--region-box-table) + runtime-box)) + (should-not (eq runtime-box box)) + (should (equal (ebox-region-ids box) ids-before)) + (should (= (ebox-get box :padding-top-height) 0)) + (should (= (ebox-get box :padding-right-pixel) 0)) + (should (= (ebox-get box :border-left-pixel) 0)) + (should (null (ebox-get box :border-right-color))) + (should (= (ebox-get box :margin-left-pixel) 0)) + (should (= (ebox-get runtime-box :padding-top-height) 1)) + (should (= (ebox-get runtime-box :padding-right-pixel) + (* 2 (ebox--space-pixel-width)))) + (should (= (ebox-get runtime-box :border-left-pixel) + (ebox--space-pixel-width))) + (should (equal (ebox-get runtime-box :border-right-color) "#abcdef")) + (should (= (ebox-get runtime-box :margin-left-pixel) + (* 3 (ebox--space-pixel-width))))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :patch-ops) + '(owner-rerender))) + (should-not (plist-get report :full-rerender))) + (let ((replace-count 0) + (original-replace (symbol-function 'ebox--replace-line-spans))) + (cl-letf (((symbol-function 'ebox--replace-line-spans) + (lambda (&rest args) + (cl-incf replace-count) + (apply original-replace args)))) + (let ((full-rerenders + (ebox-test--count-full-rerenders + (ebox-region-update region-id + :background-color "#fed7aa")))) + (should (= full-rerenders 0)))) + (should (= replace-count 0))) + (should (null (ebox-get box :bgcolor))) + (should (equal (ebox-get (gethash region-id + (ebox--buffer-region-box-table + (current-buffer))) + :bgcolor) + "#fed7aa")) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'paint-patch)) + (should (equal (plist-get report :patch-ops) + '(paint-patch))) + (should-not (plist-get report :full-rerender)))))) + +(ert-deftest ebox-color-and-visibility-are-real-and-display-is-ignored () + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Paint" + :color "#eeeeee" + :display 'none + :visibility 'hidden)) + (region-id (car (ebox-region-ids box)))) + (should (equal (ebox-get box :color) "#eeeeee")) + (should (equal (ebox--computed-display box) '(block flow))) + (should (eq (ebox-get box :visibility) 'hidden)) + (ebox-test--with-rendered-buffer box + ;; Hidden at mount: the text is present but carries no face ink. + (let ((position (string-match + "Paint" + (buffer-substring-no-properties (point-min) + (point-max))))) + (should position) + (should-not (get-text-property (+ (point-min) position) 'face))) + (ebox-region-update region-id + :color "#123456" + :display 'block + :visibility 'visible) + (let ((runtime-box + (gethash region-id (ebox--buffer-region-box-table + (current-buffer))))) + (should (equal (ebox-get box :color) "#eeeeee")) + (should (equal (ebox-get runtime-box :color) "#123456")) + (should (eq (ebox-get runtime-box :visibility) 'visible))) + (should (equal (ebox--computed-display box) '(block flow))) + (should (eq (ebox-get box :visibility) 'hidden)) + (let ((position (string-match + "Paint" + (buffer-substring-no-properties (point-min) + (point-max))))) + (should position) + (should (get-text-property (+ (point-min) position) 'face)))))) + +(ert-deftest ebox-row-column-and-spacer-compose-layouts () + (ebox-test--reset-runtime-state) + (let* ((a (ebox-create :content "A" :width 40)) + (b (ebox-create :content "B" :width 40)) + (c (ebox-create :content "C" :width 40)) + (row (ebox-row a nil b c)) + (column (ebox-column a b nil c)) + (spacer (ebox-spacer :width 20 :height 2))) + (should (equal (ebox-region-ids row) '(1 2 3))) + (should (equal (ebox-region-ids column) '(1 2 3))) + (should (eq (plist-get spacer :ebox-type) 'box)) + (should (string-match-p "A.*B.*C" + (substring-no-properties (ebox-render row)))) + (should (string-match-p "A[^\n]*\nB[^\n]*\nC" + (substring-no-properties (ebox-render column)))))) + +(ert-deftest ebox-row-and-column-store-children-as-flat-containers () + "Row and column should be one container node with direct children." + (ebox-test--reset-runtime-state) + (let* ((children (cl-loop for label in '("A" "B" "C" "D") + collect (ebox-create :content label :width 40))) + (row (apply #'ebox-row children)) + (column (apply #'ebox-column children))) + (should (= (length (ebox--node-children row)) 4)) + (should (= (length (ebox--node-children column)) 4)) + (should (= (length (ebox--runtime-node-ids row)) 5)) + (should (= (length (ebox--runtime-node-ids column)) 5)))) + +(ert-deftest ebox-overflow-hidden-clips-extra-content-lines () + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A\nB\nC" + :height 1 + :overflow 'hidden)) + (plain (substring-no-properties (ebox-render box)))) + (should (string-match-p "A" plain)) + (should-not (string-match-p "B" plain)) + (should-not (string-match-p "C" plain)) + (should (= (ebox-string-height plain) 1)))) + +(ert-deftest ebox-overflow-visible-appends-extra-content-lines () + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A\nB\nC" + :height 1 + :overflow 'visible)) + (plain (substring-no-properties (ebox-render box)))) + (should (string-match-p "A" plain)) + (should (string-match-p "B" plain)) + (should (string-match-p "C" plain)) + (should (= (ebox-string-height plain) 3)))) + +(defun ebox-test--assert-visible-overflow-paint + (rendered foreground background) + "Assert RENDERED keeps visible overflow painted but unowned." + (let* ((lines (ebox-string-lines rendered)) + (body (car lines)) + (overflow-lines (cdr lines)) + (ownership-properties + (append '(ebox-content-idx ebox-content-owners) + (mapcar #'cdr ebox-region-types))) + (body-pos (cl-position ?A body))) + (should (= (length lines) 3)) + (should (integerp body-pos)) + (let ((body-face (get-text-property body-pos 'face body))) + (should (ebox-test--face-has-key-value-p + body-face :foreground foreground)) + (should (ebox-test--face-has-key-value-p + body-face :background background))) + (cl-loop for line in overflow-lines + for glyph in '(?B ?C) + do + (let ((glyph-pos (cl-position glyph line))) + (should (integerp glyph-pos)) + (should (ebox-test--face-has-key-value-p + (get-text-property glyph-pos 'face line) + :foreground foreground))) + (dolist (offset-pos (list 0 (1- (length line)))) + (should (get-text-property offset-pos 'display line)) + (should-not + (ebox-test--face-has-key-p + (get-text-property offset-pos 'face line) + :foreground))) + (dotimes (pos (length line)) + (should-not + (ebox-test--face-has-key-p + (get-text-property pos 'face line) + :background)) + (dolist (property ownership-properties) + (should-not (get-text-property pos property line))))))) + +(ert-deftest ebox-overflow-visible-paints-foreground-without-ownership () + "Visible overflow should inherit only the outer box foreground." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A\nB\nC" + :width '(40) + :height 1 + :overflow 'visible + :color "#abcdef" + :bgcolor "#123456" + :padding-inline '(6) + :margin-inline '(4)))) + (ebox-test--assert-visible-overflow-paint + (ebox-render box) "#abcdef" "#123456"))) + +(ert-deftest ebox-region-update-refreshes-visible-overflow-foreground () + "Color updates should refresh visible overflow without owning its suffix." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A\nB\nC" + :width '(40) + :height 1 + :overflow 'visible + :color "#abcdef" + :bgcolor "#123456" + :padding-inline '(6) + :margin-inline '(4))) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-region-update region-id :color "#fedcba") + (ebox-test--assert-visible-overflow-paint + (buffer-string) "#fedcba" "#123456")))) + +(ert-deftest ebox-owner-rerender-keeps-visible-overflow-unowned () + "Owner rerenders should not restore box ownership to visible overflow." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A\nB\nC" + :width '(40) + :height 1 + :overflow 'scroll + :color "#abcdef" + :bgcolor "#123456")) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (ebox-region-update region-id :overflow 'visible) + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'owner-rerender)) + (goto-char (point-min)) + (search-forward "C") + (let* ((pos (match-beginning 0)) + (face (get-text-property pos 'face))) + (should (ebox-test--face-has-key-value-p + face :foreground "#abcdef")) + (should-not (ebox-test--face-has-key-value-p + face :background "#123456")) + (should-not (get-text-property pos 'ebox-content-owner)) + (should-not (get-text-property pos 'ebox-content-owners)))))) + +(ert-deftest ebox-overflow-scroll-keeps-window-height-and-state () + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A\nB\nC" + :height 1 + :overflow 'scroll)) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "A" plain)) + (should-not (string-match-p "B" plain)) + (should (= (ebox-string-height plain) 1))) + (should (equal (plist-get (ebox--scroll-get-state region-id) + :content-height) + 1)) + (goto-char (point-min)) + (ebox-scroll-down 1) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "B" plain)) + (should-not (string-match-p "A" plain)) + (should (= (ebox-string-height plain) 1)))))) + +(ert-deftest ebox-scroll-falls-back-when-region-cannot-scroll () + "Scroll commands should let the buffer move when the box has no scroll work." + (ebox-test--reset-runtime-state) + (with-temp-buffer + (insert (ebox-render (ebox-create :content "short" :height 1))) + (insert "\n") + (dotimes (index 40) + (insert (format "tail-%02d\n" index))) + (goto-char (point-min)) + (should (get-text-property (point) 'ebox-content)) + (let (fallback-called) + (cl-letf (((symbol-function 'scroll-up) + (lambda (&optional _arg) + (setq fallback-called t)))) + (ebox-scroll-down 1)) + (should fallback-called)))) + +(ert-deftest ebox-scroll-bubbles-from-child-to-scrollable-ancestor () + "Key scrolling over a child box should scroll the nearest scrollable ancestor." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 2 :overflow scroll + (column + (box :id "child" :content "A" :height 1) + (box :content "B" :height 1) + (box :content "C" :height 1))))) + (ids (ebox-region-ids layout)) + (root-id (car ids))) + (ebox-test--with-rendered-buffer layout + (goto-char (point-min)) + (search-forward "A") + (backward-char) + (should-not (equal (get-text-property (point) 'ebox-content) + root-id)) + (let (fallback-called) + (cl-letf (((symbol-function 'scroll-up) + (lambda (&optional _arg) + (setq fallback-called t)))) + (ebox-scroll-down 1)) + (should-not fallback-called)) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "B" plain)) + (should (string-match-p "C" plain)))))) + +(ert-deftest ebox-wheel-scroll-falls-back-to-default-mwheel () + "Mouse wheel fallback should preserve Emacs' normal scroll behavior." + (ebox-test--reset-runtime-state) + (let (mwheel-args scroll-up-called) + (cl-letf (((symbol-function 'mwheel-scroll) + (lambda (&rest args) + (setq mwheel-args args))) + ((symbol-function 'scroll-up) + (lambda (&optional _arg) + (setq scroll-up-called t)))) + (ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg)) + (should (equal mwheel-args '(fake-wheel-event fake-arg))) + (should-not scroll-up-called))) + +(ert-deftest ebox-wheel-scroll-falls-back-for-non-scrollable-region () + "Mouse wheel over a normal box should keep default buffer scrolling." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "short" :height 1)) + (region-id (car (ebox-region-ids box))) + mwheel-args) + (ebox-test--with-rendered-buffer box + (cl-letf (((symbol-function 'ebox--wheel-region-ids) + (lambda (_event) (list region-id))) + ((symbol-function 'mwheel-scroll) + (lambda (&rest args) + (setq mwheel-args args)))) + (ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg)) + (should (equal mwheel-args '(fake-wheel-event fake-arg)))))) + +(ert-deftest ebox-wheel-scroll-consumes-scrollable-region () + "Mouse wheel should still scroll an overflow='scroll box when it can." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A\nB\nC" + :height 1 + :overflow 'scroll)) + (region-id (car (ebox-region-ids box))) + mwheel-called) + (ebox-test--with-rendered-buffer box + (cl-letf (((symbol-function 'ebox--wheel-region-ids) + (lambda (_event) (list region-id))) + ((symbol-function 'mwheel-scroll) + (lambda (&rest _args) + (setq mwheel-called t)))) + (ebox--wheel-scroll 'fake-wheel-event 1)) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "B" plain)) + (should-not (string-match-p "A" plain))) + (should-not mwheel-called)))) + +(ert-deftest ebox-wheel-scroll-bubbles-from-child-to-scrollable-ancestor () + "Mouse wheel scrolling over a child box should scroll its scrollable ancestor." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 2 :overflow scroll + (column + (box :id "child" :content "A" :height 1) + (box :content "B" :height 1) + (box :content "C" :height 1))))) + (ids (ebox-region-ids layout)) + (root-id (car ids)) + (child-id (cadr ids)) + mwheel-called) + (ebox-test--with-rendered-buffer layout + (cl-letf (((symbol-function 'ebox--wheel-region-ids) + (lambda (_event) (list child-id root-id))) + ((symbol-function 'mwheel-scroll) + (lambda (&rest _args) + (setq mwheel-called t)))) + (ebox--wheel-scroll 'fake-wheel-event 1)) + (should-not mwheel-called) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "B" plain)) + (should (string-match-p "C" plain)))))) + +(ert-deftest ebox-scroll-state-is-created-only-for-scrollable-boxes () + "Non-overflowing boxes should not inflate the global scroll hit-test table." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(column + (box :id "short-a" :content "A" :height 1) + (box :id "short-b" :content "B" :height 1) + (box :id "root" :height 1 :overflow scroll + (column + (box :content "C" :height 1) + (box :content "D" :height 1)))))) + root-id short-a-id short-b-id) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (setq short-a-id (ebox-test--selector-region-id (current-buffer) + "#short-a")) + (setq short-b-id (ebox-test--selector-region-id (current-buffer) + "#short-b")) + (should-not (ebox--scroll-get-state short-a-id)) + (should-not (ebox--scroll-get-state short-b-id)) + (should (ebox--scroll-get-state root-id))))) + +(ert-deftest ebox-scroll-hit-test-uses-owner-stack-before-extents-scan () + "Wheel hit testing should avoid a table-wide extents scan when owners suffice." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 1 :overflow scroll + (column + (box :id "child" :content "A" :height 1) + (box :content "B" :height 1))))) + (root-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (goto-char (point-min)) + (cl-letf (((symbol-function 'ebox--scroll-region-ids-in-extents-at-pos) + (lambda (&rest _args) + (error "unexpected extents scan")))) + (should (member root-id + (ebox--scroll-region-ids-at-pos (point)))))))) + +(ert-deftest ebox-scroll-rendered-content-window-avoids-rerender-path () + "Complex scroll boxes should patch visible rendered lines without rerendering." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 3 :overflow scroll + (column + (box :content "row-1" :height 1) + (box :content "row-2" :height 1) + (box :content "row-3" :height 1) + (box :content "row-4" :height 1) + (box :content "row-5" :height 1))))) + root-id) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (let ((state (ebox--scroll-get-state root-id))) + (should (plist-get state :rendered-content-lines)) + (should (ebox--scroll-chrome-free-rendered-window-p + (plist-get state :box)))) + (cl-letf (((symbol-function 'ebox--scroll-replace-rendered-window) + (lambda (&rest _args) + (error "unexpected rendered-window fallback"))) + ((symbol-function 'ebox--render-scroll-state-window) + (lambda (&rest _args) + (error "unexpected scroll-state render")))) + (should (ebox--scroll-region-by root-id 2))) + (let ((state (ebox--scroll-get-state root-id)) + (plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (= (plist-get state :scroll-offset) 2)) + (should (eq (plist-get state :buffer) (current-buffer))) + (should (plist-get state :rendered-window-start-marker)) + (should (plist-get state :rendered-window-end-marker)) + (should (= (plist-get state :rendered-window-line-count) + (plist-get state :content-height))) + (should-not (plist-get state :content-span-markers)) + (should-not (plist-get state :content-window-region-id-set)) + (should (string-match-p "row-3" plain)) + (should (string-match-p "row-5" plain)) + (should-not (string-match-p "row-1" plain)))))) + +(ert-deftest ebox-chrome-free-scroll-render-reuses-propertized-prefix-lines () + "A chrome-free scroll box should not propertize its visible prefix twice." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "row-1\nrow-2\nrow-3" + :width 120 + :height 2 + :overflow 'scroll + :color "#112233" + :bgcolor "#ddeeff")) + (region-id (ebox--ensure-region-id box)) + (reference (ebox-create :content "row-1\nrow-2\nrow-3" + :width 120 + :height 2 + :overflow 'hidden + :color "#112233" + :bgcolor "#ddeeff")) + (original (symbol-function 'ebox--propertize-content-line)) + (root-propertize-count 0) + actual) + (plist-put reference :region-id region-id) + (cl-letf (((symbol-function 'ebox--propertize-content-line) + (lambda (line owner idx fallback) + (when (equal owner region-id) + (cl-incf root-propertize-count)) + (funcall original line owner idx fallback)))) + (setq actual (ebox-render box))) + (should (= root-propertize-count 3)) + (should-not + (text-property-not-all 0 (length actual) + 'ebox-scroll-window region-id actual)) + (let ((actual-without-window-marker (copy-sequence actual))) + (remove-text-properties + 0 (length actual-without-window-marker) + '(ebox-scroll-window nil) + actual-without-window-marker) + (should (equal-including-properties + actual-without-window-marker + (ebox-render reference)))))) + +(ert-deftest ebox-underfilled-chrome-free-scroll-fuses-line-finishing-passes () + "A complete top-aligned underfill should keep exact output in one line pass." + (ebox-test--reset-runtime-state) + (let* ((content + (concat (propertize "alpha" 'face '(:weight bold) + 'help-echo "rich") + "\n \nomega")) + (box (ebox-create :content content + :width 140 + :height 5 + :overflow 'scroll + :vertical-align 'top + :color "#112233" + :bgcolor "#ddeeff")) + (region-id (ebox--ensure-region-id box)) + (reference (ebox-create :content content + :width 140 + :height 5 + :overflow 'hidden + :vertical-align 'top + :color "#112233" + :bgcolor "#ddeeff")) + (expected (progn + (plist-put reference :region-id region-id) + (ebox-render reference))) + (original-align (symbol-function 'ebox--lines-align-vertical)) + (original-maplines (symbol-function 'ebox--maplines)) + (align-calls 0) + (mapline-calls 0) + actual) + (cl-letf (((symbol-function 'ebox--lines-align-vertical) + (lambda (&rest args) + (cl-incf align-calls) + (apply original-align args))) + ((symbol-function 'ebox--maplines) + (lambda (&rest args) + (cl-incf mapline-calls) + (apply original-maplines args)))) + (setq actual (ebox-render box))) + (should (= align-calls 0)) + ;; Formatting may still justify the source once; finishing must not add + ;; the former blank-normalization and color map passes. + (should (<= mapline-calls 1)) + (should (equal-including-properties actual expected)) + (should-not (ebox--scroll-get-state region-id)))) + +(ert-deftest ebox-scroll-rendered-content-window-builds-spans-linearly () + "Rendered viewport replacement should not rescan from the start per line." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 3 :overflow scroll + (column + (box :content "row-1" :height 1) + (box :content "row-2" :height 1) + (box :content "row-3" :height 1) + (box :content "row-4" :height 1) + (box :content "row-5" :height 1) + (box :content "row-6" :height 1))))) + root-id + (forward-line-count 0) + (original-forward-line (symbol-function 'forward-line))) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (let ((state (ebox--scroll-get-state root-id))) + (plist-put state :scroll-offset 2) + (when-let ((box (plist-get state :box))) + (ebox-put box :scroll-offset 2)) + (cl-letf (((symbol-function 'forward-line) + (lambda (&optional n) + (cl-incf forward-line-count) + (funcall original-forward-line n)))) + (should (ebox--scroll-replace-rendered-content-window + state root-id))) + (should (<= forward-line-count + (1+ (plist-get state :content-height)))))))) + +(ert-deftest ebox-scroll-rendered-content-window-slides-small-deltas () + "Small scroll deltas should mutate only entering/leaving rendered lines." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 3 :overflow scroll + (column + (box :content "row-1111" :height 1) + (box :content "row-2222" :height 1) + (box :content "row-3333" :height 1) + (box :content "row-4444" :height 1) + (box :content "row-5555" :height 1))))) + root-id + deleted-bytes + (original-delete-region (symbol-function 'delete-region))) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (let* ((extents (ebox--live-box-extents root-id)) + (full-window-bytes (- (marker-position (cdr extents)) + (marker-position (car extents))))) + (cl-letf (((symbol-function 'delete-region) + (lambda (start end) + (push (- end start) deleted-bytes) + (funcall original-delete-region start end)))) + (should (ebox--scroll-region-by root-id 1))) + (should deleted-bytes) + (should (< (apply #'+ deleted-bytes) full-window-bytes)))))) + +(ert-deftest ebox-scroll-window-slide-preserves-wrapper-properties () + "Entering cached lines should keep scroll and outer-wrapper ownership." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "outer" :color "#112233" :bgcolor "#ddeeff" + (box :id "scroll" :height 3 :overflow scroll + (column + (box :content "row-1111" :height 1) + (box :content "row-2222" :height 1) + (box :content "row-3333" :height 1) + (box :content "row-4444" :height 1) + (box :content "row-5555" :height 1)))))) + outer-id + scroll-id) + (ebox-test--with-rendered-buffer layout + (setq outer-id + (ebox-test--selector-region-id (current-buffer) "#outer") + scroll-id + (ebox-test--selector-region-id (current-buffer) "#scroll")) + (should (= 1 (ebox--scroll-region-by scroll-id 1))) + (let* ((state (ebox--scroll-get-state scroll-id)) + (extents (ebox--scroll-state-window-extents scroll-id state)) + (start (marker-position (car extents))) + (spans (ebox--scroll-line-spans-in-window start 3)) + (slid (buffer-string))) + (should (ebox--scroll-state-window-markers-healthy-p + (current-buffer) scroll-id state)) + (dolist (span spans) + (should-not + (text-property-not-all + (car span) (cdr span) 'ebox-scroll-window scroll-id)) + (should (member outer-id (ebox--span-wrapper-owners span)))) + (ebox-buffer-apply-root-rerender (current-buffer)) + (should (equal-including-properties slid (buffer-string))))))) + +(ert-deftest ebox-scroll-window-slide-preserves-ancestor-horizontal-borders () + "Scrolling inside a bordered ancestor should preserve slot border paint." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "outer" + :border-top-p t :border-top-color "red" + :border-bottom-p t :border-bottom-color "blue" + (box :id "scroll" :height 3 :overflow scroll + (column + (box :content "row-1111" :height 1) + (box :content "row-2222" :height 1) + (box :content "row-3333" :height 1) + (box :content "row-4444" :height 1) + (box :content "row-5555" :height 1)))))) + scroll-id) + (ebox-test--with-rendered-buffer layout + (setq scroll-id + (ebox-test--selector-region-id (current-buffer) "#scroll")) + (let ((root-rerenders 0) + (original-root-rerender + (symbol-function 'ebox-buffer-apply-root-rerender))) + (cl-letf (((symbol-function 'ebox-buffer-apply-root-rerender) + (lambda (buffer) + (cl-incf root-rerenders) + (funcall original-root-rerender buffer)))) + (should (= 1 (ebox--scroll-region-by scroll-id 1)))) + (should (= root-rerenders 1))) + (let ((scrolled (buffer-string))) + (ebox-buffer-apply-root-rerender (current-buffer)) + (should (equal-including-properties scrolled (buffer-string))))))) + +(ert-deftest ebox-scroll-rendered-content-window-uses-slide-in-gui () + "GUI rendered viewport scrolling should try bounded line sliding." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 3 :overflow scroll + (column + (box :content "row-1111" :height 1) + (box :content "row-2222" :height 1) + (box :content "row-3333" :height 1) + (box :content "row-4444" :height 1) + (box :content "row-5555" :height 1))))) + root-id + slide-called) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (let ((noninteractive nil)) + (cl-letf (((symbol-function 'display-graphic-p) + (lambda (&optional _display) t)) + ((symbol-function + 'ebox--scroll-replace-rendered-content-window-slide) + (lambda (&rest _args) + (setq slide-called t) + nil))) + (should (ebox--scroll-region-by root-id 1)))) + (should slide-called)))) + +(ert-deftest ebox-lazy-scroll-rendered-window-uses-preformatted-lines () + "Lazy scroll windows should not rebuild visible content lines per tick." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (content-window-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (original-content-window + (symbol-function 'ebox--scroll-replace-content-window))) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (with-current-buffer buffer + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let ((state (ebox--scroll-get-state root-id))) + (should (plist-get state :rendered-content-lines)) + (should (plist-get state :render-content-prefix))) + (cl-letf (((symbol-function 'ebox--scroll-replace-content-window) + (lambda (&rest args) + (cl-incf content-window-count) + (apply original-content-window args)))) + (should (ebox--scroll-region-by root-id 8))) + (should (= content-window-count 0)) + (let ((state (ebox--scroll-get-state root-id))) + (should (= (plist-get state :scroll-offset) 8)) + (should (plist-get state :rendered-content-lines))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-after-viewport-resize-keeps-window-extents () + "Viewport resize must preserve lazy scroll's fixed viewport fast path." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(column :id "viewport" :width (viewport) + :height (viewport-height) + (box :content "" :width (viewport) :height 1) + (box :id "root" :width (viewport) + :height (- (viewport-height) 2) + :overflow scroll + (column :width (viewport) :padding (1 (8)) + ,@(cl-loop for idx below 80 + collect + `(box :content ,(format "row %02d" idx) + :width (viewport) + :height 1))) + (box :content "" :width (viewport) :height 1))))) + buffer + root-id + (materialize-count 0) + (rerender-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 12) + (ebox-wheel-smooth-scroll-lines-per-tick 4) + (ebox-wheel-smooth-scroll-interval 60.0) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (ebox-rerender-buffer-with-context buffer 320 14) + (with-current-buffer buffer + (let* ((state (ebox--scroll-get-state root-id)) + (extents (ebox--live-box-extents root-id)) + (extent-lines + (and extents + (count-lines (marker-position (car extents)) + (marker-position (cdr extents)))))) + (should extents) + (ert-info ((format "state=%S" + (list :content-height + (plist-get state :content-height) + :content-lines + (length (plist-get state + :content-lines)) + :rendered-lines + (length (plist-get state + :rendered-content-lines)) + :extent-lines extent-lines))) + (should (= extent-lines + (plist-get state :content-height)))) + (when-let ((materialize + (plist-get state :materialize-content-lines))) + (plist-put + state :materialize-content-lines + (lambda (&rest args) + (cl-incf materialize-count) + (apply materialize args))) + (puthash root-id state ebox--scroll-global-state))) + (let ((original-rerender + (symbol-function 'ebox--scroll-rerender-region))) + (cl-letf (((symbol-function 'ebox--scroll-rerender-region) + (lambda (&rest args) + (cl-incf rerender-count) + (apply original-rerender args)))) + (should (ebox--smooth-scroll-region-by root-id 16)) + (while (gethash root-id ebox--smooth-scroll-state-table) + (ebox--smooth-scroll-tick root-id)))) + (should (= materialize-count 0)) + (should (= rerender-count 0)))) + (when root-id + (ebox--smooth-scroll-stop root-id)) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-offset-viewport-resize-stays-lazy () + "Viewport resize after scrolling should not materialize all hidden content." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(column :id "viewport" :width (viewport) + :height (viewport-height) + (box :content "" :width (viewport) :height 1) + (box :id "root" :width (viewport) + :height (- (viewport-height) 2) + :overflow scroll + (column :width (viewport) :padding (1 (8)) + ,@(cl-loop for idx below 80 + collect + `(box :content ,(format "offset row %02d" idx) + :width (viewport) + :height 1))) + (box :content "" :width (viewport) :height 1))))) + buffer + root-id) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 12) + (ebox-scroll-lazy-idle-prefetch-lines 0) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (with-current-buffer buffer + (should (ebox--scroll-region-by root-id 16))) + (ebox-rerender-buffer-with-context buffer 320 14) + (let ((state (ebox--scroll-get-state root-id))) + (should (= (plist-get state :scroll-offset) 16)) + (should (plist-get state :render-content-prefix)) + (should (plist-get state :materialize-content-lines)) + (should (< (length (plist-get state :content-lines)) 80)) + (should (>= (length (plist-get state :content-lines)) + (+ (plist-get state :scroll-offset) + (plist-get state :content-height)))))) + (when root-id + (ebox--smooth-scroll-stop root-id)) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-intrinsic-measurement-skips-window-source () + "Flex intrinsic measurement should not render lazy scroll window content." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) + :height (- (viewport-height) 2) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :content ,(format "measurement row %02d" idx) + :width (viewport) + :height 1)))))) + (calls 0) + rendered) + (cl-letf (((symbol-function 'ebox--render-scroll-window-source) + (lambda (&rest _args) + (cl-incf calls) + (list :lines nil :complete nil :cache nil)))) + (let ((ebox-viewport-width 240) + (ebox-viewport-height 12) + (ebox--intrinsic-layout-measurement t)) + (setq rendered (ebox-render layout))) + (should (= calls 0)) + (should (= (ebox-string-height rendered) 10)) + (let ((ebox-viewport-width 240) + (ebox-viewport-height 12) + (ebox--intrinsic-layout-measurement nil)) + (ebox-render layout)) + (should (= calls 1))))) + +(ert-deftest ebox-lazy-scroll-sync-prefix-does-not-require-idle-prefetch () + "Interactive scrolling should extend a lazy prefix even with idle prefetch off." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) + :height (- (viewport-height) 10) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 80 + collect + `(box :content ,(format "sync row %02d" idx) + :width (viewport) + :height 1)))))) + buffer + root-id) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 20) + (ebox-scroll-step 4) + (ebox-scroll-lazy-idle-prefetch-lines 0) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((before (ebox--scroll-get-state root-id)) + (initial-lines (length (plist-get before :content-lines))) + (content-height (plist-get before :content-height)) + (boundary-offset (- initial-lines content-height))) + (should (> boundary-offset 0)) + (with-current-buffer buffer + (should (= boundary-offset + (ebox--scroll-region-by root-id + boundary-offset))))) + (with-current-buffer buffer + (should (= 4 (ebox--scroll-region-by root-id 4)))) + (let ((after (ebox--scroll-get-state root-id))) + (should (> (plist-get after :scroll-offset) 4)) + (should (>= (length (plist-get after :content-lines)) + (+ (plist-get after :scroll-offset) + (plist-get after :content-height)))) + (should-not (plist-get after :content-lines-complete-p)))) + (when root-id + (ebox--smooth-scroll-stop root-id)) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-gui-cache-miss-returns-pending () + "GUI scroll cache misses should not synchronously render missing content." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) + :height (- (viewport-height) 10) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 80 + collect + `(box :content ,(format "gui row %02d" idx) + :width (viewport) + :height 1)))))) + buffer + root-id + (prefix-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 20) + (ebox-scroll-step 4) + (ebox-scroll-lazy-idle-prefetch-lines 0) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (noninteractive nil)) + (cl-letf (((symbol-function 'display-graphic-p) + (lambda (&optional _display) t))) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (render-prefix (plist-get state :render-content-prefix)) + (initial-lines (length (plist-get state :content-lines))) + (content-height (plist-get state :content-height)) + (boundary-offset (- initial-lines content-height))) + (plist-put + state :render-content-prefix + (lambda (&rest args) + (cl-incf prefix-count) + (apply render-prefix args))) + (puthash root-id state ebox--scroll-global-state) + (with-current-buffer buffer + (should (= boundary-offset + (ebox--scroll-region-by root-id + boundary-offset))) + (should (eq 'pending + (ebox--scroll-region-by root-id 4))))) + (should (= prefix-count 0)))) + (when root-id + (ebox--smooth-scroll-stop root-id)) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-explicit-budget-syncs-prefix-in-gui () + "A caller-provided prefix budget should synchronously extend GUI lazy scrolls." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) + :height (- (viewport-height) 10) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 80 + collect + `(box :content ,(format "budget row %02d" idx) + :width (viewport) + :height 1)))))) + buffer + root-id + (prefix-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 20) + (ebox-scroll-step 4) + (ebox-scroll-lazy-idle-prefetch-lines 0) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (noninteractive nil)) + (cl-letf (((symbol-function 'display-graphic-p) + (lambda (&optional _display) t))) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (render-prefix (plist-get state :render-content-prefix)) + (initial-lines (length (plist-get state :content-lines))) + (content-height (plist-get state :content-height)) + (boundary-offset (- initial-lines content-height))) + (plist-put + state :render-content-prefix + (lambda (&rest args) + (cl-incf prefix-count) + (apply render-prefix args))) + (puthash root-id state ebox--scroll-global-state) + (with-current-buffer buffer + (should (= boundary-offset + (ebox--scroll-region-by root-id + boundary-offset))) + (should (= 4 (ebox--scroll-region-by root-id 4 4))))) + (should (= prefix-count 1)))) + (when root-id + (ebox--smooth-scroll-stop root-id)) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-scroll-rendered-window-sliding-is-used-in-gui () + "Graphical frames should use bounded line sliding for scroll windows." + (let ((noninteractive nil)) + (cl-letf (((symbol-function 'display-graphic-p) + (lambda (&optional _display) t))) + (should (ebox--scroll-rendered-window-slide-preferred-p))))) + +(ert-deftest ebox-lazy-scroll-prefix-reuses-viewport-dependent-flex-leaves () + "Lazy prefix extension should not rerender prior viewport flex leaves." + (ebox-test--reset-runtime-state) + (let* ((flex-section + (lambda (label) + `(flex :width (viewport) :flex-flow (row wrap) + ,@(cl-loop for idx below 4 + collect + `(box :content ,(format "%s-%d" label idx) + :width (110) + :height 1))))) + (layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,(funcall flex-section "a") + (spacer :height 1) + ,(funcall flex-section "b"))))) + (buffer nil) + root-id + (flex-render-count 0) + (original-render-flex (symbol-function 'ebox--render-flex))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-wheel-scroll-step 0) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (with-current-buffer buffer + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let ((state (ebox--scroll-get-state root-id))) + (should (= (length (plist-get state :content-lines)) 3)) + (should (plist-get state :render-content-prefix))) + (cl-letf (((symbol-function 'ebox--render-flex) + (lambda (&rest args) + (cl-incf flex-render-count) + (apply original-render-flex args)))) + (should (ebox--scroll-region-by root-id 3))) + (should (<= flex-render-count 1)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-flex-prefix-renders-only-needed-rows () + "Lazy scroll prefixes should not render hidden row-wrap flex rows." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + (box :id "head" :content "head" :width (viewport) :height 1) + (flex :width (viewport) :flex-flow (row wrap) + :row-gap 0 :column-gap 0 + ,@(cl-loop for idx below 12 + collect + `(box :id ,(format "item-%02d" idx) + :content ,(format "item %02d" idx) + :width (100) :height 2))))))) + (buffer nil) + (hidden-render-count 0) + (original-render-box (symbol-function 'ebox--render-box))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 1) + (ebox-wheel-scroll-step 0) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (box) + (when-let ((id (and (listp box) (ebox-get box :id)))) + (when (and (string-prefix-p "item-" id) + (>= (string-to-number + (substring id (length "item-"))) + 4)) + (cl-incf hidden-render-count))) + (funcall original-render-box box)))) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (should (= hidden-render-count 0))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-flex-window-lines-match-full-render-prefix () + "Partial flex window rendering should match the full render prefix." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :width (240) :flex-flow (row wrap) + :row-gap 1 :column-gap 0 + :padding (0 (8)) :border "#D97757" + :border-top-p nil :bgcolor "#FFF8F2" + ,@(cl-loop for idx below 6 + collect + `(box :id ,(format "prefix-item-%02d" idx) + :content ,(format "prefix %02d" idx) + :width (100) :height 2))))) + (limit 4) + (full-lines (ebox-string-lines (ebox-render node))) + (window (ebox--render-flex-window-lines node limit))) + (should-not (plist-get window :complete)) + (should (equal (mapcar #'substring-no-properties + (seq-take full-lines limit)) + (mapcar #'substring-no-properties + (plist-get window :lines)))))) + +(ert-deftest ebox-flex-window-shrinks-definite-wrapping-items () + "Window prefix rendering must not freeze shrinkable definite widths." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :width (160) :flex-flow (row wrap) + (box :id "shrink-a" :content "alpha beta gamma delta" + :width (200) :height 2) + (box :id "shrink-b" :content "second row content" + :width (160) :height 2)))) + (limit 2) + (full-lines (ebox-string-lines (ebox-render node))) + (window (ebox--render-flex-window-lines node limit))) + ;; A nil window means the full-render fallback takes over, which is + ;; also correct. Produced window lines must match the full + ;; renderer's prefix geometry instead of freezing the declared + ;; 200px width inside the 160px container. + (when window + (should (equal (mapcar #'ebox--string-pixel-width + (seq-take full-lines limit)) + (mapcar #'ebox--string-pixel-width + (plist-get window :lines))))))) + +(ert-deftest ebox-flex-window-char-wrapper-falls-back-to-full-prefix () + "Char-wrapped flex prefixes should use the full-render fallback." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :width (100) :wrap-mode char :flex-flow (row wrap) + :row-gap 0 :column-gap 0 + ,@(cl-loop for char across "ABCD" + collect + `(box :content ,(char-to-string char) + :width (150) :height 1))))) + (full-lines (ebox-string-lines (ebox-render node))) + (expected-signatures + (mapcar #'ebox-test--visual-line-signature + (seq-take full-lines 3))) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (first (ebox--render-stack-window-lines node 1)) + (extended + (ebox--render-stack-window-lines + node 3 (plist-get first :cache))) + (producer (ebox--render-node-window-lines node 3)) + (actual-signatures + (mapcar #'ebox-test--visual-line-signature + (plist-get extended :lines)))) + (should (equal expected-signatures actual-signatures)) + (should-not producer))) + +(ert-deftest ebox-flex-window-aligned-wrapper-falls-back-to-full-prefix () + "Non-left-aligned flex prefixes should use the full-render fallback." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :width (100) :text-align center :flex-flow (row wrap) + :row-gap 0 :column-gap 0 + ,@(cl-loop for char across "ABCDEF" + collect + `(box :content ,(char-to-string char) + :width (50) :height 1))))) + (full-lines (ebox-string-lines (ebox-render node))) + (expected-signatures + (mapcar #'ebox-test--visual-line-signature + (seq-take full-lines 3))) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (first (ebox--render-stack-window-lines node 1)) + (extended + (ebox--render-stack-window-lines + node 3 (plist-get first :cache))) + (producer (ebox--render-node-window-lines node 3)) + (actual-signatures + (mapcar #'ebox-test--visual-line-signature + (plist-get extended :lines)))) + (should (equal expected-signatures actual-signatures)) + (should-not producer))) + +(ert-deftest ebox-flex-window-lines-use-chunk-wrapper-for-first-prefix () + "A seedless flex prefix should avoid the generic full wrapper renderer." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :id "flex-window-wrapper" :width (240) + :flex-flow (row wrap) + :row-gap 1 :column-gap 0 + :padding (0 (8)) :border "#D97757" + :border-top-p nil :bgcolor "#FFF8F2" + ,@(cl-loop for idx below 8 + collect + `(box :content ,(format "wrapper %02d" idx) + :width (100) :height 2))))) + (full-lines (ebox-string-lines (ebox-render node))) + (full-wrapper-count 0) + (chunk-wrapper-count 0) + (multiline-horizontal-concat-count 0) + (original-full-wrapper + (symbol-function 'ebox--flex-window-render-box-lines)) + (original-chunk-wrapper + (symbol-function 'ebox--flex-window-render-wrapper-chunk-lines)) + (original-horizontal-concat + (symbol-function 'ebox--lines-concat-horizontal))) + (cl-letf (((symbol-function 'ebox--flex-window-render-box-lines) + (lambda (&rest args) + (cl-incf full-wrapper-count) + (apply original-full-wrapper args))) + ((symbol-function 'ebox--flex-window-render-wrapper-chunk-lines) + (lambda (&rest args) + (cl-incf chunk-wrapper-count) + (apply original-chunk-wrapper args))) + ((symbol-function 'ebox--lines-concat-horizontal) + (lambda (&rest args) + (cl-incf multiline-horizontal-concat-count) + (apply original-horizontal-concat args)))) + (let ((window (ebox--render-flex-window-lines node 4))) + (should window) + (should-not (plist-get window :complete)) + (should (= (length (plist-get window :lines)) 4)) + (should + (equal-including-properties + (ebox-lines-join (seq-take full-lines 4)) + (ebox-lines-join (plist-get window :lines)))))) + (should (= full-wrapper-count 0)) + (should (= chunk-wrapper-count 1)) + (should (= multiline-horizontal-concat-count 0)))) + +(ert-deftest ebox-flex-window-lines-use-chunk-wrapper-for-first-completion () + "A compatible seedless completion should avoid the generic full wrapper." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :id "flex-window-complete-wrapper" :width (240) + :flex-flow (row wrap) + :row-gap 1 :column-gap 0 + :padding (0 (8)) :border "#D97757" + :border-top-p nil :bgcolor "#FFF8F2" + ,@(cl-loop for idx below 4 + collect + `(box :content ,(format "complete %02d" idx) + :width (100) :height 2))))) + (full (ebox-render node)) + (full-wrapper-count 0) + (chunk-wrapper-count 0) + (original-full-wrapper + (symbol-function 'ebox--flex-window-render-box-lines)) + (original-chunk-wrapper + (symbol-function 'ebox--flex-window-render-wrapper-chunk-lines))) + (cl-letf (((symbol-function 'ebox--flex-window-render-box-lines) + (lambda (&rest args) + (cl-incf full-wrapper-count) + (apply original-full-wrapper args))) + ((symbol-function 'ebox--flex-window-render-wrapper-chunk-lines) + (lambda (&rest args) + (cl-incf chunk-wrapper-count) + (apply original-chunk-wrapper args)))) + (let ((window (ebox--render-flex-window-lines node 8))) + (should window) + (should (plist-get window :complete)) + (should + (equal-including-properties + full + (ebox-lines-join (plist-get window :lines)))))) + (should (= full-wrapper-count 0)) + (should (= chunk-wrapper-count 1)))) + +(ert-deftest ebox-flex-flat-wrapper-owns-color-copy-in-place () + "Flat flex wrappers should decorate one private line copy per source line." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :width 120 + :padding-left 8 + :padding-right 8 + :border-left 1 + :border-right 1 + :border-color "#D97757" + :bgcolor "#FFF8F2")) + (source (propertize "child" 'ebox-content 77 + 'ebox-content-owner 77 + 'ebox-content-owners '(77))) + (source-before (copy-sequence source)) + (expected + (ebox--flex-window-render-wrapper-chunk-lines + box (list source) 0 t)) + (copying-owner-count 0) + (original-copying-owner + (symbol-function 'ebox--add-content-owner)) + actual) + (cl-letf (((symbol-function 'ebox--add-content-owner) + (lambda (&rest args) + (cl-incf copying-owner-count) + (apply original-copying-owner args)))) + (setq actual + (ebox--flex-window-render-wrapper-chunk-lines + box (list source) 0 t))) + (should (equal-including-properties source source-before)) + (should + (equal-including-properties + (ebox-lines-join actual) + (ebox-lines-join expected))) + (should (= copying-owner-count 0)))) + +(ert-deftest ebox-flex-window-lines-reuses-render-cache () + "Repeated flex window renders should reuse the render cache." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :width (240) :flex-flow (row wrap) + :row-gap 1 :column-gap 0 + ,@(cl-loop for idx below 8 + collect + `(box :id ,(format "cache-item-%02d" idx) + :content ,(format "cache %02d" idx) + :width (100) :height 2))))) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (row-render-count 0) + (original-render-row-line + (symbol-function 'ebox--flex-render-row-line))) + (cl-letf (((symbol-function 'ebox--flex-render-row-line) + (lambda (&rest args) + (cl-incf row-render-count) + (apply original-render-row-line args)))) + (should (ebox--render-flex-window-lines node 4)) + (let ((after-first row-render-count)) + (should (> after-first 0)) + (should (ebox--render-flex-window-lines node 4)) + (should (= row-render-count after-first)))))) + +(ert-deftest ebox-flex-window-lines-reuses-node-cache-signature () + "One flex window render should not repeatedly compute the node signature." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :width (240) :flex-flow (row wrap) + :row-gap 1 :column-gap 0 + ,@(cl-loop for idx below 8 + collect + `(box :id ,(format "sig-item-%02d" idx) + :content ,(format "sig %02d" idx) + :width (100) :height 2))))) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (signature-count 0) + (original-signature + (symbol-function 'ebox--render-cache-node-signature))) + (cl-letf (((symbol-function 'ebox--render-cache-node-signature) + (lambda (&rest args) + (when (eq (car args) node) + (cl-incf signature-count)) + (apply original-signature args)))) + (should (ebox--render-flex-window-lines node 4))) + (should (<= signature-count 2)))) + +(ert-deftest ebox-flex-window-prefix-extension-reuses-wrapper-lines () + "Extending a flex prefix should not rerender prior wrapper lines." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :id "flex-prefix-wrapper" :width (240) + :flex-flow (row wrap) + :row-gap 1 :column-gap 0 + :padding (0 (8)) :border "#D97757" + :border-top-p nil :bgcolor "#FFF8F2" + ,@(cl-loop for idx below 8 + collect + `(box :content ,(format "extend %02d" idx) + :width (100) :height 2))))) + (full-lines (mapcar #'substring-no-properties + (ebox-string-lines (ebox-render node)))) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (generic-wrapper-render-count 0) + (original-render-box (symbol-function 'ebox--render-box))) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (box) + (when (equal (and (listp box) (ebox-get box :id)) + "flex-prefix-wrapper") + (cl-incf generic-wrapper-render-count)) + (funcall original-render-box box)))) + (should (ebox--render-flex-window-lines node 4)) + (should (= generic-wrapper-render-count 0)) + (let ((window (ebox--render-flex-window-lines node 8))) + (should window) + (should (= generic-wrapper-render-count 0)) + (should (equal (seq-take full-lines 8) + (mapcar #'substring-no-properties + (plist-get window :lines)))))))) + +(ert-deftest ebox-flex-window-prefix-extension-joins-only-new-pieces () + "Extending a flex prefix should not rejoin the whole cached prefix." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :width (240) :flex-flow (row wrap) + :row-gap 1 :column-gap 0 + :padding (0 (8)) :border "#D97757" + :border-top-p nil :bgcolor "#FFF8F2" + ,@(cl-loop for idx below 16 + collect + `(box :content ,(format "piece %02d" idx) + :width (100) :height 2))))) + (ebox--render-cache-table (make-hash-table :test 'equal)) + join-piece-counts + (original-join-pieces + (symbol-function 'ebox--flex-window-lines-from-pieces))) + (cl-letf (((symbol-function 'ebox--flex-window-lines-from-pieces) + (lambda (pieces) + (push (length pieces) join-piece-counts) + (funcall original-join-pieces pieces)))) + (should (ebox--render-flex-window-lines node 4)) + (setq join-piece-counts nil) + (should (ebox--render-flex-window-lines node 8))) + (should join-piece-counts) + (should-not (cl-some (lambda (count) (> count 4)) + join-piece-counts)))) + +(ert-deftest ebox-flex-window-complete-prefix-defers-bottom-wrapper () + "A complete flex source should not full-render bottom chrome before LIMIT." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :id "flex-complete-prefix" :width (240) + :flex-flow (row wrap) + :row-gap 1 :column-gap 0 + :padding (0 (8) 2 (8)) + :margin-bottom 2 + :border "#D97757" + :border-top-p nil + :bgcolor "#FFF8F2" + ,@(cl-loop for idx below 2 + collect + `(box :content ,(format "complete %02d" idx) + :width (100) :height 8))))) + (limit 4) + (full-lines (mapcar #'substring-no-properties + (ebox-string-lines (ebox-render node)))) + (complete-wrapper-count 0) + (original-render-box-lines + (symbol-function 'ebox--flex-window-render-box-lines))) + (cl-letf (((symbol-function 'ebox--flex-window-render-box-lines) + (lambda (node content complete limit) + (when complete + (cl-incf complete-wrapper-count)) + (funcall original-render-box-lines + node content complete limit)))) + (let ((window (ebox--render-flex-window-lines node limit))) + (should window) + (should-not (plist-get window :complete)) + (should (= complete-wrapper-count 0)) + (should (equal (seq-take full-lines limit) + (mapcar #'substring-no-properties + (plist-get window :lines)))))))) + +(ert-deftest ebox-flex-window-complete-extension-appends-bottom-border () + "Completing a cached flex prefix should append bottom border without full render." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :id "flex-complete-border" :width (240) + :flex-flow (row wrap) + :row-gap 1 :column-gap 0 + :padding (0 (8)) + :border "#D97757" + :border-top-p nil + :bgcolor "#FFF8F2" + ,@(cl-loop for idx below 4 + collect + `(box :content ,(format "border %02d" idx) + :width (100) :height 2))))) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (full-lines (mapcar #'substring-no-properties + (ebox-string-lines (ebox-render node)))) + (complete-wrapper-count 0) + (original-render-box-lines + (symbol-function 'ebox--flex-window-render-box-lines))) + (should (ebox--render-flex-window-lines node 4)) + (cl-letf (((symbol-function 'ebox--flex-window-render-box-lines) + (lambda (node content complete limit) + (when complete + (cl-incf complete-wrapper-count)) + (funcall original-render-box-lines + node content complete limit)))) + (let ((window (ebox--render-flex-window-lines node 8))) + (should window) + (should (plist-get window :complete)) + (should (= complete-wrapper-count 0)) + (should (equal (seq-take full-lines 8) + (mapcar #'substring-no-properties + (plist-get window :lines)))))))) + +(ert-deftest ebox-flex-window-failed-completion-keeps-prefix-cache-atomic () + "A failed flex completion should not publish half-appended cache lists." + (ebox-test--reset-runtime-state) + (let* ((node + (ebox-build + `(flex :id "flex-atomic-prefix" :width (240) + :flex-flow (row wrap) + :row-gap 1 :column-gap 0 + :padding (0 (8)) + :border "#D97757" + :border-top-p nil + ,@(cl-loop for idx below 4 + collect + `(box :content ,(format "atomic %02d" idx) + :width (100) :height 2))))) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (context (ebox--flex-window-cache-context node))) + (should (ebox--render-flex-window-lines node 4)) + (let* ((before (ebox--flex-window-prefix-cache-lookup node context)) + (before-lines (plist-get before :lines)) + (before-content-lines (plist-get before :content-lines)) + (before-line-count (plist-get before :line-count)) + (before-content-count (plist-get before :content-line-count))) + (cl-letf (((symbol-function 'ebox--flex-window-complete-tail-line) + (lambda (&rest _) + (error "injected flex completion failure")))) + (should-error (ebox--render-flex-window-lines node 8))) + (let ((after (ebox--flex-window-prefix-cache-lookup node context))) + (should (eq before-lines (plist-get after :lines))) + (should (eq before-content-lines + (plist-get after :content-lines))) + (should (= before-line-count (length before-lines))) + (should (= before-content-count (length before-content-lines))) + (should (= (plist-get after :line-count) + (length (plist-get after :lines)))) + (should (= (plist-get after :content-line-count) + (length (plist-get after :content-lines))))) + (should (plist-get (ebox--render-flex-window-lines node 8) + :complete))))) + +(ert-deftest ebox-stack-window-completion-reconciles-declared-prefix-tail () + "A completed leaf should refresh declared chrome on its cached tail." + (ebox-test--reset-runtime-state) + (let* ((type 'ebox-test-completing-window) + (leaf (list :ebox-type type)) + (stack (list :ebox-type 'stack :children (list leaf))) + (plain-tail (propertize "tail" 'face '(:foreground "red"))) + (complete-tail + (ebox--propertize-underline + (copy-sequence plain-tail) "blue"))) + (unwind-protect + (progn + (ebox--register-window-line-renderer + type + (lambda (_node limit) + (if (< limit 3) + (list :lines (list "head" plain-tail) + :complete nil) + (list :lines (list "head" complete-tail) + :complete t + :completion-prefix-tail-rewrite-count 1)))) + (let* ((prefix (ebox--render-stack-window-lines stack 2)) + (completed + (ebox--render-stack-window-lines + stack 3 (plist-get prefix :cache))) + (tail (cadr (plist-get completed :lines))) + (face (get-text-property 0 'face tail)) + (delta (plist-get completed :prefix-delta))) + (should (plist-get completed :complete)) + (should (= (length (plist-get completed :lines)) 2)) + (should (plist-get delta :exact-p)) + (should (= (plist-get delta :base-count) 2)) + (should (equal (plist-get delta :rewrite-range) '(1 . 2))) + (should + (ebox-test--face-has-key-value-p + face :underline '(:position t :color "blue"))))) + (remhash type ebox--window-line-renderer-table)))) + +(ert-deftest ebox-stack-window-invalid-completion-rewrite-rebuilds-prefix () + "Invalid completion metadata should discard a potentially stale prefix." + (ebox-test--reset-runtime-state) + (dolist (metadata '(3 -1 invalid missing)) + (let* ((type 'ebox-test-invalid-completing-window) + (leaf (list :ebox-type type)) + (stack (list :ebox-type 'stack :children (list leaf))) + (plain-tail "tail") + (complete-tail + (ebox--propertize-underline "tail" "blue")) + (calls 0)) + (unwind-protect + (progn + (ebox--register-window-line-renderer + type + (lambda (_node limit) + (cl-incf calls) + (if (< limit 3) + (list :lines (list "head" plain-tail) + :complete nil) + (append + (list :lines (list "head" complete-tail) + :complete t) + (unless (eq metadata 'missing) + (list :completion-prefix-tail-rewrite-count + metadata)))))) + (let* ((prefix (ebox--render-stack-window-lines stack 2)) + (completed + (ebox--render-stack-window-lines + stack 3 (plist-get prefix :cache))) + (tail (cadr (plist-get completed :lines))) + (face (get-text-property 0 'face tail)) + (delta (plist-get completed :prefix-delta))) + (should (= calls 3)) + (should-not (plist-get delta :exact-p)) + (should + (ebox-test--face-has-key-value-p + face :underline '(:position t :color "blue"))))) + (remhash type ebox--window-line-renderer-table))))) + +(ert-deftest ebox-flex-window-completion-tail-metadata-is-border-specific () + "Only an existing bottom-border line should request a prefix rewrite." + (ebox-test--reset-runtime-state) + (should + (= 1 + (ebox--flex-window-completion-prefix-tail-rewrite-count + (list :box (ebox-create :border-bottom-p t + :padding-bottom 0))))) + (should + (= 1 + (ebox--flex-window-completion-prefix-tail-rewrite-count + (list :box (ebox-create :border-bottom-p t + :padding-bottom 0 + :margin-bottom 2))))) + (should + (= 0 + (ebox--flex-window-completion-prefix-tail-rewrite-count + (list :box (ebox-create :border-bottom-p t + :padding-bottom 2))))) + (should + (= 0 + (ebox--flex-window-completion-prefix-tail-rewrite-count + (list :box (ebox-create :border-bottom-p nil + :padding-bottom 0)))))) + +(ert-deftest ebox-flex-window-cache-rejects-short-incomplete-exact-hit () + "An underfilled exact-limit cache must resume from its prefix seed." + (ebox-test--reset-runtime-state) + (let* ((lines (cl-loop repeat 112 collect "line")) + (entry (list :lines lines + :line-count 112 + :line-tail (last lines) + :complete nil)) + looked-up-keys) + (cl-letf (((symbol-function 'ebox--render-cache-lookup) + (lambda (key _signature) + (push key looked-up-keys) + (cond + ((equal key '(7 flex-window prefix)) entry) + ((equal key '(7 flex-window 118)) entry))))) + (should-not + (ebox--flex-window-cache-lookup + :test-node 118 '(:node-id 7 :signature test-signature)))) + (should (member '(7 flex-window 118) looked-up-keys)))) + +(ert-deftest ebox-byte-compiled-scroll-window-binds-wrapper-viewport () + "Compiled lazy rendering must dynamically bind the wrapper content width." + (ebox-test--reset-runtime-state) + (let* ((wrapper (ebox-create :content "" :width '(278) :wrap-mode nil)) + (source (list :stack :test-stack :wrapper wrapper)) + observed-viewport) + (cl-letf (((symbol-function 'ebox--render-stack-window-lines) + (lambda (&rest _args) + (setq observed-viewport ebox-viewport-width) + (throw 'viewport-observed nil)))) + (let ((ebox-viewport-width 900)) + (catch 'viewport-observed + (ebox--render-scroll-window-source source 1)))) + (should (= observed-viewport 278)))) + +(ert-deftest ebox-scroll-window-wrapper-applies-declared-prefix-rewrite () + "Wrapper prefix reuse should rerender only a declared changed range." + (ebox-test--reset-runtime-state) + (let* ((wrapper (ebox-create :content "" :width 20 :wrap-mode nil)) + (source (list :stack :test-stack :wrapper wrapper)) + (plain-tail "tail") + (complete-tail + (ebox--propertize-underline "tail" "blue")) + (stack-lines (list plain-tail)) + (calls 0) + (second-call-line-count 0) + (original-chunk-renderer + (symbol-function + 'ebox--scroll-window-render-wrapper-chunk-lines))) + (cl-letf (((symbol-function 'ebox--render-stack-window-lines) + (lambda (&rest _args) + (cl-incf calls) + (if (= calls 1) + (list :lines stack-lines + :line-count 1 + :line-tail stack-lines + :complete nil + :cache :first + :prefix-delta + '(:exact-p nil :base-count 0 + :rewrite-range nil + :rewrite-lines nil + :append-lines nil)) + (setcar stack-lines complete-tail) + (list :lines stack-lines + :line-count 1 + :line-tail stack-lines + :complete nil + :cache :second + :prefix-delta + (list :exact-p t :base-count 1 + :rewrite-range '(0 . 1) + :rewrite-lines (list complete-tail) + :append-lines nil))))) + ((symbol-function + 'ebox--scroll-window-render-wrapper-chunk-lines) + (lambda (&rest args) + (when (= calls 2) + (cl-incf second-call-line-count + (length (cadr args)))) + (apply original-chunk-renderer args))) + ((symbol-function 'equal-including-properties) + (lambda (&rest _) + (ert-fail "wrapper reuse scanned the whole prefix")))) + (let* ((prefix (ebox--render-scroll-window-source source 1)) + (extended + (ebox--render-scroll-window-source + source 2 (plist-get prefix :cache))) + (line (car (plist-get extended :lines))) + (face (get-text-property 0 'face line)) + (delta (plist-get extended :prefix-delta))) + (should (= calls 2)) + (should (= second-call-line-count 1)) + (should (plist-get delta :exact-p)) + (should (= (plist-get delta :base-count) 1)) + (should (equal (plist-get delta :rewrite-range) '(0 . 1))) + (should + (ebox-test--face-has-key-value-p + face :underline '(:position t :color "blue"))))))) + +(ert-deftest ebox-scroll-window-wrapper-maps-top-chrome-rewrite-ranges () + "Wrapper reuse should include top chrome only when source line zero changes." + (ebox-test--reset-runtime-state) + (let ((wrapper (ebox-create :content "" :width 20 :wrap-mode nil + :margin-top 1 :padding-top 2))) + (cl-labels + ((mapped-range (source-range) + (let* ((source + (list :stack :test-stack :wrapper wrapper)) + (source-lines (list "head" "body")) + (calls 0)) + (cl-letf + (((symbol-function 'ebox--render-stack-window-lines) + (lambda (&rest _) + (cl-incf calls) + (when (= calls 2) + (setcar (nthcdr (car source-range) source-lines) + "changed")) + (list + :lines source-lines + :line-count 2 + :line-tail (last source-lines) + :complete nil + :cache calls + :prefix-delta + (if (= calls 1) + '(:exact-p nil :base-count 0 + :rewrite-range nil :rewrite-lines nil + :append-lines nil) + (list :exact-p t :base-count 2 + :rewrite-range source-range + :rewrite-lines (list "changed") + :append-lines nil)))))) + (let* ((prefix + (ebox--render-scroll-window-source source 2)) + (extended + (ebox--render-scroll-window-source + source 3 (plist-get prefix :cache)))) + (plist-get (plist-get extended :prefix-delta) + :rewrite-range)))))) + (should (equal (mapped-range '(0 . 1)) '(0 . 4))) + (should (equal (mapped-range '(1 . 2)) '(4 . 5)))))) + +(ert-deftest ebox-scroll-prefix-renderer-applies-equal-length-prefix-rewrite () + "Equal-length completion should rerender only its declared changed line." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "" :width 20 :height 1 + :wrap-mode nil)) + (plain-lines '("head" "body" "tail")) + (source-lines (copy-sequence plain-lines)) + (complete-tail + (ebox--propertize-underline "tail" "blue")) + (renderer + (ebox--scroll-window-prefix-renderer box :test-source 20 4)) + (rendered-line-count 0) + (original-renderer + (symbol-function 'ebox--scroll-rendered-content-lines))) + (cl-letf (((symbol-function 'ebox--render-scroll-window-source) + (lambda (_source limit &optional _cache) + (when (> limit 3) + (setcar (nthcdr 2 source-lines) complete-tail)) + (list :lines source-lines + :line-count 3 + :line-tail (last source-lines) + :cache nil + :complete (> limit 3) + :content-width-exact-p t + :rendered-lines nil + :prefix-delta + (if (= limit 3) + '(:exact-p nil :base-count 0 + :rewrite-range nil + :rewrite-lines nil + :append-lines nil) + (list :exact-p t :base-count 3 + :rewrite-range '(2 . 3) + :rewrite-lines (list complete-tail) + :append-lines nil))))) + ((symbol-function 'ebox--scroll-window-formatted-lines) + (lambda (_box window) (plist-get window :lines))) + ((symbol-function 'ebox--scroll-window-source-lines-exact-p) + (lambda (&rest _) t)) + ((symbol-function 'ebox--scroll-rendered-content-lines) + (lambda (&rest args) + (cl-incf rendered-line-count (length (cadr args))) + (apply original-renderer args))) + ((symbol-function 'equal-including-properties) + (lambda (&rest _) + (ert-fail "rendered prefix reuse scanned all old lines")))) + (let* ((prefix (funcall renderer nil 1 3)) + (_ (setq rendered-line-count 0)) + (completed (funcall renderer prefix 1 4)) + (lines (plist-get completed :rendered-content-lines)) + (line (nth 2 lines)) + (face (get-text-property 0 'face line))) + (should (plist-get completed :complete)) + (should (= (length lines) 3)) + (should (= rendered-line-count 1)) + (should + (ebox-test--face-has-key-value-p + face :underline '(:position t :color "blue"))))))) + +(ert-deftest ebox-scroll-prefix-projects-wrapper-top-chrome-delta () + "A published prefix should reuse a source cache with hidden top chrome." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "" :width 20 :height 1 + :wrap-mode nil)) + (wrapper (ebox-create :content "" :width 20 :wrap-mode nil + :padding-top 2 :margin-top 1)) + (source (list :stack :test-stack :wrapper wrapper)) + stack-lines + stack-tail + (stack-count 0) + (calls 0) + (rendered-line-count 0) + (original-renderer + (symbol-function 'ebox--scroll-rendered-content-lines)) + (renderer + (ebox--scroll-window-prefix-renderer box source 20 4))) + (cl-letf (((symbol-function 'ebox--render-stack-window-lines) + (lambda (_stack limit &optional _cache) + (cl-incf calls) + (let ((base stack-count) + appended-lines) + (while (< stack-count limit) + (let ((cell (list (format "row-%d" stack-count)))) + (if stack-tail + (setcdr stack-tail cell) + (setq stack-lines cell)) + (setq stack-tail cell) + (push (car cell) appended-lines) + (cl-incf stack-count))) + (setq appended-lines (nreverse appended-lines)) + (list :lines stack-lines + :line-count stack-count + :line-tail stack-tail + :complete nil + :cache (list :call calls) + :prefix-delta + (if (= calls 1) + '(:exact-p nil :base-count 0 + :rewrite-range nil + :rewrite-lines nil + :append-lines nil) + (list :exact-p t + :base-count base + :rewrite-range nil + :rewrite-lines nil + :append-lines appended-lines)))))) + ((symbol-function 'ebox--scroll-window-formatted-lines) + (lambda (_box window) (plist-get window :lines))) + ((symbol-function 'ebox--scroll-window-source-lines-exact-p) + (lambda (&rest _) t)) + ((symbol-function 'ebox--scroll-rendered-content-lines) + (lambda (&rest args) + (cl-incf rendered-line-count (length (cadr args))) + (apply original-renderer args))) + ((symbol-function 'equal-including-properties) + (lambda (&rest _) + (ert-fail "top-chrome reuse scanned the whole prefix")))) + (let* ((prefix (funcall renderer nil 1 6)) + (prefix-count + (length (plist-get prefix :content-lines))) + (_ (setq rendered-line-count 0)) + (extended (funcall renderer prefix 1 7))) + (should (= calls 2)) + (should (= prefix-count 6)) + (should (= (length (plist-get extended :content-lines)) 7)) + (should (= rendered-line-count 1)))))) + +(ert-deftest ebox-scroll-prefix-exact-extension-scans-only-appended-lines () + "A long exact prefix should retain its cons cells and scan only its delta." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "" :width 20 :height 1 + :wrap-mode nil)) + (source-lines + (cl-loop for idx below 1000 collect (format "line-%d" idx))) + (source-tail (last source-lines)) + (source-count 1000) + (calls 0) + scan-sizes + record-scans + (original-seq-take (symbol-function 'seq-take)) + (renderer + (ebox--scroll-window-prefix-renderer box :test-source 20 4))) + (cl-letf (((symbol-function 'ebox--render-scroll-window-source) + (lambda (_source limit &optional _cache) + (cl-incf calls) + (let ((base source-count) + appended-lines) + (while (< source-count limit) + (let ((cell (list (format "line-%d" source-count)))) + (setcdr source-tail cell) + (setq source-tail cell) + (push (car cell) appended-lines) + (cl-incf source-count))) + (setq appended-lines (nreverse appended-lines)) + (list :lines source-lines + :line-count source-count + :line-tail source-tail + :cache :same + :complete nil + :content-width-exact-p t + :rendered-lines nil + :prefix-delta + (if (= calls 1) + '(:exact-p nil :base-count 0 + :rewrite-range nil + :rewrite-lines nil + :append-lines nil) + (list :exact-p t :base-count base + :rewrite-range nil + :rewrite-lines nil + :append-lines appended-lines)))))) + ((symbol-function 'ebox--scroll-window-formatted-lines) + (lambda (_box window) (plist-get window :lines))) + ((symbol-function 'ebox--scroll-window-source-lines-exact-p) + (lambda (&rest _) t)) + ((symbol-function 'ebox--scroll-rendered-content-lines) + (lambda (_box lines _region-id _start-index) lines)) + ((symbol-function 'seq-take) + (lambda (sequence count) + (when record-scans + (push (length sequence) scan-sizes)) + (funcall original-seq-take sequence count)))) + (let* ((prefix (funcall renderer nil 1 1000)) + (content-head (plist-get prefix :content-lines))) + (setq record-scans t) + (let ((extended (funcall renderer prefix 1 1001))) + (should (eq content-head + (plist-get extended :content-lines))) + (should (= (length (plist-get extended :content-lines)) 1001)) + (should scan-sizes) + (should (<= (apply #'max scan-sizes) 1))))))) + +(ert-deftest ebox-stack-window-completion-rewrite-uses-delta-payload () + "A long leaf completion should not scan its full prefix for one rewrite." + (ebox-test--reset-runtime-state) + (let* ((type 'ebox-test-long-completing-window) + (leaf (list :ebox-type type)) + (stack (list :ebox-type 'stack :children (list leaf))) + (leaf-lines + (cl-loop for idx below 1000 collect (format "line-%d" idx))) + (leaf-tail (last leaf-lines)) + (complete-tail + (ebox--propertize-underline "line-999" "blue")) + (original-length (symbol-function 'length)) + (original-nthcdr (symbol-function 'nthcdr)) + (original-seq-subseq (symbol-function 'seq-subseq)) + record-scans + (full-length-count 0) + (full-nthcdr-count 0) + (full-subseq-count 0)) + (unwind-protect + (progn + (ebox--register-window-line-renderer + type + (lambda (_node limit) + (if (<= limit 1000) + (list :lines leaf-lines + :line-count 1000 + :line-tail leaf-tail + :complete nil) + (setcar leaf-tail complete-tail) + (list :lines leaf-lines + :line-count 1000 + :line-tail leaf-tail + :complete t + :completion-prefix-tail-rewrite-count 1 + :prefix-delta + (list :exact-p t :base-count 1000 + :rewrite-range '(999 . 1000) + :rewrite-lines (list complete-tail) + :append-lines nil))))) + (let ((prefix (ebox--render-stack-window-lines stack 1000))) + (cl-letf (((symbol-function 'length) + (lambda (sequence) + (when (and record-scans (eq sequence leaf-lines)) + (cl-incf full-length-count)) + (funcall original-length sequence))) + ((symbol-function 'nthcdr) + (lambda (count sequence) + (when (and record-scans (eq sequence leaf-lines)) + (cl-incf full-nthcdr-count)) + (funcall original-nthcdr count sequence))) + ((symbol-function 'seq-subseq) + (lambda (sequence start &optional end) + (when (and record-scans (eq sequence leaf-lines)) + (cl-incf full-subseq-count)) + (funcall original-seq-subseq sequence start end)))) + (setq record-scans t) + (let ((completed + (ebox--render-stack-window-lines + stack 1001 (plist-get prefix :cache)))) + (setq record-scans nil) + (should (plist-get completed :complete)) + (should (eq (car (plist-get completed :line-tail)) + complete-tail))))) + (should (= full-length-count 0)) + (should (= full-nthcdr-count 0)) + (should (= full-subseq-count 0))) + (remhash type ebox--window-line-renderer-table)))) + +(ert-deftest ebox-scroll-prefix-renderer-failure-keeps-published-pair-atomic () + "A failed rendered delta should preserve both published prefix lists." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "" :width 20 :height 1 + :wrap-mode nil)) + (source-lines (list "line-0" "line-1" "line-2")) + (source-tail (last source-lines)) + (calls 0) + cache-arguments + fail-render + (original-renderer + (symbol-function 'ebox--scroll-rendered-content-lines)) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (renderer + (ebox--scroll-window-prefix-renderer + box :test-source 20 4))) + (cl-letf + (((symbol-function 'ebox--render-scroll-window-source) + (lambda (_source _limit &optional cache) + (push cache cache-arguments) + (cl-incf calls) + (cond + ((= calls 1) + (list :lines source-lines + :line-count 3 + :line-tail source-tail + :cache :first + :complete nil + :content-width-exact-p t + :rendered-lines source-lines + :prefix-delta + '(:exact-p nil :base-count 0 + :rewrite-range nil :rewrite-lines nil + :append-lines nil))) + ((= calls 2) + (let ((cell (list "line-3"))) + (setcdr source-tail cell) + (setq source-tail cell)) + (list :lines source-lines + :line-count 4 + :line-tail source-tail + :cache :second + :complete nil + :content-width-exact-p t + :rendered-lines source-lines + :prefix-delta + '(:exact-p t :base-count 3 + :rewrite-range nil :rewrite-lines nil + :append-lines ("line-3")))) + (t + (let ((fresh (list "line-0" "line-1" "line-2" "line-3"))) + (list :lines fresh + :line-count 4 + :line-tail (last fresh) + :cache :recovered + :complete nil + :content-width-exact-p t + :rendered-lines fresh + :prefix-delta + '(:exact-p nil :base-count 0 + :rewrite-range nil :rewrite-lines nil + :append-lines nil))))))) + ((symbol-function 'ebox--scroll-window-formatted-lines) + (lambda (_box window) (plist-get window :lines))) + ((symbol-function 'ebox--scroll-window-source-lines-exact-p) + (lambda (&rest _) t)) + ((symbol-function 'ebox--scroll-rendered-content-lines) + (lambda (&rest args) + (if (and fail-render (= (nth 3 args) 3)) + (error "injected rendered-prefix failure") + (apply original-renderer args))))) + (let* ((prefix (funcall renderer nil 1 3)) + (content-lines (plist-get prefix :content-lines)) + (rendered-lines (plist-get prefix :rendered-content-lines))) + (puthash :poison t ebox--render-cache-table) + (setq fail-render t) + (should-error (funcall renderer prefix 1 4)) + (should (= (length content-lines) 3)) + (should (= (length rendered-lines) 3)) + (should-not (cdr (last content-lines))) + (should-not (cdr (last rendered-lines))) + (should (= (hash-table-count ebox--render-cache-table) 0)) + (setq fail-render nil) + (let ((recovered (funcall renderer prefix 1 4))) + (should (= (length (plist-get recovered :content-lines)) 4)) + (should (= (length (plist-get recovered + :rendered-content-lines)) + 4)))) + (setq cache-arguments (nreverse cache-arguments)) + (should (equal cache-arguments '(nil :first nil)))))) + +(ert-deftest ebox-stack-window-leaf-lines-uses-registered-producer () + "Lazy stack scrolling should use generic window-line producers." + (ebox-test--reset-runtime-state) + (let ((node (list :ebox-type 'test-window-producer)) + (call-count 0) + result) + (unwind-protect + (progn + (ebox--register-window-line-renderer + 'test-window-producer + (lambda (_node limit) + (cl-incf call-count) + (list :lines (seq-take '("aa" "bb" "cc") limit) + :complete nil))) + (setq result + (ebox--render-stack-window-leaf-lines node 2 0)) + (should (= call-count 1)) + (should-not (plist-get result :complete)) + (should (equal '("aa" "bb") + (mapcar #'substring-no-properties + (plist-get result :lines))))) + (remhash 'test-window-producer + ebox--window-line-renderer-table)))) + +(ert-deftest ebox-scroll-prefix-yielding-prewarm-converges-after-zero-line-slice () + "A prewarm-only prefix slice should yield once, then publish exact content." + (ebox-test--reset-runtime-state) + (let* ((node (list :ebox-type 'test-yielding-prewarm)) + (box (ebox-create :content "" :width 20 :height 1 + :wrap-mode nil :overflow 'scroll)) + (renderer + (ebox--scroll-window-prefix-renderer + box (list :stack node) 20 1)) + (prewarm-count 0) + (render-count 0)) + (unwind-protect + (progn + (ebox--register-window-line-prewarmer + 'test-yielding-prewarm + (lambda (_node _target-width) + (cl-incf prewarm-count) + (= prewarm-count 1))) + (ebox--register-window-line-renderer + 'test-yielding-prewarm + (lambda (_node _limit) + (cl-incf render-count) + (list :lines '("ready") :line-count 1 + :line-tail '("ready") :complete t))) + (let* ((ebox--scroll-window-yielding-prewarm-p t) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (first (funcall renderer nil "yield-region" 1)) + (second (funcall renderer first "yield-region" 1)) + (complete (funcall renderer second "yield-region" 2))) + (should (= prewarm-count 2)) + (should (= render-count 1)) + (should-not (plist-get first :content-lines)) + (should-not (plist-get first :complete)) + (should + (equal '("ready") + (mapcar #'substring-no-properties + (plist-get second :content-lines)))) + (should-not (plist-get second :complete)) + (should (plist-get complete :complete)) + (should + (equal '("ready") + (mapcar #'substring-no-properties + (plist-get complete :content-lines)))))) + (remhash 'test-yielding-prewarm + ebox--window-line-prewarmer-table) + (remhash 'test-yielding-prewarm + ebox--window-line-renderer-table)))) + +(ert-deftest ebox-scroll-stack-window-defers-hidden-leaf-render () + "Large stack-backed scroll boxes should render hidden rows only on demand." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) :padding (1 0) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (hidden-render-count 0) + (original-render-box (symbol-function 'ebox--render-box))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2)) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (box) + (when (equal (ebox-tree-node-id box) "row-30") + (cl-incf hidden-render-count)) + (funcall original-render-box box)))) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (should (= hidden-render-count 0)) + (with-current-buffer buffer + (should (string-match-p "row 00" + (buffer-substring-no-properties + (point-min) (point-max)))) + (should-not (string-match-p "row 30" + (buffer-substring-no-properties + (point-min) (point-max)))) + (should (ebox--scroll-region-by root-id 31)) + (should (= hidden-render-count 0)) + (should (gethash root-id ebox--scroll-idle-prefetch-timers)) + (ebox--scroll-idle-prefetch root-id) + (should (> hidden-render-count 0)) + (let* ((state (ebox--scroll-get-state root-id)) + (delta (- 30 (or (plist-get state :scroll-offset) + 0)))) + (when (> delta 0) + (should (ebox--scroll-region-by root-id delta)))) + (should (string-match-p "row 30" + (buffer-substring-no-properties + (point-min) (point-max))))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-incremental-render-covers-wheel-drain () + "Incremental lazy scroll renders should cover one smooth wheel drain." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "budget-row-%02d" idx) + :content ,(format "budget row %02d" idx) + :height 1)))))) + (limits nil) + (original-render-source + (symbol-function 'ebox--render-scroll-window-source))) + (let ((ebox-viewport-width 240) + (ebox-viewport-height 6) + (ebox--defer-scroll-content-index t) + (ebox-wheel-smooth-scroll t) + (ebox-wheel-scroll-step 16) + (ebox-wheel-smooth-scroll-lines-per-tick 4) + (ebox-scroll-lazy-prefix-lookahead-lines 8)) + (cl-letf (((symbol-function 'ebox--render-scroll-window-source) + (lambda (source limit &optional cache) + (push limit limits) + (funcall original-render-source source limit cache)))) + (ebox-render layout))) + (should (equal (nreverse limits) '(23))))) + +(ert-deftest ebox-lazy-scroll-small-step-extends-prefix-only () + "Small lazy scroll steps should not materialize the whole source." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (hidden-render-count 0) + (original-render-box (symbol-function 'ebox--render-box))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2)) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (box) + (when (equal (ebox-tree-node-id box) "row-30") + (cl-incf hidden-render-count)) + (funcall original-render-box box)))) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (should (= hidden-render-count 0)) + (with-current-buffer buffer + (should (ebox--scroll-region-by root-id 1)) + (should (= hidden-render-count 0)) + (let ((state (ebox--scroll-get-state root-id))) + (should (= (plist-get state :scroll-offset) 1)) + (should (< (length (plist-get state :content-lines)) 40)) + (should (plist-get state :materialize-content-lines))) + (should (string-match-p "row 01" + (buffer-substring-no-properties + (point-min) (point-max)))) + (should-not (string-match-p "row 30" + (buffer-substring-no-properties + (point-min) (point-max))))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-large-cache-miss-schedules-prefetch () + "Large lazy scroll misses should schedule prefetch without inline render." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "cache-row-%02d" idx) + :content ,(format "cache row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (prefix-count 0) + (materialize-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-wheel-scroll-step 0) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-scroll-lazy-idle-prefetch-delay 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (render-prefix (plist-get state :render-content-prefix)) + (materialize (plist-get state :materialize-content-lines))) + (should (= (length (plist-get state :content-lines)) 3)) + (plist-put + state :render-content-prefix + (lambda (&rest args) + (cl-incf prefix-count) + (apply render-prefix args))) + (plist-put + state :materialize-content-lines + (lambda (&rest args) + (cl-incf materialize-count) + (apply materialize args))) + (puthash root-id state ebox--scroll-global-state)) + (with-current-buffer buffer + (should (ebox--scroll-region-by root-id 30)) + (should (= prefix-count 0)) + (should (= materialize-count 0)) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (should (< (length (plist-get (ebox--scroll-get-state root-id) + :content-lines)) + 40)) + (should (gethash root-id ebox--scroll-idle-prefetch-timers)) + (should-not (string-match-p "cache row 30" + (buffer-substring-no-properties + (point-min) (point-max)))))) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-smooth-scroll-cache-miss-renders-bounded-prefix () + "Smooth scroll cache misses should keep moving with bounded prefix render." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "smooth-cache-row-%02d" idx) + :content ,(format "smooth cache row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (prefix-count 0) + (materialize-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-wheel-scroll-step 0) + (ebox-wheel-smooth-scroll-lines-per-tick 4) + (ebox-wheel-smooth-scroll-interval 60.0) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-scroll-lazy-idle-prefetch-delay 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (render-prefix (plist-get state :render-content-prefix)) + (materialize (plist-get state :materialize-content-lines))) + (should (= (length (plist-get state :content-lines)) 3)) + (plist-put + state :render-content-prefix + (lambda (&rest args) + (cl-incf prefix-count) + (apply render-prefix args))) + (plist-put + state :materialize-content-lines + (lambda (&rest args) + (cl-incf materialize-count) + (apply materialize args))) + (puthash root-id state ebox--scroll-global-state)) + (with-current-buffer buffer + (should (ebox--smooth-scroll-region-by root-id 16))) + (should (= prefix-count 1)) + (should (= materialize-count 0)) + (should (> (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (let ((entry (gethash root-id ebox--smooth-scroll-state-table))) + (should entry) + (should (> (or (plist-get entry :pending) 0) 0)) + (should-not (plist-get entry :waiting-prefetch)))) + (ebox--smooth-scroll-stop root-id) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-smooth-scroll-cache-miss-continues-with-bounded-prefix () + "Smooth scroll cache misses should extend a bounded prefix without stalling." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "smooth-inline-row-%02d" idx) + :content ,(format "smooth inline row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (prefix-count 0) + (materialize-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-wheel-scroll-step 0) + (ebox-wheel-smooth-scroll-lines-per-tick 4) + (ebox-wheel-smooth-scroll-interval 60.0) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-scroll-lazy-idle-prefetch-delay 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (render-prefix (plist-get state :render-content-prefix)) + (materialize (plist-get state :materialize-content-lines))) + (should (= (length (plist-get state :content-lines)) 3)) + (plist-put + state :render-content-prefix + (lambda (&rest args) + (cl-incf prefix-count) + (apply render-prefix args))) + (plist-put + state :materialize-content-lines + (lambda (&rest args) + (cl-incf materialize-count) + (apply materialize args))) + (puthash root-id state ebox--scroll-global-state)) + (with-current-buffer buffer + (should (ebox--smooth-scroll-region-by root-id 16)) + (ebox--smooth-scroll-tick root-id)) + (should (> prefix-count 0)) + (should (= materialize-count 0)) + (should (> (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (let ((entry (gethash root-id ebox--smooth-scroll-state-table))) + (should entry) + (should-not (plist-get entry :waiting-prefetch)))) + (ebox--smooth-scroll-stop root-id) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-smooth-scroll-large-pending-uses-base-tick-at-prefix-boundary () + "Large smooth pending input should not render a large lazy prefix at once." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 200 + collect + `(box :id ,(format "smooth-large-row-%03d" idx) + :content ,(format "smooth large row %03d" idx) + :height 1)))))) + (buffer nil) + root-id + (prefix-count 0) + requested-lines) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-wheel-scroll-step 0) + (ebox-wheel-smooth-scroll-lines-per-tick 4) + (ebox-wheel-smooth-scroll-target-ticks 8) + (ebox-wheel-smooth-scroll-interval 60.0) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-scroll-lazy-idle-prefetch-delay 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (render-prefix (plist-get state :render-content-prefix))) + (should (= (length (plist-get state :content-lines)) 3)) + (plist-put + state :render-content-prefix + (lambda (state region-id required-lines) + (cl-incf prefix-count) + (setq requested-lines required-lines) + (funcall render-prefix state region-id required-lines))) + (puthash root-id state ebox--scroll-global-state)) + (with-current-buffer buffer + (should (ebox--smooth-scroll-region-by root-id 1000))) + (should (= prefix-count 1)) + (should (<= requested-lines 8)) + (should (<= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + ebox-wheel-smooth-scroll-lines-per-tick)) + (let ((entry (gethash root-id ebox--smooth-scroll-state-table))) + (should entry) + (should-not (plist-get entry :waiting-prefetch)))) + (ebox--smooth-scroll-stop root-id) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-smooth-scroll-cache-miss-defers-prefetch-while-active () + "Idle prefetch should not compete with an active smooth scroll timer." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "smooth-defer-row-%02d" idx) + :content ,(format "smooth defer row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (prefix-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-wheel-scroll-step 0) + (ebox-wheel-smooth-scroll-lines-per-tick 4) + (ebox-wheel-smooth-scroll-interval 60.0) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-scroll-lazy-idle-prefetch-delay 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (render-prefix (plist-get state :render-content-prefix))) + (plist-put + state :render-content-prefix + (lambda (&rest args) + (cl-incf prefix-count) + (apply render-prefix args))) + (puthash root-id state ebox--scroll-global-state)) + (with-current-buffer buffer + (should (ebox--smooth-scroll-region-by root-id 16))) + (should (gethash root-id ebox--smooth-scroll-state-table)) + (let ((prefix-after-start prefix-count)) + (should (> prefix-after-start 0)) + (ebox--scroll-idle-prefetch root-id) + (should (= prefix-count prefix-after-start)) + (should (gethash root-id ebox--scroll-idle-prefetch-timers)) + (ebox--smooth-scroll-stop root-id) + (ebox--scroll-idle-prefetch root-id) + (should (> prefix-count prefix-after-start)))) + (ebox--smooth-scroll-stop root-id) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-smooth-scroll-cache-miss-never-enters-prefetch-wait () + "Smooth scroll should not pause at a lazy prefix boundary." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "smooth-resume-row-%02d" idx) + :content ,(format "smooth resume row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (prefix-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-wheel-scroll-step 0) + (ebox-wheel-smooth-scroll-lines-per-tick 4) + (ebox-wheel-smooth-scroll-interval 60.0) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-scroll-lazy-idle-prefetch-delay 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (render-prefix (plist-get state :render-content-prefix))) + (plist-put + state :render-content-prefix + (lambda (&rest args) + (cl-incf prefix-count) + (apply render-prefix args))) + (puthash root-id state ebox--scroll-global-state)) + (with-current-buffer buffer + (should (ebox--smooth-scroll-region-by root-id 16))) + (should (> prefix-count 0)) + (let ((offset-before (plist-get (ebox--scroll-get-state root-id) + :scroll-offset))) + (ebox--smooth-scroll-tick root-id) + (should (> (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + offset-before))) + (let ((entry (gethash root-id ebox--smooth-scroll-state-table))) + (should entry) + (should-not (plist-get entry :waiting-prefetch)) + (should (timerp (plist-get entry :timer))))) + (ebox--smooth-scroll-stop root-id) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-repeated-small-steps-keep-marker-window () + "Repeated lazy scroll steps should keep marker windows valid." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (hidden-render-count 0) + (materialize-count 0) + (original-render-box (symbol-function 'ebox--render-box))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2)) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (box) + (when (equal (ebox-tree-node-id box) "row-30") + (cl-incf hidden-render-count)) + (funcall original-render-box box)))) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (materialize (plist-get state :materialize-content-lines))) + (plist-put + state :materialize-content-lines + (lambda (&rest args) + (cl-incf materialize-count) + (apply materialize args))) + (puthash root-id state ebox--scroll-global-state)) + (with-current-buffer buffer + (cl-loop repeat 8 + do (should (ebox--scroll-region-by root-id 1))) + (should (= hidden-render-count 0)) + (should (= materialize-count 0)) + (let ((state (ebox--scroll-get-state root-id))) + (should (= (plist-get state :scroll-offset) 8)) + (should (< (length (plist-get state :content-lines)) 40)) + (should (plist-get state :materialize-content-lines))) + (should (string-match-p "row 08" + (buffer-substring-no-properties + (point-min) (point-max)))) + (should-not (string-match-p "row 30" + (buffer-substring-no-properties + (point-min) (point-max))))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-repeated-small-steps-prefetch-prefix () + "Repeated lazy scroll steps should reuse a prefetched lazy prefix." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (prefix-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (render-prefix (plist-get state :render-content-prefix))) + (plist-put + state :render-content-prefix + (lambda (&rest args) + (cl-incf prefix-count) + (apply render-prefix args))) + (puthash root-id state ebox--scroll-global-state)) + (with-current-buffer buffer + (cl-loop repeat 8 + do (should (ebox--scroll-region-by root-id 1))) + (should (= prefix-count 0)) + (let ((state (ebox--scroll-get-state root-id))) + (should (= (plist-get state :scroll-offset) 8)) + (should (plist-get state :render-content-prefix)) + (should (<= (+ (plist-get state :scroll-offset) + (plist-get state :content-height) + 1) + (length (plist-get state :content-lines))))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-idle-prefetch-extends-prefix-cache () + "Idle prefetch should fill lazy scroll lines before wheel ticks need them." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 80 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (prefix-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-idle-prefetch-lines 16)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (before-lines (length (plist-get state :content-lines))) + (render-prefix (plist-get state :render-content-prefix))) + (plist-put + state :render-content-prefix + (lambda (&rest args) + (cl-incf prefix-count) + (apply render-prefix args))) + (puthash root-id state ebox--scroll-global-state) + (ebox--scroll-idle-prefetch root-id) + (let ((prefetched (ebox--scroll-get-state root-id))) + (should (> (length (plist-get prefetched :content-lines)) + before-lines)) + (should (< (length (plist-get prefetched :content-lines)) + 80)) + (should (= prefix-count 1)))) + (with-current-buffer buffer + (cl-loop repeat 8 + do (should (ebox--scroll-region-by root-id 1)))) + (should (= prefix-count 1))) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-prefix-reuses-display-measurement-cache () + "Lazy prefix slices should retain the initial render's display cache." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 80 + collect + `(box :content ,(format "repeated row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + caches) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (original + (symbol-function 'ebox--render-scroll-window-source))) + (cl-letf (((symbol-function 'ebox--render-scroll-window-source) + (lambda (&rest arguments) + (push ebox--render-string-pixel-width-cache caches) + (apply original arguments)))) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prefix-cache*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (before (length (plist-get state :content-lines)))) + (ebox--scroll-state-ensure-prefix-lines + root-id state (1+ before) t))) + (should (>= (length caches) 2)) + (should (car caches)) + (should (cl-every (lambda (cache) (eq cache (car caches))) + caches))) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-idle-prefetch-covers-visible-window () + "Idle prefetch should warm beyond the current visible scroll window." + (ebox-test--reset-runtime-state) + (let ((region-id "scroll") + requested-lines) + (puthash + region-id + (list :scroll-offset 0 + :content-height 10 + :content-lines (make-list 3 "") + :render-content-prefix + (lambda (_state _region-id target-lines) + (setq requested-lines target-lines) + (list :content-lines (make-list target-lines "") + :content-height 10 + :complete nil))) + ebox--scroll-global-state) + (let ((ebox-scroll-lazy-idle-prefetch-lines 5) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (ebox--scroll-idle-prefetch region-id)) + (should requested-lines) + (should (>= requested-lines 15)))) + +(ert-deftest ebox-lazy-scroll-idle-prefetch-is-sliced () + "Idle prefetch should extend lazy prefixes in bounded foreground slices." + (ebox-test--reset-runtime-state) + (let ((region-id "scroll") + requested-lines) + (puthash + region-id + (list :scroll-offset 0 + :content-height 10 + :content-lines (make-list 3 "") + :render-content-prefix + (lambda (_state _region-id target-lines) + (setq requested-lines target-lines) + (list :content-lines (make-list target-lines "") + :content-height 10 + :complete nil))) + ebox--scroll-global-state) + (let ((ebox-scroll-lazy-idle-prefetch-lines 64) + (ebox-scroll-lazy-idle-prefetch-slice-lines 4) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox--scroll-allow-noninteractive-prefetch t)) + (ebox--scroll-idle-prefetch region-id)) + ;; Three cached lines plus the configured four-line slice. Completion is + ;; probed on a later turn instead of exceeding this callback's budget. + (should (= requested-lines 7)) + (should (gethash region-id ebox--scroll-idle-prefetch-timers)) + (ebox--scroll-cancel-idle-prefetch region-id))) + +(ert-deftest ebox-lazy-scroll-idle-prefetch-bounds-atomic-item-work () + "Item-heavy buffers should request only one new line per idle render turn." + (ebox-test--reset-runtime-state) + (let ((region-id "scroll") + buffer + requested-lines) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-item-prefetch*") + (ebox-build + '(flex :width (240) + (item (box :content "atomic" :width (120))))))) + (puthash + region-id + (list :buffer buffer + :scroll-offset 0 + :content-height 10 + :content-lines (make-list 3 "") + :render-content-prefix + (lambda (_state _region-id target-lines) + (setq requested-lines target-lines) + (list :content-lines (make-list target-lines "") + :content-height 10 + :complete nil))) + ebox--scroll-global-state) + (let ((ebox-scroll-lazy-idle-prefetch-lines 64) + (ebox-scroll-lazy-idle-prefetch-slice-lines 16) + (ebox-scroll-lazy-prefix-lookahead-lines 13) + (ebox--scroll-allow-noninteractive-prefetch t)) + (ebox--scroll-idle-prefetch region-id)) + ;; Three cached lines + one atomic requested line. The completion + ;; probe must not silently double a latency-sensitive slice. + (should (= requested-lines 4))) + (ebox--scroll-cancel-idle-prefetch region-id) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-idle-prefetch-bounds-large-runtime-work () + "Large runtimes should request only one new line per idle render turn." + (ebox-test--reset-runtime-state) + (let ((region-id "scroll") + (buffer (generate-new-buffer " *ebox-large-prefetch*")) + (node-table (make-hash-table :test 'equal)) + (selector-type-table (make-hash-table :test 'eq)) + requested-lines) + (unwind-protect + (progn + (dotimes (index 512) + (puthash index t node-table)) + (puthash buffer + (list :node-table node-table + :selector-type-table selector-type-table) + ebox--buffer-render-state-table) + (puthash + region-id + (list :buffer buffer + :scroll-offset 0 + :content-height 10 + :content-lines (make-list 3 "") + :render-content-prefix + (lambda (_state _region-id target-lines) + (setq requested-lines target-lines) + (list :content-lines (make-list target-lines "") + :content-height 10 + :complete nil))) + ebox--scroll-global-state) + (let ((ebox-scroll-lazy-idle-prefetch-lines 64) + (ebox-scroll-lazy-idle-prefetch-slice-lines 16) + (ebox-scroll-lazy-prefix-lookahead-lines 13) + (ebox--scroll-allow-noninteractive-prefetch t)) + (ebox--scroll-idle-prefetch region-id)) + ;; Three cached lines + one atomic requested line. The completion + ;; probe must not silently double a latency-sensitive slice. + (should (= requested-lines 4))) + (ebox--scroll-cancel-idle-prefetch region-id) + (remhash buffer ebox--buffer-render-state-table) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-idle-prefetch-defers-visible-render-gc () + "Visible idle prefix slices should not combine atomic rendering with GC." + (ebox-test--reset-runtime-state) + (let* ((region-id "visible-prefix-gc") + (buffer (generate-new-buffer " *ebox-visible-prefix-gc*")) + (state + (list :buffer buffer + :scroll-offset 0 + :content-height 1 + :content-lines '("row 0") + :render-content-prefix + (lambda (input-state _region-id target-lines &optional _exact) + (setq input-state + (plist-put input-state :content-lines + (make-list target-lines "row"))) + (plist-put input-state :content-lines-complete-p t)))) + (gc-enters 0) + (gc-restores 0)) + (unwind-protect + (let ((ebox-scroll-lazy-idle-prefetch-lines 1) + (ebox-scroll-lazy-idle-prefetch-slice-lines 1) + (ebox--scroll-allow-noninteractive-prefetch t)) + (puthash region-id state ebox--scroll-global-state) + (cl-letf (((symbol-function 'get-buffer-window-list) + (lambda (&rest _) (list (selected-window)))) + ((symbol-function 'display-graphic-p) + (lambda (&rest _) t)) + ((symbol-function 'ebox--deferred-render-gc-enter) + (lambda () (cl-incf gc-enters))) + ((symbol-function + 'ebox--deferred-render-gc-schedule-restore) + (lambda () (cl-incf gc-restores))) + ((symbol-function 'ebox--schedule-buffer-runtime-prewarm) + (lambda (&rest _) nil))) + (ebox--scroll-idle-prefetch region-id)) + (should (= gc-enters 1)) + (should (= gc-restores 1))) + (ebox--scroll-cancel-idle-prefetch region-id) + (remhash region-id ebox--scroll-global-state) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-prefetch-yields-to-input-between-slices () + "Background prefix work should use idle time and rearm beyond current idle." + (ebox-test--reset-runtime-state) + (let ((region-id "scroll") + scheduled-delay + scheduled-function) + (puthash + region-id + (list :content-height 2 + :content-lines '("row 0") + :render-content-prefix + (lambda (_state _region-id target-lines) + (list :content-lines (make-list target-lines "row") + :content-height 2 + :complete nil))) + ebox--scroll-global-state) + (let ((ebox-scroll-lazy-idle-prefetch-lines 8) + (ebox-scroll-lazy-idle-prefetch-slice-lines 1) + (ebox-scroll-lazy-idle-prefetch-delay 0.15) + (ebox--scroll-allow-noninteractive-prefetch t)) + (cl-letf (((symbol-function 'current-idle-time) + (lambda () (seconds-to-time 0.4))) + ((symbol-function 'run-at-time) + (lambda (&rest _) + (ert-fail "Background scroll work must yield to input"))) + ((symbol-function 'run-with-idle-timer) + (lambda (delay _repeat function &rest _arguments) + (setq scheduled-delay delay + scheduled-function function) + 'scheduled-scroll-timer))) + (ebox--scroll-idle-prefetch region-id))) + (should (< (abs (- scheduled-delay 0.55)) 0.000001)) + (should (eq scheduled-function #'ebox--scroll-idle-prefetch)) + (should (eq (gethash region-id ebox--scroll-idle-prefetch-timers) + 'scheduled-scroll-timer)) + (remhash region-id ebox--scroll-idle-prefetch-timers))) + +(ert-deftest ebox-lazy-scroll-idle-prefetch-rearms-after-scroll () + "Successful lazy scroll steps should schedule the next generic prefetch." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 80 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-idle-prefetch-delay 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (ebox--scroll-cancel-idle-prefetch root-id) + (with-current-buffer buffer + (should (ebox--scroll-region-by root-id 1))) + (should (gethash root-id ebox--scroll-idle-prefetch-timers))) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-idle-prefetch-pauses-during-smooth-scroll () + "Smooth scroll should prefetch only after the active animation stops." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 80 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-idle-prefetch-delay 999) + (ebox-wheel-smooth-scroll-lines-per-tick 4) + (ebox-wheel-smooth-scroll-target-ticks 4) + (ebox-wheel-smooth-scroll-interval 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (should (gethash root-id ebox--scroll-idle-prefetch-timers)) + (with-current-buffer buffer + (should (ebox--smooth-scroll-region-by root-id 16))) + (should (gethash root-id ebox--smooth-scroll-state-table)) + (should-not (gethash root-id ebox--scroll-idle-prefetch-timers)) + (ebox--smooth-scroll-stop root-id) + (should (gethash root-id ebox--scroll-idle-prefetch-timers))) + (ebox--scroll-cancel-idle-prefetch root-id) + (ebox--smooth-scroll-stop root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-idle-prefetch-pauses-for-resize-animation () + "A resize animation should own idle time until its final frame settles." + (ebox-test--reset-runtime-state) + (let* ((buffer (generate-new-buffer " *ebox-resize-prefetch*")) + (region-id "scroll") + scheduled) + (unwind-protect + (progn + (puthash + region-id + (list :buffer buffer + :content-height 2 + :content-lines '("row 0") + :render-content-prefix #'ignore + :content-lines-complete-p nil) + ebox--scroll-global-state) + (cl-letf (((symbol-function 'run-with-idle-timer) + (lambda (&rest _) + (setq scheduled 'scroll-timer)))) + (ebox--scroll-inhibit-idle-prefetch-for-buffer buffer) + (ebox--scroll-schedule-idle-prefetch region-id 0) + (should-not scheduled) + (ebox--scroll-resume-idle-prefetch-for-buffer buffer) + (should (eq scheduled 'scroll-timer)))) + (remhash region-id ebox--scroll-global-state) + (remhash buffer ebox--scroll-idle-prefetch-inhibited-buffers) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-prefix-shares-display-cache-scope () + "Each lazy prefix generation should share one display cache scope." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 20 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (validate-count 0) + (original-validate (symbol-function 'ebox--validate-display-cache))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 1) + (ebox-wheel-scroll-step 0) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let ((state (ebox--scroll-get-state root-id))) + (should (= (length (plist-get state :content-lines)) 2)) + (should (plist-get state :render-content-prefix))) + (cl-letf (((symbol-function 'ebox--validate-display-cache) + (lambda (&rest args) + (cl-incf validate-count) + (apply original-validate args)))) + (with-current-buffer buffer + (should (ebox--scroll-region-by root-id 2))) + (should (= validate-count 1)) + (should (gethash root-id ebox--scroll-idle-prefetch-timers)) + (ebox--scroll-idle-prefetch root-id)) + (should (= validate-count 2))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-prefix-reuses-exact-source-lines () + "Exact-width lazy scroll source lines should not be reformatted." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 20 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (format-count 0) + (original-format (symbol-function 'ebox--format-content-string))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 1) + (ebox-wheel-scroll-step 0) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (cl-letf (((symbol-function 'ebox--format-content-string) + (lambda (&rest args) + (when (equal (and (listp (car args)) + (ebox-get (car args) :id)) + "root") + (cl-incf format-count)) + (apply original-format args)))) + (with-current-buffer buffer + (should (ebox--scroll-region-by root-id 2)))) + (should (= format-count 0))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(defun ebox-test--complete-lazy-scroll-batch-layout () + "Return a complete five-row lazy scroll cache fixture." + (ebox-build + '(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + (box :content "row 00" :height 1) + (box :id "target" :content "target row" :height 1 + :color "#111111") + (box :id "second-target" :content "second target row" :height 1 + :color "#111111") + (box :content "row 03" :height 1) + (box :content "row 04" :height 1))))) + +(defun ebox-test--text-has-foreground-p (string text color) + "Return non-nil when TEXT in STRING has foreground COLOR." + (let ((pos (string-match (regexp-quote text) + (substring-no-properties string)))) + (and pos + (ebox-test--face-has-key-value-p + (get-text-property pos 'face string) + :foreground color)))) + +(defun ebox-test--lines-have-text-foreground-p (lines text color) + "Return non-nil when one of LINES paints TEXT with foreground COLOR." + (cl-some (lambda (line) + (ebox-test--text-has-foreground-p line text color)) + lines)) + +(defun ebox-test--lines-have-text-p (lines text) + "Return non-nil when one of LINES contains TEXT." + (and (cl-some (lambda (line) + (string-match-p (regexp-quote text) + (substring-no-properties line))) + lines) + t)) + +(ert-deftest ebox-lazy-scroll-prefix-extension-keeps-runtime-updates () + "Extending a lazy prefix should not replay producer caches from before an update." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 12 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-wheel-scroll-step 0) + (ebox-wheel-smooth-scroll nil)) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (root-id (ebox-test--selector-region-id buffer "#root")) + (target-id (ebox-test--selector-region-id buffer "#row-01")) + (state (ebox-scroll-state root-id))) + (should-not (plist-get state :content-lines-complete-p)) + (ebox-region-update target-id :content "updated row 01") + (setq state (ebox-scroll-state root-id)) + (should (ebox-test--lines-have-text-p + (plist-get state :content-lines) "updated row 01")) + (setq state + (ebox--scroll-state-ensure-prefix-lines root-id state 8)) + (should (ebox-test--lines-have-text-p + (plist-get state :content-lines) "updated row 01")))))) + +(ert-deftest ebox-lazy-scroll-prefix-does-not-publish-unflushed-batch () + "Extending a lazy prefix should preserve committed content until batch flush." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 12 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-wheel-scroll-step 0) + (ebox-wheel-smooth-scroll nil)) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (root-id (ebox-test--selector-region-id buffer "#root")) + (target-id (ebox-test--selector-region-id buffer "#row-01")) + (initial-revision + (plist-get (ebox--buffer-render-state buffer) + :runtime-revision))) + (ebox-test--set-buffer-update-report + buffer '(:strategy span-patch)) + (ebox-incremental-begin-batch buffer) + (ebox-region-update target-id :content "unflushed row 01") + (should (= (plist-get (ebox--buffer-render-state buffer) + :runtime-revision) + initial-revision)) + (let ((state + (ebox--scroll-state-ensure-prefix-lines + root-id (ebox-scroll-state root-id) 8))) + (should (ebox-test--lines-have-text-p + (plist-get state :content-lines) "row 01")) + (should-not (ebox-test--lines-have-text-p + (plist-get state :content-lines) + "unflushed row 01"))) + (should (= 2 (ebox--scroll-region-by root-id 2))) + (should (= -2 (ebox--scroll-region-by root-id -2))) + (should (string-match-p "row 01" + (substring-no-properties (buffer-string)))) + (should-not (string-match-p + "unflushed row 01" + (substring-no-properties (buffer-string)))) + (ebox-incremental-flush buffer) + (should (= (plist-get (ebox--buffer-render-state buffer) + :runtime-revision) + (1+ initial-revision))) + (should (ebox-test--lines-have-text-p + (plist-get (ebox-scroll-state root-id) :content-lines) + "unflushed row 01")) + (should (string-match-p + "unflushed row 01" + (substring-no-properties (buffer-string)))))))) + +(ert-deftest ebox-explicit-batch-synchronizes-complete-scroll-paint-cache () + "A paint-only batch should not replay stale complete scroll cache faces." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--complete-lazy-scroll-batch-layout)) + (ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-wheel-scroll-step 16) + (ebox-scroll-lazy-idle-prefetch-lines 0)) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (root-id (ebox-test--selector-region-id buffer "#root")) + (target-id (ebox-test--selector-region-id buffer "#target"))) + (should (plist-get (ebox-scroll-state root-id) + :content-lines-complete-p)) + (ebox-incremental-begin-batch buffer) + (ebox-region-update target-id :color "#DC2626") + (ebox-incremental-flush buffer) + (let* ((runtime (gethash target-id ebox--region-box-table)) + (visible-before + (ebox-test--text-has-foreground-p + (buffer-string) "target row" "#DC2626")) + (state (ebox-scroll-state root-id)) + (content-cache + (ebox-test--lines-have-text-foreground-p + (plist-get state :content-lines) + "target row" "#DC2626")) + (rendered-cache + (ebox-test--lines-have-text-foreground-p + (plist-get state :rendered-content-lines) + "target row" "#DC2626"))) + (should (= 2 (ebox--scroll-region-by root-id 2))) + (should (= -2 (ebox--scroll-region-by root-id -2))) + (should + (equal + (list (ebox-get runtime :color) + visible-before + content-cache + rendered-cache + (ebox-test--text-has-foreground-p + (buffer-string) "target row" "#DC2626")) + '("#DC2626" t t t t)))))))) + +(ert-deftest ebox-explicit-batch-synchronizes-mixed-scroll-cache-sources () + "A mixed batch should synchronize each final runtime node exactly once." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--complete-lazy-scroll-batch-layout)) + (ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-wheel-scroll-step 16) + (ebox-scroll-lazy-idle-prefetch-lines 0)) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (root-id (ebox-test--selector-region-id buffer "#root")) + (target-id (ebox-test--selector-region-id buffer "#target")) + (sync-count 0) + (original-sync + (symbol-function 'ebox--sync-scroll-state-lines-for-node))) + (should (plist-get (ebox-scroll-state root-id) + :content-lines-complete-p)) + (cl-letf (((symbol-function 'ebox--sync-scroll-state-lines-for-node) + (lambda (&rest args) + (cl-incf sync-count) + (apply original-sync args)))) + (ebox-incremental-begin-batch buffer) + (ebox-region-update target-id :content "batched target row") + (ebox-region-update target-id :color "#2563EB") + (ebox-incremental-flush buffer)) + (let* ((runtime (gethash target-id ebox--region-box-table)) + (visible-before (buffer-string)) + (state (ebox-scroll-state root-id)) + (content-lines (plist-get state :content-lines)) + (rendered-lines (plist-get state :rendered-content-lines))) + (should (= 2 (ebox--scroll-region-by root-id 2))) + (should (= -2 (ebox--scroll-region-by root-id -2))) + (let ((visible-after (buffer-string))) + (should + (equal + (list (ebox-get runtime :content) + (ebox-get runtime :color) + (and (string-match-p + "batched target row" + (substring-no-properties visible-before)) + t) + (ebox-test--text-has-foreground-p + visible-before "batched target row" "#2563EB") + (ebox-test--lines-have-text-p + content-lines "batched target row") + (ebox-test--lines-have-text-foreground-p + content-lines "batched target row" "#2563EB") + (ebox-test--lines-have-text-p + rendered-lines "batched target row") + (ebox-test--lines-have-text-foreground-p + rendered-lines "batched target row" "#2563EB") + (and (string-match-p + "batched target row" + (substring-no-properties visible-after)) + t) + (ebox-test--text-has-foreground-p + visible-after "batched target row" "#2563EB") + sync-count) + '("batched target row" "#2563EB" + t t t t t t t t 1))))))))) + +(ert-deftest ebox-explicit-batch-synchronizes-distinct-scroll-cache-nodes () + "A batch flush should synchronize each final node from any current buffer." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--complete-lazy-scroll-batch-layout)) + (ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-wheel-scroll-step 16) + (ebox-scroll-lazy-idle-prefetch-lines 0)) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (root-id (ebox-test--selector-region-id buffer "#root")) + (target-id (ebox-test--selector-region-id buffer "#target")) + (second-id + (ebox-test--selector-region-id buffer "#second-target")) + calls + (original-sync + (symbol-function 'ebox--sync-scroll-state-lines-for-node))) + (should (plist-get (ebox-scroll-state root-id) + :content-lines-complete-p)) + (cl-letf (((symbol-function 'ebox--sync-scroll-state-lines-for-node) + (lambda (sync-buffer region-id node changed-keys report) + (push (list sync-buffer region-id + (ebox-tree-node-id node) + changed-keys) + calls) + (funcall original-sync sync-buffer region-id node + changed-keys report)))) + (ebox-incremental-begin-batch buffer) + (with-temp-buffer + (ebox-region-update target-id :color "#DC2626") + (ebox-region-update second-id :color "#2563EB") + (ebox-incremental-flush buffer))) + (should (= (length calls) 2)) + (should (cl-every (lambda (call) (eq (car call) buffer)) calls)) + (should + (equal (sort (mapcar (lambda (call) (nth 2 call)) calls) + #'string-lessp) + '("second-target" "target"))) + (let* ((state (ebox-scroll-state root-id)) + (content-lines (plist-get state :content-lines)) + (rendered-lines (plist-get state :rendered-content-lines))) + (should (ebox-test--lines-have-text-foreground-p + content-lines "target row" "#DC2626")) + (should (ebox-test--lines-have-text-foreground-p + rendered-lines "target row" "#DC2626")) + (should (ebox-test--lines-have-text-foreground-p + content-lines "second target row" "#2563EB")) + (should (ebox-test--lines-have-text-foreground-p + rendered-lines "second target row" "#2563EB"))))))) + +(ert-deftest ebox-explicit-batch-scroll-sync-hook-skips-unsuccessful-flushes () + "Empty, no-op, and failed batch flushes should not publish sync work." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--complete-lazy-scroll-batch-layout))) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (target-id (ebox-test--selector-region-id buffer "#target")) + (hook-count 0) + (ebox-incremental--after-successful-batch-flush-hook + (list (lambda (&rest _args) (cl-incf hook-count))))) + (ebox-incremental-begin-batch buffer) + (should (eq (plist-get (ebox-incremental-flush buffer) :strategy) + 'no-op)) + (should (= hook-count 0)) + (ebox-incremental-begin-batch buffer) + (should (ebox-incremental-record-region-change + buffer target-id 'paint '(:color))) + (cl-letf (((symbol-function 'ebox--execute-constraint-change) + (lambda (&rest _args) + (ebox--update-report target-id 'no-op)))) + (should (eq (plist-get (ebox-incremental-flush buffer) :strategy) + 'no-op))) + (should (= hook-count 0)) + (ebox-incremental-begin-batch buffer) + (should (ebox-incremental-record-region-change + buffer target-id 'paint '(:color))) + (cl-letf (((symbol-function 'ebox--execute-constraint-change) + (lambda (&rest _args) (error "expected flush failure")))) + (should-error (ebox-incremental-flush buffer) + :type 'error)) + (should (= hook-count 0)))))) + +(ert-deftest ebox-explicit-batch-flush-retries-after-quit () + "A quit during a batch flush must keep the recorded changes retryable." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Batch retry" :color "black")) + (id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (let ((buffer (current-buffer))) + (ebox-incremental-begin-batch buffer) + (ebox-region-update id :color "red") + (should (= (ebox-incremental-pending-count buffer) 1)) + (cl-letf (((symbol-function 'ebox--execute-constraint-change) + (lambda (&rest _) (signal 'quit nil)))) + (condition-case nil + (ebox-incremental-flush buffer) + (quit nil))) + ;; The interrupted flush published nothing, so the batch must + ;; still be active with the recorded change pending. + (should (ebox-incremental-batching-p buffer)) + (should (= (ebox-incremental-pending-count buffer) 1)) + ;; The retry must execute the preserved change instead of no-op. + (let ((report (ebox-incremental-flush buffer))) + (should report) + (should-not (eq (plist-get report :strategy) 'no-op))) + (should-not (ebox-incremental-batching-p buffer)) + (should (= (ebox-incremental-pending-count buffer) 0))) + (goto-char (point-min)) + (search-forward "Batch retry") + (let ((face (get-text-property (- (point) (length "Batch retry")) + 'face))) + (should (ebox-test--face-has-key-value-p + face :foreground "red"))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-lazy-scroll-paint-sync-does-not-rerender-node () + "Paint updates inside scroll caches should patch cached faces without render." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 32 + collect + `(box :id ,(cond + ((= idx 1) "target") + ((= idx 31) "hidden-target") + (t (format "row-%02d" idx))) + :content ,(if (= idx 1) + "target row" + (format "row %02d" idx)) + :height 1 + :color "#111111")))))) + (buffer nil) + target-id + root-id + (render-count 0) + (index-build-count 0) + (fallback-count 0) + (face-in-lines-p + (lambda (lines color) + (cl-some + (lambda (line) + (let* ((plain (substring-no-properties line)) + (pos (string-match "target row" plain))) + (and pos + (ebox-test--face-has-key-value-p + (get-text-property pos 'face line) + :foreground color)))) + lines))) + (original-render (symbol-function 'ebox-render)) + (original-index-build + (symbol-function 'ebox--scroll-build-region-line-span-index)) + (original-fallback + (symbol-function 'ebox--sync-scroll-state-lines-for-node-fallback))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 3) + (ebox-scroll-lazy-idle-prefetch-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq target-id (ebox-test--selector-region-id buffer "#target")) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let ((state (ebox-scroll-state root-id))) + (should + (plist-get state :rendered-region-line-span-index-deferred)) + (should-not (plist-get state :rendered-region-line-span-index)) + (cl-letf (((symbol-function 'ebox-render) + (lambda (&rest args) + (cl-incf render-count) + (apply original-render args))) + ((symbol-function + 'ebox--scroll-build-region-line-span-index) + (lambda (&rest args) + (cl-incf index-build-count) + (apply original-index-build args))) + ((symbol-function + 'ebox--sync-scroll-state-lines-for-node-fallback) + (lambda (&rest args) + (cl-incf fallback-count) + (apply original-fallback args)))) + (ebox-region-update target-id :color "#DC2626") + (setq state (ebox-scroll-state root-id)) + (should + (plist-get state :rendered-region-line-span-index-deferred)) + (should-not (plist-get state :rendered-region-line-span-index)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'paint-patch)) + (should (plist-get report :scroll-state-paint-sync)) + (should (plist-get report :scroll-state-index-preserved))) + (let ((lines (plist-get state :content-lines))) + (should-not + (cl-some + (lambda (line) + (string-match-p "row 31" + (substring-no-properties line))) + lines)) + (dolist (key '(:content-lines :rendered-content-lines)) + (should (funcall face-in-lines-p + (plist-get state key) "#DC2626")))) + (ebox-region-update + target-id :border-left-color "#F59E0B") + (should (= fallback-count 0)) + (ebox-selector-update-buffer + buffer "#hidden-target" :color "#2563EB") + (setq state (ebox-scroll-state root-id)) + (should (plist-get state :lazy-scroll-prefix-dirty)) + (ebox-region-update target-id :color "#16A34A") + (setq state (ebox-scroll-state root-id)) + (should (plist-get state :lazy-scroll-prefix-dirty)) + (should + (plist-get state :rendered-region-line-span-index-deferred)) + (should-not (plist-get state :rendered-region-line-span-index)) + (dolist (key '(:content-lines :rendered-content-lines)) + (should (funcall face-in-lines-p + (plist-get state key) "#16A34A"))) + (should (= fallback-count 0)) + (should (= render-count 0)) + (should (= index-build-count 0))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-prefix-appends-rendered-content-lines () + "Lazy prefix extension should render only newly appended visible lines." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 80 + collect + `(box :id ,(format "rendered-tail-row-%02d" idx) + :content ,(format "rendered tail row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + rendered-line-counts + (original-rendered-lines + (symbol-function 'ebox--scroll-rendered-content-lines))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-wheel-scroll-step 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (cl-letf (((symbol-function 'ebox--scroll-rendered-content-lines) + (lambda (&rest args) + (push (list (length (cadr args)) + (nth 3 args)) + rendered-line-counts) + (apply original-rendered-lines args)))) + (dotimes (_ 2) + (let* ((state (ebox--scroll-get-state root-id)) + (current-lines (length (plist-get state + :content-lines)))) + (ebox--scroll-state-ensure-prefix-lines + root-id state (+ current-lines 4))))) + (should + (cl-some (lambda (entry) + (and (<= (car entry) 5) + (integerp (cadr entry)) + (> (cadr entry) 0))) + rendered-line-counts)) + (dolist (entry rendered-line-counts) + (should (<= (car entry) 8)))) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-prefix-avoids-full-prefix-joins () + "Lazy prefix extension should not join the full cached prefix string." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 80 + collect + `(box :id ,(format "join-row-%02d" idx) + :content ,(format "join row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + join-sizes + (original-join (symbol-function 'ebox-lines-join))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-wheel-scroll-step 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (cl-letf (((symbol-function 'ebox-lines-join) + (lambda (&rest args) + (push (length (car args)) join-sizes) + (apply original-join args)))) + (let* ((state (ebox--scroll-get-state root-id)) + (current-lines (length (plist-get state + :content-lines)))) + (ebox--scroll-state-ensure-prefix-lines + root-id state (+ current-lines 4)))) + (should-not (cl-some (lambda (size) (> size 5)) join-sizes))) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-prefix-reuses-signature-cache () + "Lazy prefix extension should not recompute node signatures each slice." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll))) + (source-node + (ebox-build + `(column :width (viewport) + (flex :id "signature-flex" :width (viewport) + :flex-flow (row wrap) + :row-gap 1 :column-gap (8) + :padding (0 (12)) :border "#D97757" + :border-top-p nil + ,@(cl-loop for idx below 24 + collect + `(box :content ,(format "signature %02d" idx) + :width (100) :height 2)))))) + (source (list :stack source-node)) + (renderer (ebox--scroll-window-prefix-renderer + layout source 240 2 nil nil)) + signature-caches + (original-node-signature + (symbol-function 'ebox--render-cache-node-signature))) + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache nil)) + (cl-letf (((symbol-function 'ebox--render-cache-node-signature) + (lambda (node) + (when (and (listp node) + ebox--render-cache-signature-cache) + (push ebox--render-cache-signature-cache + signature-caches)) + (funcall original-node-signature node)))) + (funcall renderer (list :rendered-content-lines nil) + "signature-region" 4) + (funcall renderer (list :rendered-content-lines nil) + "signature-region" 8)) + (should (> (length signature-caches) 1)) + (let ((first-cache (car signature-caches))) + (dolist (cache (cdr signature-caches)) + (should (eq cache first-cache))))))) + +(ert-deftest ebox-lazy-scroll-prefix-appends-generic-wrapper-lines () + "Lazy prefix extension should append wrapper chrome without rerendering it." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) :box-sizing border-box + :padding (1 (8)) :border "#555555" + :bgcolor "#FAFAFA" :color "#111111" + ,@(cl-loop for idx below 20 + collect + `(box :id ,(format "wrapped-row-%02d" idx) + :content ,(format "wrapped row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (wrapper-render-count 0) + (original-render (symbol-function 'ebox-render))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 1) + (ebox-wheel-scroll-step 0) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + (ebox-scroll-lazy-idle-prefetch-lines 3) + (ebox-scroll-lazy-idle-prefetch-delay 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (with-current-buffer buffer + (let ((state (ebox--scroll-get-state root-id))) + (should (= (length (plist-get state :content-lines)) 2)) + (should (plist-get state :render-content-prefix))) + (cl-letf (((symbol-function 'ebox-render) + (lambda (node) + (when (and (listp node) + (eq (plist-get node :ebox-type) 'box) + (plist-get node + :ebox-content-width-exact-p) + (null (plist-get node + :ebox-content-node))) + (cl-incf wrapper-render-count)) + (funcall original-render node)))) + (ebox--scroll-idle-prefetch root-id)) + (should (= wrapper-render-count 0)) + (should (> (length (plist-get (ebox--scroll-get-state root-id) + :content-lines)) + 2)))) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-first-smooth-wheel-uses-prefetched-prefix () + "The first smooth wheel input should only replace visible lines." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (prefix-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-wheel-smooth-scroll-lines-per-tick 4) + (ebox-wheel-smooth-scroll-interval 60.0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (render-prefix (plist-get state :render-content-prefix))) + (should (<= (+ (plist-get state :content-height) + ebox-wheel-scroll-step) + (length (plist-get state :content-lines)))) + (plist-put + state :render-content-prefix + (lambda (&rest args) + (cl-incf prefix-count) + (apply render-prefix args))) + (puthash root-id state ebox--scroll-global-state)) + (with-current-buffer buffer + (should (ebox--smooth-scroll-region-by root-id 16)) + (while (gethash root-id ebox--smooth-scroll-state-table) + (ebox--smooth-scroll-tick root-id)) + (should (string-match-p "row 16" + (buffer-substring-no-properties + (point-min) (point-max)))))) + (should (= prefix-count 0)) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 16))) + (ebox--smooth-scroll-stop root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer)))) + +(ert-deftest ebox-lazy-scroll-incremental-smooth-wheel-uses-prefetched-prefix () + "Post-resize lazy scroll should not extend prefixes inside smooth ticks." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "resize-row-%02d" idx) + :content ,(format "resize row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (prefix-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 6) + (ebox--defer-scroll-content-index t) + (ebox-wheel-smooth-scroll t) + (ebox-wheel-scroll-step 16) + (ebox-wheel-smooth-scroll-lines-per-tick 4) + (ebox-wheel-smooth-scroll-interval 60.0) + (ebox-scroll-lazy-idle-prefetch-lines 0) + (ebox--scroll-allow-noninteractive-prefetch nil) + (ebox-scroll-lazy-prefix-lookahead-lines 8)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (render-prefix (plist-get state :render-content-prefix))) + (plist-put + state :render-content-prefix + (lambda (&rest args) + (cl-incf prefix-count) + (apply render-prefix args))) + (puthash root-id state ebox--scroll-global-state)) + (with-current-buffer buffer + (should (ebox--smooth-scroll-region-by root-id 16)) + (while (gethash root-id ebox--smooth-scroll-state-table) + (ebox--smooth-scroll-tick root-id)) + (should (string-match-p "resize row 16" + (buffer-substring-no-properties + (point-min) (point-max)))))) + (should (= prefix-count 0)) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 16))) + (ebox--smooth-scroll-stop root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer)))) + +(ert-deftest ebox-viewport-rerender-schedules-urgent-scroll-prefetch () + "Viewport rerender should warm lazy scroll prefixes before fast scrolling." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 80 + collect + `(box :id ,(format "urgent-row-%02d" idx) + :content ,(format "urgent row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + delays + (original-schedule + (symbol-function 'ebox--scroll-schedule-idle-prefetch))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4) + (ebox-scroll-lazy-idle-prefetch-delay 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (ebox--scroll-cancel-idle-prefetch root-id) + (cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch) + (lambda (region-id &optional delay) + (push delay delays) + (funcall original-schedule region-id delay)))) + (ebox-rerender-buffer-with-context buffer 300 4)) + (should (member 0 delays))) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-smooth-scroll-defers-gc-during-animation () + "Smooth scrolling should keep render GC settings until the animation stops." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (old-threshold gc-cons-threshold) + (old-percentage gc-cons-percentage) + (target-threshold 2000)) + (unwind-protect + (let ((gc-cons-threshold 1000) + (ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-render-gc-cons-threshold target-threshold) + (ebox-deferred-render-gc-cons-threshold target-threshold) + (ebox-render-gc-cons-percentage 0.9) + (ebox-wheel-smooth-scroll-lines-per-tick 1) + (ebox-wheel-smooth-scroll-interval 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (with-current-buffer buffer + (should (ebox--smooth-scroll-region-by root-id 4))) + (should (>= gc-cons-threshold target-threshold)) + (should (>= gc-cons-percentage 0.9)) + (should ebox--deferred-render-gc-state) + (ebox--smooth-scroll-stop root-id) + (should (timerp ebox--deferred-render-gc-timer))) + (ebox--smooth-scroll-stop root-id) + (when (timerp ebox--deferred-render-gc-timer) + (cancel-timer ebox--deferred-render-gc-timer)) + (setq ebox--deferred-render-gc-timer nil) + (setq ebox--deferred-render-gc-state nil) + (setq gc-cons-threshold old-threshold) + (setq gc-cons-percentage old-percentage) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(defun ebox-test--visible-foreground (face) + "Return the highest-priority :foreground carried by FACE." + (cond + ((null face) nil) + ((and (listp face) (keywordp (car face))) + (plist-get face :foreground)) + ((listp face) + (cl-some #'ebox-test--visible-foreground face)) + (t nil))) + +(ert-deftest ebox-ancestor-paint-patch-preserves-descendant-own-paint () + "A parent paint-patch must not clobber a child's own declared paint." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id parent :width (120) :color "black" + (column :width (120) + (box :id child :content "Child" :width (120) + :color "blue") + (box :id plain :content "Plain" :width (120))))))) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (parent-id (ebox-test--selector-region-id buffer "#parent"))) + (ebox-region-update parent-id :color "red") + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'paint-patch)) + (goto-char (point-min)) + (search-forward "Child") + ;; The child's own declaration stays the highest-priority paint. + (should (equal (ebox-test--visible-foreground + (get-text-property (match-beginning 0) 'face)) + "blue")) + (goto-char (point-min)) + (search-forward "Plain") + ;; A non-declaring sibling inherits the new parent paint. + (should (equal (ebox-test--visible-foreground + (get-text-property (match-beginning 0) 'face)) + "red")))))) + +(ert-deftest ebox-intrinsic-measurement-preserves-scroll-state () + "Intrinsic measurement must not reset live scroll offsets or state." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 20 + collect + `(box :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (with-current-buffer buffer + (should (ebox--scroll-region-by root-id 5 48))) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 5)) + (let ((box (gethash root-id ebox--region-box-table))) + (should box) + ;; Flex ancestors measure live subtrees with the intrinsic + ;; flag bound; the measurement render must be side-effect + ;; free for the published runtime. + (let ((ebox--intrinsic-layout-measurement t)) + (ebox-render box)) + (should (equal (ebox-get box :scroll-offset) 5))) + (let ((state (ebox--scroll-get-state root-id))) + (should state) + (should (= (plist-get state :scroll-offset) 5)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-rerender-retries-after-quit () + "A quit during viewport reflow must not wedge later same-size reflows." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) + :content "viewport reflow retry"))) + (buffer nil)) + (unwind-protect + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (cl-letf (((symbol-function 'ebox--execute-constraint-change) + (lambda (&rest _) (signal 'quit nil)))) + (condition-case nil + (ebox-rerender-buffer-with-context buffer 320) + (quit nil))) + ;; The interrupted reflow published nothing, so the stored + ;; dimensions must still match the published render. + (should (equal (plist-get (ebox--buffer-render-state buffer) + :viewport-width) + 240)) + ;; The same-size retry must reflow instead of no-op. + (let ((report (ebox-rerender-buffer-with-context buffer 320))) + (should report) + (should-not (eq (plist-get report :strategy) 'no-op))) + (should (equal (plist-get (ebox--buffer-render-state buffer) + :viewport-width) + 320))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-smooth-scroll-multi-event-restores-gc-depth () + "Coalesced wheel events must not leak the deferred GC lease depth." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (old-threshold gc-cons-threshold) + (old-percentage gc-cons-percentage)) + (unwind-protect + (let ((gc-cons-threshold 1000) + (ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-deferred-render-gc-cons-threshold 2000) + (ebox-render-gc-cons-percentage 0.9) + (ebox-wheel-smooth-scroll-lines-per-tick 1) + (ebox-wheel-smooth-scroll-interval 999)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (with-current-buffer buffer + ;; One trackpad flick delivers many wheel events that + ;; coalesce into a single running animation. + (dotimes (_ 5) + (should (ebox--smooth-scroll-region-by root-id 4)))) + (should (= ebox--deferred-render-gc-depth 1)) + (ebox--smooth-scroll-stop root-id) + (should (= ebox--deferred-render-gc-depth 0)) + (should (timerp ebox--deferred-render-gc-timer))) + (when root-id + (ebox--smooth-scroll-stop root-id)) + (when (timerp ebox--deferred-render-gc-timer) + (cancel-timer ebox--deferred-render-gc-timer)) + (setq ebox--deferred-render-gc-timer nil) + (setq ebox--deferred-render-gc-state nil) + (setq ebox--deferred-render-gc-depth 0) + (setq gc-cons-threshold old-threshold) + (setq gc-cons-percentage old-percentage) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-smooth-scroll-clamps-without-full-materialize () + "Smooth wheel clamp should extend lazy scroll prefixes without full render." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + (hidden-render-count 0) + (original-render-box (symbol-function 'ebox--render-box))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2)) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (box) + (when (equal (ebox-tree-node-id box) "row-30") + (cl-incf hidden-render-count)) + (funcall original-render-box box)))) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let ((ebox-wheel-smooth-scroll-lines-per-tick 1) + (ebox-wheel-smooth-scroll-interval 60.0)) + (should (ebox--smooth-scroll-region-by root-id 2)) + (should (= hidden-render-count 0)) + (let ((state (ebox--scroll-get-state root-id))) + (should (= (plist-get state :scroll-offset) 1)) + (should (< (length (plist-get state :content-lines)) 40)) + (should (plist-get state :materialize-content-lines)))))) + (ebox--smooth-scroll-stop root-id) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-hidden-child-update-does-not-materialize () + "Hidden updates inside lazy scroll windows should stay deferred." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 32 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id target-id next-target-id + (materialize-count 0) + (owner-rerenders 0) + (line-span-scans 0) + (membership-builds 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (with-current-buffer buffer + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (setq target-id (ebox-test--selector-region-id buffer "#row-30")) + (setq next-target-id + (ebox-test--selector-region-id buffer "#row-31")) + (should target-id) + (should next-target-id) + (let ((state (ebox--scroll-get-state root-id))) + (should (plist-get state :materialize-content-lines)) + (let ((materialize (plist-get state + :materialize-content-lines))) + (plist-put + state :materialize-content-lines + (lambda (&rest args) + (cl-incf materialize-count) + (apply materialize args))) + (puthash root-id state ebox--scroll-global-state))) + (let ((original-line-span-scan + (symbol-function 'ebox--scroll-state-region-line-spans)) + (original-membership-build + (symbol-function + 'ebox--scroll-build-content-region-id-set))) + (cl-letf (((symbol-function 'ebox-buffer-apply-owner-rerender) + (lambda (&rest _args) + (cl-incf owner-rerenders) + nil)) + ((symbol-function 'ebox--scroll-state-region-line-spans) + (lambda (&rest args) + (cl-incf line-span-scans) + (apply original-line-span-scan args))) + ((symbol-function + 'ebox--scroll-build-content-region-id-set) + (lambda (&rest args) + (cl-incf membership-builds) + (apply original-membership-build args)))) + (ebox-selector-update-buffer buffer "#row-30" + :content "new row 30") + (ebox-selector-update-buffer buffer "#row-31" + :content "new row 31"))) + (let ((report (ebox-test--buffer-update-report))) + (should (= materialize-count 0)) + (should (= owner-rerenders 0)) + (should (= line-span-scans 0)) + (should (= membership-builds 1)) + (should (plist-get (ebox--scroll-get-state root-id) + :content-region-id-set)) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :lazy-scroll-deferred-patch))) + (should-not (string-match-p "new row 30" + (buffer-substring-no-properties + (point-min) (point-max)))) + (should (ebox--scroll-region-by root-id 30)) + (should (= materialize-count 0)) + (let ((state (ebox--scroll-get-state root-id))) + (should-not (plist-get state :content-lines-complete-p)) + (should (gethash root-id ebox--scroll-idle-prefetch-timers))) + (ebox--scroll-idle-prefetch root-id) + (let* ((state (ebox--scroll-get-state root-id)) + (delta (- 30 (or (plist-get state :scroll-offset) + 0)))) + (when (> delta 0) + (should (ebox--scroll-region-by root-id delta)))) + (let ((state (ebox--scroll-get-state root-id))) + (should (plist-get state :content-lines-complete-p)) + (should-not (plist-get state :materialize-content-lines))) + (should (string-match-p "new row 30" + (buffer-substring-no-properties + (point-min) (point-max)))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-prefix-extension-keeps-live-window-settled () + "A clean offscreen prefix extension should not stall the live window." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for index below 40 + collect + `(box :content ,(format "row %02d" index) + :width (300) :height 1)))))) + root-id + (ebox-viewport-width 300) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id + (current-buffer) "#root")) + (let* ((state (ebox-scroll-state root-id)) + (required-lines + (+ 5 (length (plist-get state :content-lines))))) + (should-not (plist-get state :content-lines-complete-p)) + (setq state + (ebox--scroll-state-ensure-prefix-lines + root-id state required-lines t)) + (should-not + (plist-get state :lazy-scroll-window-refresh-required))) + (should (= (ebox--scroll-region-by root-id 1 1) 1)) + (should-not (plist-get (ebox-scroll-state root-id) + :lazy-scroll-window-refresh-required)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-lazy-scroll-prefix-refreshes-without-offset-motion () + "A visibly changed prefix should publish atomically without offset motion." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for index below 40 + collect + `(box :content ,(format "row %02d" index) + :width (300) :height 1)))))) + root-id + (ebox-viewport-width 300) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id + (current-buffer) "#root")) + (let* ((state (ebox-scroll-state root-id)) + (render-prefix (plist-get state :render-content-prefix)) + (required-lines + (+ 5 (length (plist-get state :content-lines))))) + (plist-put + state :render-content-prefix + (lambda (source-state region-id limit) + (let* ((rendered + (funcall render-prefix source-state region-id limit)) + (lines + (copy-sequence + (plist-get rendered :rendered-content-lines))) + (first (copy-sequence (car lines)))) + (when (> (length first) 0) + (put-text-property 0 1 'ebox-test-generation t first)) + (setf (car lines) first) + (plist-put rendered :rendered-content-lines lines) + rendered))) + (ebox--scroll-state-ensure-prefix-lines + root-id state required-lines t) + (should (plist-get (ebox-scroll-state root-id) + :lazy-scroll-window-refresh-required))) + (should (eq (ebox--scroll-region-by root-id 0) 'refreshed)) + (should (= (plist-get (ebox-scroll-state root-id) :scroll-offset) 0)) + (should-not (plist-get (ebox-scroll-state root-id) + :lazy-scroll-window-refresh-required)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-visible-geometry-slot-miss-refreshes-lazy-window-atomically () + "A visible slot miss should publish rebuilt lazy lines before returning." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + (flex :width (300) :height 5 :padding (0 (10)) + :border "#6F6A95" :justify-content center + :align-items center + (box :id "target" :content "width target\nexpands" + :width (190) :height 3 :padding (0 (10)) + :border "#9CA3AF" :text-align center)) + ,@(cl-loop for index below 40 + collect + `(box :content ,(format "row %02d" index) + :width (300) :height 1)))))) + root-id target-id + (ebox-viewport-width 300) + (ebox-viewport-height 5) + (ebox-scroll-lazy-prefix-lookahead-lines 12) + (rendered-lookups 0) + (original-rendered-lookup + (symbol-function 'ebox--scroll-hot-rendered-line-spans))) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id + (current-buffer) "#root")) + (setq target-id (ebox-test--selector-region-id + (current-buffer) "#target")) + (let ((state (ebox-scroll-state root-id))) + (should (plist-get state :render-content-prefix)) + (should-not (plist-get state :content-lines-complete-p)) + ;; Cache enough untouched lines that the next one-line scroll does not + ;; cross the old prefix boundary. Dirty-state refresh must not depend + ;; on reaching that boundary. + (setq state + (ebox--scroll-state-ensure-prefix-lines + root-id state 12 t)) + (should (> (- (length (plist-get state :content-lines)) + (plist-get state :content-height)) + 1))) + (cl-letf (((symbol-function 'ebox--scroll-hot-rendered-line-spans) + (lambda (&rest args) + (cl-incf rendered-lookups) + (apply original-rendered-lookup args)))) + (ebox-region-update target-id :width '(280))) + (let ((report (ebox-test--buffer-update-report)) + (state (ebox-scroll-state root-id))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :scroll-state-sync-refreshed)) + (should (= (plist-get report :scroll-state-sync-refreshed-count) 1)) + (should-not (plist-get report :scroll-state-sync-deferred)) + (should (= rendered-lookups 0)) + (should-not (plist-get state :lazy-scroll-prefix-dirty)) + (should-not (plist-get state + :lazy-scroll-window-refresh-required)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))) + ;; Later movement must start from the already-published cache generation. + (should (= (ebox--scroll-region-by root-id 1 1) 1)) + (should-not (plist-get (ebox-scroll-state root-id) + :lazy-scroll-prefix-dirty)) + (should-not (plist-get (ebox-scroll-state root-id) + :lazy-scroll-window-refresh-required)) + (should (= (ebox--scroll-region-by root-id -1 1) -1)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-lazy-scroll-cached-hidden-child-update-patches-prefix () + "Hidden updates should patch cached lazy prefix lines before deferring." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + buffer + root-id) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (with-current-buffer buffer + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (should (ebox--scroll-region-by root-id 30 32)) + (should (string-match-p "row 30" + (buffer-substring-no-properties + (point-min) (point-max)))) + (should (ebox--scroll-region-by root-id -30)) + (should-not (string-match-p "new row 30" + (buffer-substring-no-properties + (point-min) (point-max)))) + (ebox-selector-update-buffer buffer "#row-30" + :content "new row 30") + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should-not (plist-get report :lazy-scroll-deferred-patch)) + (should (plist-get report :scroll-state-patch))) + (should (ebox--scroll-region-by root-id 30)) + (let ((plain (buffer-substring-no-properties + (point-min) (point-max)))) + (should (string-match-p "new row 30" plain)) + (should-not (string-match-p "^row 30$" plain))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-deferred-update-dirties-future-prefix () + "Deferred hidden updates should be visible when lazy prefix reaches them." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + buffer + root-id + (materialize-count 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (with-current-buffer buffer + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox--scroll-get-state root-id)) + (materialize (plist-get state :materialize-content-lines))) + (plist-put + state :materialize-content-lines + (lambda (&rest args) + (cl-incf materialize-count) + (apply materialize args))) + (puthash root-id state ebox--scroll-global-state)) + (ebox-selector-update-buffer buffer "#row-30" + :content "new row 30") + (let ((report (ebox-test--buffer-update-report))) + (should (plist-get report :lazy-scroll-deferred-patch))) + (should (= materialize-count 0)) + (cl-loop repeat 30 + do (should (ebox--scroll-region-by root-id 1 1))) + (should (= materialize-count 0)) + (let ((plain (buffer-substring-no-properties + (point-min) (point-max)))) + (should (string-match-p "new row 30" plain)) + (should-not (string-match-p "^row 30$" plain))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-dirty-prefix-rebinds-signature-cache () + "Dirty lazy prefixes should rebind the render signature cache before rendering." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "" :width 10)) + (renderer (ebox--scroll-window-prefix-renderer + box :test-source 120 4)) + first-cache + second-cache + (render-count 0)) + (cl-letf (((symbol-function 'ebox--render-scroll-window-source) + (lambda (&rest _args) + (cl-incf render-count) + (if first-cache + (setq second-cache ebox--render-cache-signature-cache) + (setq first-cache ebox--render-cache-signature-cache)) + (list :lines (list (format "line %d" render-count)) + :cache nil + :complete nil + :content-width-exact-p t + :rendered-lines + (list (format "line %d" render-count))))) + ((symbol-function 'ebox--scroll-window-formatted-lines) + (lambda (_box window) + (plist-get window :lines))) + ((symbol-function 'ebox--scroll-rendered-content-lines) + (lambda (_box lines _region-id _start-index) + lines))) + (funcall renderer nil 1 1) + (puthash :stale t first-cache) + (funcall renderer '(:lazy-scroll-prefix-dirty t) 1 1) + (should (= render-count 2)) + (should-not (eq first-cache second-cache)) + (should-not (gethash :stale second-cache))))) + +(ert-deftest ebox-visible-scroll-child-content-paint-patches-scroll-lines () + "Visible content plus paint updates should patch scroll lines directly." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 8 + collect + `(box :id ,(format "metric-%02d" idx) + :content "待运行" + :width (120) + :height 2)))))) + buffer + root-id) + (unwind-protect + (let ((ebox-viewport-width 260) + (ebox-viewport-height 4) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (with-current-buffer buffer + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (ebox-selector-update-buffer buffer "#metric-01" + :content "结果:通过" + :color "#B91C1C") + (let* ((report (ebox-test--buffer-update-report)) + (state (ebox--scroll-get-state root-id)) + (plain (mapconcat #'substring-no-properties + (plist-get state :content-lines) + "\n"))) + (should (plist-get report :scroll-state-patch)) + (should (string-match-p "结果:通过" plain)) + (should-not (string-match-p "metric-01.*待运行" plain))) + (should (ebox--scroll-region-by root-id 1 1)) + (let ((plain (buffer-substring-no-properties + (point-min) (point-max)))) + (should (string-match-p "结果:通过" plain))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-scroll-state-span-patch-key-policy-requires-content-for-paint () + "Paint keys should only enter scroll-state patch with content changes." + (should (ebox--scroll-state-span-patch-keys-p '(:content))) + (should (ebox--scroll-state-span-patch-keys-p '(:content :color))) + (should (ebox--scroll-state-span-patch-keys-p + '(:content :bgcolor :border-top-color))) + (should (ebox--scroll-state-span-patch-keys-p '(:text-align))) + (should-not (ebox--scroll-state-span-patch-keys-p '(:color))) + (should-not (ebox--scroll-state-span-patch-keys-p + '(:bgcolor :border-top-color)))) + +(ert-deftest ebox-scroll-state-span-patch-ignores-foreign-runtime-states () + "One buffer update must not materialize foreign or unrelated ownerless state." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :height 2 :overflow scroll + (column + (box :id "target" :content "before" :height 1) + (box :content "tail" :height 1))))) + (foreign-buffer (generate-new-buffer " *ebox-foreign-scroll*")) + (foreign-id :foreign-scroll-state) + (ownerless-id :ownerless-scroll-state) + (foreign-materializations 0) + (ownerless-materializations 0)) + (unwind-protect + (ebox-test--with-rendered-buffer layout + (let ((target-id + (ebox-test--selector-region-id + (current-buffer) "#target"))) + (puthash + foreign-id + (list :buffer foreign-buffer + :content-lines '("foreign prefix") + :materialize-content-lines + (lambda (_state _region-id) + (cl-incf foreign-materializations) + '(:content-lines ("foreign complete") + :rendered-content-lines ("foreign complete") + :content-height 1))) + ebox--scroll-global-state) + (puthash + ownerless-id + (list :content-lines '("ownerless prefix") + :materialize-content-lines + (lambda (_state _region-id) + (cl-incf ownerless-materializations) + '(:content-lines ("ownerless complete") + :rendered-content-lines ("ownerless complete") + :content-height 1))) + ebox--scroll-global-state) + (ebox-region-update target-id :content "after") + (should (= foreign-materializations 0)) + (should (= ownerless-materializations 0)) + (should (string-match-p + "after" + (buffer-substring-no-properties + (point-min) (point-max)))))) + (remhash foreign-id ebox--scroll-global-state) + (remhash ownerless-id ebox--scroll-global-state) + (when (buffer-live-p foreign-buffer) + (kill-buffer foreign-buffer))))) + +(ert-deftest ebox-visible-scroll-child-sync-publishes-window-markers () + "Post-patch scroll line sync must publish one current marker generation." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 8 + collect + `(box :id ,(format "metric-%02d" idx) + :content "待运行" + :width (120) + :height 2)))))) + buffer + root-id) + (unwind-protect + (let ((ebox-viewport-width 260) + (ebox-viewport-height 4) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (with-current-buffer buffer + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (ebox-selector-update-buffer buffer "#metric-01" + :padding-left (list 8) + :color "#B91C1C") + (let* ((report (ebox-test--buffer-update-report)) + (state (ebox--scroll-get-state root-id))) + (should (plist-get report :scroll-state-sync)) + (should-not + (plist-get report + :scroll-state-window-markers-invalidated)) + (should (> (or (plist-get + report + :scroll-state-window-marker-refresh-count) + 0) + 0)) + (should (plist-get state :content-span-markers)) + (should (plist-get state :content-window-region-id-set)) + (should (ebox--scroll-state-window-markers-healthy-p + buffer root-id state))) + (should (ebox--scroll-region-by root-id 1 1)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-scroll-retains-published-window-markers-after-paint-update () + "Paint updates and later scrolling should share one current marker window." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 24 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + buffer + root-id) + (unwind-protect + (let ((ebox-viewport-width 260) + (ebox-viewport-height 4) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (with-current-buffer buffer + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (should (plist-get (ebox-scroll-state root-id) + :content-span-markers)) + (should (plist-get (ebox-scroll-state root-id) + :rendered-window-start-marker)) + (ebox-selector-update-buffer buffer "#row-01" + :color "#B91C1C") + (let ((report (ebox-test--buffer-update-report)) + (state (ebox-scroll-state root-id))) + (should (plist-get report :scroll-state-paint-sync)) + (should-not + (plist-get report + :scroll-state-window-markers-invalidated)) + (should (plist-get state :content-span-markers)) + (should (ebox--scroll-state-window-markers-healthy-p + buffer root-id state))) + (should (plist-get (ebox-scroll-state root-id) + :rendered-window-start-marker)) + (cl-loop repeat 6 + do + (should (ebox--scroll-region-by root-id 1 1)) + (should (plist-get (ebox-scroll-state root-id) + :rendered-window-start-marker)) + (should (ebox--scroll-state-window-markers-healthy-p + buffer root-id (ebox-scroll-state root-id))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-chromed-scroll-owner-rerender-publishes-current-window () + "A scoped chromed owner rerender must publish its cache and live markers." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(column :id "doc" :width (240) + (box :id "scroll" :width (180) :height 3 + :overflow scroll :border-top-p t + (column + (box :id "target" :content "alpha" + :width (100) :height 1) + (box :content "tail one" :height 1) + (box :content "tail two" :height 1) + (box :content "tail three" :height 1)))))) + (fallback-count 0)) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (scroll-id (ebox-test--selector-region-id buffer "#scroll")) + (target-id (ebox-test--selector-region-id buffer "#target")) + (scroll-node-id + (ebox--ensure-node-id + (plist-get (ebox-scroll-state scroll-id) :box))) + (original-fallback + (symbol-function + 'ebox--sync-scroll-state-lines-for-node-fallback))) + (cl-letf (((symbol-function 'ebox--patch-set-from-dirty-set) + (lambda (dirty-set _buffer) + (list + (ebox--patch-op + 'owner-rerender scroll-node-id + :dirty (car dirty-set))))) + ((symbol-function + 'ebox--sync-scroll-state-lines-for-node-fallback) + (lambda (&rest args) + (cl-incf fallback-count) + (apply original-fallback args)))) + (ebox-region-update target-id :height 2)) + (let* ((report (ebox-test--buffer-update-report)) + (state (ebox-scroll-state scroll-id)) + (spans + (ebox--scroll-content-marker-spans + (plist-get state :content-span-markers)))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (plist-get report :scroll-state-patch)) + (should (plist-get report :scroll-state-owner-publication)) + (should (= (or fallback-count 0) 0)) + (should-not (plist-get state :lazy-scroll-prefix-dirty)) + (should-not + (plist-get state :lazy-scroll-window-refresh-required)) + (should (= (length spans) (plist-get state :content-height))) + (should + (cl-every + (lambda (span) + (and (marker-buffer (car span)) + (marker-buffer (cdr span)))) + spans)) + (should (ebox--scroll-state-window-markers-healthy-p + buffer scroll-id state)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))) + (should (= (ebox--scroll-region-by scroll-id 1 48) 1)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-owner-rerender-without-scroll-skips-scroll-publication () + "A scroll-free root reflow should not scan or publish scroll marker state." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(column :width (viewport) + (box :content "resize" :width (viewport))))) + buffer + (containment-count 0) + (publication-count 0) + (original-containment + (symbol-function + 'ebox--scroll-state-region-ids-containing-node-ids)) + (original-publication + (symbol-function 'ebox--publish-scoped-owner-scroll-windows))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-no-scroll-publication*") + layout)) + (cl-letf + (((symbol-function + 'ebox--scroll-state-region-ids-containing-node-ids) + (lambda (&rest arguments) + (cl-incf containment-count) + (apply original-containment arguments))) + ((symbol-function 'ebox--publish-scoped-owner-scroll-windows) + (lambda (&rest arguments) + (cl-incf publication-count) + (apply original-publication arguments)))) + (ebox-rerender-buffer-with-context buffer 260 4)) + (should (= containment-count 0)) + (should (= publication-count 0)) + (with-current-buffer buffer + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-nested-scroll-fallback-publishes-one-marker-generation () + "Nested fallback sync must rebuild markers once without zero-delta scrolls." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "outer" :width (180) :height 3 :overflow scroll + (column + (box :id "inner" :width (160) :height 2 :overflow scroll + (column + (box :id "target" :content "old" :height 1) + (box :content "inner 1" :height 1) + (box :content "inner 2" :height 1))) + (box :content "outer 1" :height 1) + (box :content "outer 2" :height 1))))) + (marker-refresh-count 0) + (zero-delta-scroll-count 0)) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (outer-id (ebox-test--selector-region-id buffer "#outer")) + (inner-id (ebox-test--selector-region-id buffer "#inner")) + (target-id (ebox-test--selector-region-id buffer "#target")) + (target-node + (ebox--buffer-region-render-owner-node buffer target-id))) + (ebox-region-update target-id :content "new") + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (dolist (scroll-id (list outer-id inner-id)) + (let ((state (ebox-scroll-state scroll-id))) + (plist-put state :lazy-scroll-window-refresh-required t) + (puthash scroll-id state ebox--scroll-global-state))) + (let ((visible-before + (buffer-substring (point-min) (point-max))) + (original-refresh + (symbol-function 'ebox--refresh-buffer-scroll-content-markers)) + (original-scroll (symbol-function 'ebox--scroll-region-by))) + (cl-letf + (((symbol-function 'ebox--refresh-buffer-scroll-content-markers) + (lambda (&rest args) + (cl-incf marker-refresh-count) + (apply original-refresh args))) + ((symbol-function 'ebox--scroll-region-by) + (lambda (region-id delta &rest args) + (when (zerop delta) + (cl-incf zero-delta-scroll-count)) + (apply original-scroll region-id delta args)))) + (ebox--sync-scroll-state-lines-for-node-fallback + buffer target-id target-node '(:content) + (ebox-test--buffer-update-report))) + (should (= (or marker-refresh-count 0) 1)) + (should (= (or zero-delta-scroll-count 0) 0)) + (should (equal-including-properties + visible-before + (buffer-substring (point-min) (point-max))))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (dolist (scroll-id (list outer-id inner-id)) + (let ((state (ebox-scroll-state scroll-id))) + (should-not + (plist-get state :lazy-scroll-window-refresh-required)) + (should (ebox--scroll-state-window-extents scroll-id state)) + (should (ebox--scroll-state-window-markers-healthy-p + buffer scroll-id state)))))))) + +(ert-deftest ebox-root-scroll-owner-rerender-publishes-current-window () + "Root scroll rerenders must publish both plain and chromed windows." + (dolist (chromed '(nil t)) + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (180) :height 3 + :overflow scroll :wrap-mode nil + ,@(when chromed '(:border-top-p t)) + (column + (box :content "row 0" :height 1) + (box :content "row 1" :height 1) + (box :content "row 2" :height 1) + (box :content "row 3" :height 1))))) + (fallback-count 0)) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (root-id (ebox-test--selector-region-id buffer "#root")) + (root-node-id (ebox--buffer-root-node-id buffer)) + (original-fallback + (symbol-function + 'ebox--sync-scroll-state-lines-for-node-fallback))) + (cl-letf (((symbol-function 'ebox--try-scroll-state-span-patch) + (lambda (&rest _args) nil)) + ((symbol-function 'ebox--patch-set-from-dirty-set) + (lambda (dirty-set _buffer) + (list + (ebox--patch-op + 'owner-rerender root-node-id + :dirty (car dirty-set))))) + ((symbol-function + 'ebox--sync-scroll-state-lines-for-node-fallback) + (lambda (&rest args) + (cl-incf fallback-count) + (apply original-fallback args)))) + (ebox-region-update root-id :wrap-mode 'char)) + (let ((report (ebox-test--buffer-update-report)) + (state (ebox-scroll-state root-id))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :owner-ids) + (list root-node-id))) + (should (plist-get report :scroll-state-patch)) + (should (plist-get report :scroll-state-owner-publication)) + (should (= (or fallback-count 0) 0)) + (should-not + (plist-get state :lazy-scroll-window-refresh-required)) + (should (ebox--scroll-state-window-markers-healthy-p + buffer root-id state)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))) + (should (= (ebox--scroll-region-by root-id 1 48) 1)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-lazy-scroll-hidden-geometry-update-defers-rerender () + "Hidden geometry changes in lazy scroll windows should not rerender roots." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 32 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :width (120) + :height 1)))))) + (buffer nil) + target-id + (owner-rerenders 0) + (root-rerenders 0)) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (with-current-buffer buffer + (setq target-id (ebox-test--selector-region-id buffer "#row-30")) + (should target-id) + (cl-letf (((symbol-function 'ebox-buffer-apply-owner-rerender) + (lambda (&rest _args) + (cl-incf owner-rerenders) + nil)) + ((symbol-function 'ebox-buffer-apply-root-rerender) + (lambda (&rest _args) + (cl-incf root-rerenders) + nil))) + (ebox-selector-update-buffer buffer "#row-30" :width '(180))) + (let ((report (ebox-test--buffer-update-report))) + (should (= owner-rerenders 0)) + (should (= root-rerenders 0)) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :lazy-scroll-deferred-patch))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-lazy-scroll-hidden-paint-update-keeps-paint-path () + "Paint-only updates should not pay lazy scroll geometry deferral costs." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + ,@(cl-loop for idx below 32 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :color "#111111" + :height 1)))))) + (buffer nil) + target-id) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (with-current-buffer buffer + (setq target-id (ebox-test--selector-region-id buffer "#row-30")) + (should target-id) + (ebox-selector-update-buffer buffer "#row-30" :color "#cc0000") + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'paint-patch)) + (should-not (plist-get report :lazy-scroll-deferred-patch))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-scroll-rendered-content-patches-child-range-only () + "Hidden child updates should patch the rendered scroll-line range." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :height 2 :overflow scroll + (column + (box :id "a" :content "A" :height 1) + (box :id "target" :content "old" :height 1 + :padding (0 (8)) :border "#999999") + (box :id "c" :content "C" :height 1))))) + root-id target-id + (full-window-renders 0)) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (setq target-id (ebox-test--selector-region-id (current-buffer) + "#target")) + (cl-letf (((symbol-function 'ebox--render-scroll-state-window) + (lambda (&rest _args) + (cl-incf full-window-renders) + nil))) + (ebox-region-update target-id :content "new")) + (let ((report (ebox-test--buffer-update-report))) + (should (plist-get report :scroll-rendered-range-patch)) + (should (= full-window-renders 0))) + (should (ebox--scroll-region-by root-id 1)) + (should (string-match-p "new" + (buffer-substring-no-properties + (point-min) (point-max)))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-scroll-state-set-lines-defers-line-indexes-when-bound () + "Deferred scroll state setup should avoid building full line indexes." + (ebox-test--reset-runtime-state) + (let ((content-lines (list (propertize "a" 'ebox-content 1) + (propertize "b" 'ebox-content 2))) + (rendered-lines (list (propertize "a" 'ebox-content 1))) + (build-count 0) + (original (symbol-function 'ebox--scroll-build-region-line-span-index)) + (ebox--defer-scroll-content-index t)) + (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) + (lambda (&rest args) + (cl-incf build-count) + (apply original args)))) + (let ((state (ebox--scroll-state-set-lines nil content-lines + rendered-lines))) + (should (= build-count 0)) + (should-not (plist-get state :region-line-span-index)) + (should (plist-get state :region-line-span-index-deferred)) + (should-not (plist-get state :rendered-region-line-span-index)) + (should (plist-get state + :rendered-region-line-span-index-deferred)))))) + +(ert-deftest ebox-lazy-scroll-set-state-defers-line-indexes () + "Lazy scroll setup should not build hidden content indexes on first render." + (ebox-test--reset-runtime-state) + (let ((content-lines (list (propertize "a" 'ebox-content 1) + (propertize "b" 'ebox-content 2))) + (rendered-lines (list (propertize "a" 'ebox-content 1))) + (build-count 0) + (original (symbol-function 'ebox--scroll-build-region-line-span-index)) + (ebox-scroll-lazy-idle-prefetch-lines 0)) + (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) + (lambda (&rest args) + (cl-incf build-count) + (apply original args)))) + (ebox--scroll-set-state + 42 + (list :scroll-offset 0 + :content-height 1 + :content-lines content-lines + :rendered-content-lines rendered-lines + :render-content-prefix (lambda (&rest _args) nil) + :materialize-content-lines (lambda (&rest _args) nil))) + (let ((state (ebox--scroll-get-state 42))) + (should (= build-count 0)) + (should-not (plist-get state :region-line-span-index)) + (should (plist-get state :region-line-span-index-deferred)) + (should-not (plist-get state :rendered-region-line-span-index)) + (should (plist-get state + :rendered-region-line-span-index-deferred)))))) + +(ert-deftest ebox-scroll-state-builds-deferred-rendered-index-on-demand () + "Deferred rendered line indexes should be built only when queried." + (ebox-test--reset-runtime-state) + (let ((content-lines (list (propertize "a" 'ebox-content 1) + (propertize "b" 'ebox-content 2))) + (rendered-lines (list (propertize "a" 'ebox-content 1))) + (build-count 0) + (original (symbol-function 'ebox--scroll-build-region-line-span-index)) + (ebox--defer-scroll-content-index t)) + (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) + (lambda (&rest args) + (cl-incf build-count) + (apply original args)))) + (let ((state (ebox--scroll-state-set-lines nil content-lines + rendered-lines))) + (should (= build-count 0)) + (should (ebox--scroll-rendered-line-spans-for-region-ids state + '(1))) + (should (= build-count 1)) + (should (plist-get state :rendered-region-line-span-index)) + (should-not + (plist-get state :rendered-region-line-span-index-deferred)))))) + +(ert-deftest ebox-reflow-cache-prewarm-uses-first-valid-width-delta () + "Predictive warming should start from the first valid resize velocity." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) :height 2 + (box :content "steady" :width (viewport) :height 1)))) + buffer + scheduled-delay + scheduled-repeat + scheduled-function + scheduled-arguments) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm t) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") layout)) + (cl-letf (((symbol-function 'run-at-time) + (lambda (&rest _) + (ert-fail "Resize prediction must yield to input"))) + ((symbol-function 'run-with-idle-timer) + (lambda (delay repeat function &rest arguments) + (setq scheduled-delay delay + scheduled-repeat repeat + scheduled-function function + scheduled-arguments arguments) + 'scheduled-reflow-timer))) + (ebox-rerender-buffer-with-context buffer 260 4)) + (should (= scheduled-delay + ebox-runtime-idle-reflow-cache-prewarm-delay)) + (should-not scheduled-repeat) + (should (eq scheduled-function #'ebox--run-reflow-cache-prewarm)) + (should (equal (car scheduled-arguments) buffer)) + (should (eq (gethash buffer ebox--reflow-cache-prewarm-timers) + 'scheduled-reflow-timer)) + (should (= (plist-get (ebox--buffer-render-state buffer) + :last-viewport-width-delta) + 20))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-reflow-cache-prewarm-isolates-published-runtime-state () + "Predicted renders should publish only persistent render-cache entries." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (flex :width (viewport) :gap 1 + ,@(cl-loop for index below 8 + collect + `(box :id ,(format "card-%d" index) + :content ,(format "card %d" index) + :width 72 :height 1)))))) + buffer) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") layout)) + (ebox--cancel-buffer-runtime-prewarm buffer) + (let* ((state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (root-before (copy-tree root)) + (text-before + (with-current-buffer buffer (buffer-string))) + (cache (plist-get state :render-cache)) + (cache-count (hash-table-count cache)) + (signature-count + (hash-table-count + (plist-get state :render-signature-cache))) + (root-id (ebox-test--selector-region-id buffer "#root")) + (scroll-before + (copy-tree (ebox--scroll-get-state root-id))) + (region-table-count (hash-table-count ebox--region-box-table))) + (ebox-test--set-buffer-update-report + buffer 'keep-this-report) + (should + (ebox--prewarm-buffer-render-cache-at-context + buffer state 0 240 280 4)) + (should (> (hash-table-count cache) cache-count)) + (should (= (hash-table-count + (plist-get state :render-signature-cache)) + signature-count)) + (should (equal root root-before)) + (should (equal-including-properties + (with-current-buffer buffer (buffer-string)) + text-before)) + (should (= (plist-get state :viewport-width) 240)) + (should (equal (ebox--scroll-get-state root-id) scroll-before)) + (should (= (hash-table-count ebox--region-box-table) + region-table-count)) + (should (eq (ebox-buffer-update-report buffer) + 'keep-this-report)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-reflow-cache-prewarm-rejects-stale-runtime-revision () + "A stale predicted render should exit before touching render caches." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) :content "stale"))) + buffer + (render-count 0) + (original-render (symbol-function 'ebox-render))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") layout)) + (ebox--cancel-buffer-runtime-prewarm buffer) + (let* ((state (ebox--buffer-render-state buffer)) + (cache (plist-get state :render-cache)) + (cache-count (hash-table-count cache))) + (cl-letf (((symbol-function 'ebox-render) + (lambda (&rest args) + (cl-incf render-count) + (apply original-render args)))) + (should-not + (ebox--prewarm-buffer-render-cache-at-context + buffer state 1 240 280 4))) + (should (= render-count 0)) + (should (= (hash-table-count cache) cache-count)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-local-update-cancels-without-scheduling-reflow-prewarm () + "Single-property updates should cancel stale predictions and schedule none." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) + (box :id "target" :content "old" :width 80)))) + buffer) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm t) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") layout)) + (ebox-rerender-buffer-with-context buffer 260 4) + (ebox-rerender-buffer-with-context buffer 280 4) + (should (gethash buffer ebox--reflow-cache-prewarm-timers)) + (ebox-selector-update-buffer buffer "#target" :content "new") + (should-not (gethash buffer ebox--reflow-cache-prewarm-timers)) + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'span-patch))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-prepares-shared-update-state () + "Idle prewarm should prepare shared update state without materializing scrolls." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height 2 :overflow scroll + (column + ,@(cl-loop for idx below 40 + collect + `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + root-id + expected-viewport-ids + (materialize-count 0)) + (unwind-protect + (let ((ebox-viewport-width 900) + (ebox-scroll-lazy-idle-prefetch-lines 0) + (ebox-runtime-idle-prewarm-slice-size 10000) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (setq expected-viewport-ids + (ebox--viewport-dependent-node-ids layout)) + (let* ((state (ebox-scroll-state root-id)) + (before-lines (length (plist-get state :content-lines))) + (materialize (plist-get state :materialize-content-lines))) + (when materialize + (plist-put state :materialize-content-lines + (lambda (&rest args) + (cl-incf materialize-count) + (apply materialize args)))) + (ebox--runtime-prewarm-drain buffer) + (let ((render-state (ebox--buffer-render-state buffer)) + (warmed (ebox-scroll-state root-id))) + (should + (plist-get render-state :layout-snapshots-complete-p)) + (should + (plist-get render-state :viewport-dependent-node-ids-ready)) + (should + (equal (plist-get render-state :viewport-dependent-node-ids) + expected-viewport-ids)) + (should (plist-get warmed :region-line-span-index)) + (should (plist-get warmed :rendered-region-line-span-index)) + (should (= (length (plist-get warmed :content-lines)) + before-lines)) + (should (= materialize-count 0))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-does-not-wait-for-prefix-prefetch () + "Prewarm should index current scroll lines while lazy prefix work is active." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :height 2 :overflow scroll + (column + (box :id "target" :content "target" :height 1) + (box :id "row-1" :content "row 1" :height 1) + (box :id "row-2" :content "row 2" :height 1))))) + (buffer nil) + root-id) + (unwind-protect + (let ((ebox-runtime-idle-prewarm-slice-size 10000) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (puthash root-id t ebox--scroll-idle-prefetch-timers) + (ebox--runtime-prewarm-drain buffer) + (let ((state (ebox-scroll-state root-id))) + (should (plist-get state :region-line-span-index)) + (should (plist-get state :rendered-region-line-span-index)))) + (remhash root-id ebox--scroll-idle-prefetch-timers) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-takes-priority-over-prefix-prefetch () + "Lazy prefix warming should yield while shared runtime prewarming is active." + (ebox-test--reset-runtime-state) + (let ((buffer nil) + root-id + (prefix-render-count 0) + exact-prefix + scheduled-delay) + (unwind-protect + (let ((ebox-scroll-lazy-idle-prefetch-lines 8) + (ebox--scroll-allow-noninteractive-prefetch t) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") + (ebox-build + '(box :id "root" :height 2 :overflow scroll + (column + (box :content "row 0" :height 1) + (box :content "row 1" :height 1)))))) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let* ((state (ebox-scroll-state root-id)) + (job (gethash buffer ebox--runtime-prewarm-jobs))) + (setq state (plist-put state :render-content-prefix t)) + (setq state (plist-put state :content-lines-complete-p nil)) + (setq state (plist-put state :buffer buffer)) + (puthash root-id state ebox--scroll-global-state) + (cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines) + (lambda (&rest _args) + (cl-incf prefix-render-count) + state)) + ((symbol-function 'ebox--scroll-schedule-idle-prefetch) + (lambda (_region-id &optional delay) + (setq scheduled-delay delay)))) + (ebox--scroll-idle-prefetch root-id)) + (should (= prefix-render-count 0)) + (should (eq (gethash buffer ebox--runtime-prewarm-jobs) job)) + (should (equal scheduled-delay + ebox-runtime-idle-prewarm-prefix-resume-delay)))) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-does-not-delay-cache-miss-prefetch () + "Urgent cache-miss prefetch should bypass shared prewarm priority." + (ebox-test--reset-runtime-state) + (let ((buffer nil) + root-id + (prefix-render-count 0) + scheduled-delay) + (unwind-protect + (let ((ebox-scroll-lazy-idle-prefetch-lines 8) + (ebox--scroll-allow-noninteractive-prefetch t) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") + (ebox-build + '(box :id "root" :height 2 :overflow scroll + (column + (box :content "row 0" :height 1) + (box :content "row 1" :height 1)))))) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (let ((state (ebox-scroll-state root-id))) + (setq state (plist-put state :render-content-prefix t)) + (setq state (plist-put state :content-lines-complete-p nil)) + (setq state (plist-put state :cache-miss-prefetch-target-lines 8)) + (setq state (plist-put state :buffer buffer)) + (puthash root-id state ebox--scroll-global-state) + (cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines) + (lambda (_region-id input-state target-lines + &optional exact) + (cl-incf prefix-render-count) + (setq exact-prefix exact) + (setq input-state + (plist-put input-state :content-lines + (make-list target-lines + (ebox-pixel-space 1)))) + (plist-put input-state :content-lines-complete-p t))) + ((symbol-function 'ebox--scroll-schedule-idle-prefetch) + (lambda (_region-id &optional delay) + (setq scheduled-delay delay))) + ((symbol-function 'ebox--schedule-buffer-runtime-prewarm) + (lambda (&rest _args) nil))) + (ebox--scroll-idle-prefetch root-id))) + (should (= prefix-render-count 1)) + (should exact-prefix) + (should-not (equal scheduled-delay + ebox-runtime-idle-prewarm-prefix-resume-delay))) + (ebox--scroll-cancel-idle-prefetch root-id) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-idle-prefix-slices-do-not-restart-runtime-prewarm () + "Consecutive prefix slices should not starve behind repeated tree prewarms." + (ebox-test--reset-runtime-state) + (let* ((buffer (generate-new-buffer " *ebox-prefix-slice-test*")) + (region-id "prefix-slice-scroll") + (state (list :buffer buffer + :scroll-offset 0 + :content-height 2 + :content-lines '("row 0" "row 1") + :render-content-prefix t + :content-lines-complete-p nil)) + (runtime-prewarm-count 0) + (prefix-reschedule-count 0)) + (unwind-protect + (let ((ebox-scroll-lazy-idle-prefetch-lines 8) + (ebox-scroll-lazy-idle-prefetch-slice-lines 1)) + (puthash region-id state ebox--scroll-global-state) + (cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines) + (lambda (_region-id input-state _target-lines + &optional _exact) + (plist-put input-state :content-lines + (append (plist-get input-state :content-lines) + '("row 2"))) + (puthash region-id input-state + ebox--scroll-global-state) + input-state)) + ((symbol-function 'ebox--scroll-schedule-idle-prefetch) + (lambda (&rest _args) + (cl-incf prefix-reschedule-count))) + ((symbol-function 'ebox--schedule-buffer-runtime-prewarm) + (lambda (&rest _args) + (cl-incf runtime-prewarm-count)))) + (ebox--scroll-idle-prefetch region-id)) + (should (= prefix-reschedule-count 1)) + (should (= runtime-prewarm-count 0))) + (remhash region-id ebox--scroll-global-state) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-yields-between-slices () + "Each prewarm slice should leave an idle interval for redisplay." + (ebox-test--reset-runtime-state) + (let ((buffer nil) + scheduled-delay) + (unwind-protect + (let ((ebox-runtime-idle-prewarm-delay 0.1) + (ebox-runtime-idle-prewarm-slice-size 1) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") + (ebox-build + '(box :height 2 :overflow scroll + (column + (box :content "row 0" :height 1) + (box :content "row 1" :height 1) + (box :content "row 2" :height 1)))))) + (cl-letf (((symbol-function 'current-idle-time) + (lambda () (seconds-to-time 0.4))) + ((symbol-function 'ebox--runtime-prewarm-schedule-timer) + (lambda (_buffer &optional delay) + (setq scheduled-delay delay)))) + (ebox--runtime-prewarm-timer buffer)) + (should (equal scheduled-delay 0.5))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-removes-first-scroll-style-index-build () + "A prewarmed runtime should reuse scroll indexes on its first style update." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) :height 2 :overflow scroll + (column + (box :id "target" :content "old" :height 1) + (box :id "head-1" :content "head 1" :height 1) + (box :id "head-2" :content "head 2" :height 1) + (box :id "head-3" :content "head 3" :height 1))))) + (buffer nil) + root-id target-id + (build-count 0) + (scan-count 0) + (full-snapshot-refresh-count 0)) + (unwind-protect + (let ((ebox-viewport-width 900) + (ebox-scroll-lazy-idle-prefetch-lines 0) + (ebox-runtime-idle-prewarm-slice-size 10000) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (setq target-id (ebox-test--selector-region-id buffer "#target")) + (ebox--runtime-prewarm-drain buffer) + (let ((original-build + (symbol-function 'ebox--scroll-build-region-line-span-index)) + (original-scan + (symbol-function 'ebox--scroll-state-region-line-spans)) + (original-refresh + (symbol-function 'ebox--refresh-buffer-layout-snapshots))) + (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) + (lambda (&rest args) + (cl-incf build-count) + (apply original-build args))) + ((symbol-function 'ebox--scroll-state-region-line-spans) + (lambda (&rest args) + (cl-incf scan-count) + (apply original-scan args))) + ((symbol-function 'ebox--refresh-buffer-layout-snapshots) + (lambda (&rest args) + (cl-incf full-snapshot-refresh-count) + (apply original-refresh args)))) + (with-current-buffer buffer + (ebox-region-update target-id :content "new")))) + (should (= build-count 0)) + (should (= scan-count 0)) + (should (= full-snapshot-refresh-count 0)) + (let ((state (ebox-scroll-state root-id))) + (should-not (plist-get state :region-line-span-index)) + (should (plist-get state :region-line-span-index-deferred))) + (should (gethash buffer ebox--runtime-prewarm-jobs)) + (should (plist-get (ebox-test--buffer-update-report) + :scroll-state-index))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-discards-stale-runtime-revisions () + "A runtime mutation should invalidate an older sliced prewarm job." + (ebox-test--reset-runtime-state) + (let ((buffer nil)) + (unwind-protect + (let ((ebox-runtime-idle-prewarm-slice-size 1) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") + (ebox-build + '(column + (box :id "a" :content "A") + (box :id "b" :content "B"))))) + (should (gethash buffer ebox--runtime-prewarm-jobs)) + (ebox--bump-buffer-runtime-revision buffer) + (should-not (ebox--runtime-prewarm-step buffer)) + (should-not (gethash buffer ebox--runtime-prewarm-jobs))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-cancels-before-update-errors () + "A failing mutation should not leave an old prewarm job publishable." + (ebox-test--reset-runtime-state) + (let ((buffer nil)) + (unwind-protect + (let ((ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") + (ebox-build '(box :id "target" :content "old")))) + (should (gethash buffer ebox--runtime-prewarm-jobs)) + (let ((target-id + (ebox-test--selector-region-id buffer "#target"))) + (should-error + (cl-letf (((symbol-function 'ebox-incremental-record-region-change) + (lambda (&rest _args) + (error "forced update failure")))) + (with-current-buffer buffer + (ebox-region-update target-id :color "#DC2626"))))) + (should-not (gethash buffer ebox--runtime-prewarm-jobs)) + (should-not (gethash buffer ebox--runtime-prewarm-timers))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-cancels-before-rerender-errors () + "A failing viewport rerender should discard old prewarm work." + (ebox-test--reset-runtime-state) + (let ((buffer nil)) + (unwind-protect + (let ((ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") + (ebox-build '(box :id "target" :content "old")))) + (should (gethash buffer ebox--runtime-prewarm-jobs)) + (should-error + (cl-letf (((symbol-function + 'ebox-incremental-rerender-buffer-with-context) + (lambda (&rest _args) + (error "forced rerender failure")))) + (ebox-rerender-buffer-with-context buffer 360))) + (should-not (gethash buffer ebox--runtime-prewarm-jobs)) + (should-not (gethash buffer ebox--runtime-prewarm-timers))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-reschedules-after-batched-updates () + "A successful explicit batch should schedule shared-state prewarming." + (ebox-test--reset-runtime-state) + (let ((buffer nil)) + (unwind-protect + (let ((ebox-runtime-idle-prewarm-slice-size 10000) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") + (ebox-build + '(column + (box :id "target" :content "old") + (box :id "sibling" :content "same"))))) + (ebox--runtime-prewarm-drain buffer) + (should-not (gethash buffer ebox--runtime-prewarm-jobs)) + (let ((target-id + (ebox-test--selector-region-id buffer "#target"))) + (ebox-incremental-begin-batch buffer) + (with-current-buffer buffer + (ebox-region-update target-id :color "#DC2626") + (ebox-incremental-flush buffer))) + (let ((job (gethash buffer ebox--runtime-prewarm-jobs)) + (state (ebox--buffer-render-state buffer))) + (should job) + (should (= (plist-get job :runtime-revision) + (plist-get state :runtime-revision))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-cancels-during-open-batch () + "An unflushed batch mutation should cancel stale shared-state prewarming." + (ebox-test--reset-runtime-state) + (let ((buffer nil)) + (unwind-protect + (let ((ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") + (ebox-build '(box :id "target" :content "old")))) + (should (gethash buffer ebox--runtime-prewarm-jobs)) + (let ((target-id + (ebox-test--selector-region-id buffer "#target"))) + (ebox-incremental-begin-batch buffer) + (with-current-buffer buffer + (ebox-region-update target-id :padding-left 9))) + (should (= (ebox-incremental-pending-count buffer) 1)) + (should-not (gethash buffer ebox--runtime-prewarm-jobs)) + (should-not (gethash buffer ebox--runtime-prewarm-timers))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-avoids-unbounded-tree-scans () + "Scheduling and draining prewarm should not scan all nodes in one callback." + (ebox-test--reset-runtime-state) + (let ((buffer nil) + (node-table-scan-count 0) + (viewport-scan-count 0)) + (unwind-protect + (let ((ebox--runtime-prewarm-allow-noninteractive t) + (ebox-runtime-idle-prewarm-slice-size 1)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") + (ebox-build + '(column + (box :content "row 0") + (box :content "row 1") + (box :content "row 2"))))) + (ebox--cancel-buffer-runtime-prewarm buffer) + (let ((node-table (ebox--buffer-node-table buffer)) + (original-maphash (symbol-function 'maphash))) + (cl-letf (((symbol-function 'maphash) + (lambda (function table) + (when (eq table node-table) + (cl-incf node-table-scan-count)) + (funcall original-maphash function table)))) + (ebox--schedule-buffer-runtime-prewarm buffer))) + (let ((original-viewport + (symbol-function + 'ebox--buffer-viewport-dependent-node-ids))) + (cl-letf + (((symbol-function 'ebox--buffer-viewport-dependent-node-ids) + (lambda (&rest args) + (cl-incf viewport-scan-count) + (apply original-viewport args)))) + (ebox--runtime-prewarm-drain buffer))) + (should (= node-table-scan-count 0)) + (should (= viewport-scan-count 0))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-preserves-viewport-dependency-semantics () + "Sliced dependency discovery should match the canonical tree algorithm." + (ebox-test--reset-runtime-state) + (let ((buffer nil)) + (unwind-protect + (let ((ebox-viewport-width 360) + (ebox-viewport-height 12) + (ebox-runtime-idle-prewarm-slice-size 1) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") + (ebox-build + '(box :width (210) + (column + (box :id "width-only" :width (viewport)) + (box :id "height-only" + :height (viewport-height)) + (flex :id "flex-height" + :min-height (- (viewport-height) 1) + (box :content "Flex"))))))) + (let* ((state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (expected-axes + (let ((ebox--viewport-dependent-node-ids-cache + (make-hash-table :test 'eq)) + (ebox--viewport-height-dependent-subtree-cache + (make-hash-table :test 'eq))) + (ebox--viewport-dependent-node-id-axes root))) + (expected + (delete-dups + (copy-sequence + (append (car expected-axes) (cdr expected-axes))))) + (width-only-node-id + (plist-get + (car (ebox-selector-query-buffer buffer "#width-only")) + :node-id)) + (height-only-node-id + (plist-get + (car (ebox-selector-query-buffer buffer "#height-only")) + :node-id))) + (ebox--runtime-prewarm-drain buffer) + (should (equal (plist-get state :viewport-dependent-node-id-axes) + expected-axes)) + (should (equal (plist-get state :viewport-dependent-node-ids) + expected)) + (pcase-let ((`(,width-ids . ,height-ids) + (plist-get state + :viewport-dependent-node-id-axes))) + (should-not (member width-only-node-id width-ids)) + (should-not (member width-only-node-id height-ids)) + (should-not (member height-only-node-id width-ids)) + (should (member height-only-node-id height-ids))) + (should (plist-get state :viewport-dependent-node-ids-ready)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-runtime-idle-prewarm-scroll-slices-use-list-cursors () + "Late cached-line slices should not restart traversal from the list head." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :height 2 :overflow scroll + (column + ,@(cl-loop for idx below 40 + collect + `(box :content ,(format "row %02d" idx) + :height 1)))))) + (buffer nil) + (nth-count 0) + (original-nth (symbol-function 'nth))) + (unwind-protect + (let ((ebox-scroll-lazy-idle-prefetch-lines 0) + (ebox-runtime-idle-prewarm-slice-size 3) + (ebox--runtime-prewarm-allow-noninteractive t)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-test*") layout)) + (cl-letf (((symbol-function 'nth) + (lambda (&rest args) + (cl-incf nth-count) + (apply original-nth args)))) + (ebox--runtime-prewarm-drain buffer)) + (should (= nth-count 0))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-owner-rerender-defers-scroll-content-index () + "Owner rerender should not rebuild full hidden scroll content indexes." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + `(box :id "root" :width (viewport) :height 2 + :overflow scroll + (column + ,@(cl-loop for idx below 24 + collect + `(box :content + ,(format "row %02d alpha beta gamma" + idx) + :wrap-mode word)))))) + (buffer nil) + (build-count 0) + (original (symbol-function 'ebox--scroll-build-region-line-span-index))) + (unwind-protect + (progn + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout))) + (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) + (lambda (&rest args) + (cl-incf build-count) + (apply original args)))) + (ebox-rerender-buffer-with-context buffer 160)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (= build-count 0)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-root-owner-viewport-rerender-uses-lightweight-scroll-window () + "Root viewport rerender should defer broad indexes and keep scrolling." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height 3 :overflow scroll + (column + ,@(cl-loop for idx below 16 + collect + `(box :id ,(format "row-%02d" idx) + :content + ,(format "row %02d alpha beta gamma" idx) + :wrap-mode word)))))) + (buffer nil) + root-id + (hidden-prewarm-region-id "hidden-prewarm-scroll-state") + (full-marker-refreshes 0) + (full-role-refreshes 0) + (full-property-scans 0) + (original-refresh + (symbol-function 'ebox--refresh-buffer-scroll-content-markers)) + (original-role-refresh + (symbol-function 'ebox-buffer-refresh-region-role-spans)) + (original-property-scan + (symbol-function + 'ebox--refresh-box-extents-for-region-ids-in-spans))) + (unwind-protect + (progn + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") layout))) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (puthash hidden-prewarm-region-id t + (ebox--buffer-region-id-set buffer)) + (puthash hidden-prewarm-region-id + (list :box (ebox-create :content "hidden" :height 2 + :overflow 'scroll) + :content-height 2) + ebox--scroll-global-state) + (cl-letf (((symbol-function + 'ebox--refresh-buffer-scroll-content-markers) + (lambda (&rest args) + (cl-incf full-marker-refreshes) + (apply original-refresh args))) + ((symbol-function + 'ebox-buffer-refresh-region-role-spans) + (lambda (&rest args) + (cl-incf full-role-refreshes) + (apply original-role-refresh args))) + ((symbol-function + 'ebox--refresh-box-extents-for-region-ids-in-spans) + (lambda (&rest args) + (cl-incf full-property-scans) + (apply original-property-scan args)))) + (ebox-rerender-buffer-with-context buffer 160) + (ebox-rerender-buffer-with-context buffer 200)) + (should (= full-marker-refreshes 0)) + (should (= full-role-refreshes 0)) + (should (= full-property-scans 0)) + (should-not (ebox--buffer-region-role-span-table buffer)) + (let ((state (ebox--scroll-get-state root-id))) + (should-not (plist-get state :content-span-markers)) + (should (plist-get state :rendered-window-start-marker)) + (should (plist-get state :rendered-window-end-marker))) + (with-current-buffer buffer + (ebox-region-update root-id :scroll-offset 1) + (should (plist-get (ebox-test--buffer-update-report) + :scroll-fast-path))) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (cl-letf (((symbol-function + 'ebox-buffer-refresh-region-role-spans) + (lambda (&rest args) + (cl-incf full-role-refreshes) + (apply original-role-refresh args)))) + (with-current-buffer buffer + (ebox-region-update root-id :bgcolor "#223344"))) + (should (= full-role-refreshes 1)) + (should (hash-table-p + (ebox--buffer-region-role-span-table buffer))) + (with-current-buffer buffer + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer)) + (remhash hidden-prewarm-region-id ebox--scroll-global-state)))) + +(ert-deftest ebox-root-owner-chromed-scroll-rerender-uses-prepared-indexes () + "A visible chromed scroll box should install prepared complete indexes." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height 3 :overflow scroll + :border-top-p t + (column + ,@(cl-loop for idx below 8 + collect + `(box :content ,(format "row %02d" idx))))))) + (buffer nil) + root-id + (full-marker-refreshes 0) + (original-refresh + (symbol-function 'ebox--refresh-buffer-scroll-content-markers))) + (unwind-protect + (progn + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") layout))) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (cl-letf (((symbol-function + 'ebox--refresh-buffer-scroll-content-markers) + (lambda (&rest args) + (cl-incf full-marker-refreshes) + (apply original-refresh args)))) + (ebox-rerender-buffer-with-context buffer 160)) + (should (= full-marker-refreshes 0)) + (should + (gethash + ebox-buffer--role-span-table-template-key + (ebox--buffer-region-role-span-table buffer))) + (should (vectorp + (plist-get (ebox--scroll-get-state root-id) + :content-span-markers))) + (with-current-buffer buffer + (should (ebox-test--buffer-visually-matches-runtime-render-p)))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-root-owner-rerender-publishes-property-aware-patches () + "A root owner rerender should publish only its property-aware delta." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :width (viewport) + :content "responsive root\nsecond line\nthird line"))) + (buffer nil) + (owner-set-scans 0) + (root-patch-publications 0) + (original-owner-set + (symbol-function 'ebox--string-content-owner-set)) + (original-replace + (symbol-function + 'ebox-buffer--replace-root-with-property-patches))) + (unwind-protect + (progn + (let ((ebox-viewport-width 240)) + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") layout))) + (cl-letf (((symbol-function 'ebox--string-content-owner-set) + (lambda (&rest args) + (cl-incf owner-set-scans) + (apply original-owner-set args))) + ((symbol-function + 'ebox-buffer--replace-root-with-property-patches) + (lambda (spans rendered) + (cl-incf root-patch-publications) + (funcall original-replace spans rendered)))) + (ebox-rerender-buffer-with-context buffer 160)) + (should (= owner-set-scans 0)) + (should (= root-patch-publications 1)) + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'owner-rerender)) + (should (eq (plist-get (ebox-test--buffer-update-report) + :publication-scope) + 'minimal-spans)) + (should (plist-get (ebox-test--buffer-update-report) + :published-buffer-spans)) + (let ((expected (let ((ebox-viewport-width 160)) + (ebox-render layout)))) + (with-current-buffer buffer + (should (equal-including-properties + (buffer-substring (point-min) (point-max)) + expected))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-span-patch-defers-scroll-content-index () + "Span patches should not rebuild full hidden scroll content indexes." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + `(box :id "root" :width 160 :height 2 + :overflow scroll + (column + ,@(cl-loop for idx below 24 + collect + `(box :content + ,(format "row %02d alpha beta gamma" + idx) + :wrap-mode word)))))) + (buffer nil) + root-id + (build-count 0) + (original (symbol-function 'ebox--scroll-build-region-line-span-index))) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + layout)) + (setq root-id (ebox-test--selector-region-id buffer "#root")) + (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) + (lambda (&rest args) + (cl-incf build-count) + (apply original args)))) + (should (ebox-buffer-apply-span-patch + buffer + (list :owner-id root-id + :dirty (list :changed-keys '(:width)))))) + (should (= build-count 0))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + +(ert-deftest ebox-hidden-scroll-child-content-update-patches-scroll-state () + "Hidden scroll children should update cached scroll lines without rerendering." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 1 :overflow scroll + (column + (box :content "visible" :height 1) + (box :id "target" :content "old" :height 1) + (box :content "tail" :height 1))))) + root-id target-id) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (setq target-id (ebox-test--selector-region-id (current-buffer) + "#target")) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "visible" plain)) + (should-not (string-match-p "old" plain))) + (let ((owner-rerenders 0)) + (cl-letf (((symbol-function 'ebox-buffer-apply-owner-rerender) + (lambda (&rest _args) + (cl-incf owner-rerenders) + nil))) + (ebox-region-update target-id :content "new") + (should (= owner-rerenders 0)))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :scroll-state-patch)) + (should-not (plist-get report :full-rerender))) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "visible" plain)) + (should-not (string-match-p "new" plain))) + (should (ebox--scroll-region-by root-id 1)) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "new" plain)) + (should-not (string-match-p "old" plain)))))) + +(ert-deftest ebox-visible-lazy-scroll-child-update-does-not-defer () + "Lazy scroll children in the current visible range should patch immediately." + (ebox-test--reset-runtime-state) + (let* ((children (cl-loop for idx below 40 + collect `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1))) + (layout (ebox-build + `(box :id "root" :height 2 :overflow scroll + (column ,@children)))) + root-id target-id) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (should (ebox--scroll-region-by root-id 30)) + (setq target-id (ebox-test--selector-region-id (current-buffer) + "#row-30")) + (should target-id) + (should (string-match-p "row 30" + (buffer-substring-no-properties + (point-min) (point-max)))) + (cl-letf (((symbol-function 'ebox--region-ids-visible-in-buffer-p) + (lambda (&rest _args) nil))) + (ebox-region-update target-id :content "new row 30")) + (let ((report (ebox-test--buffer-update-report))) + (should (memq (plist-get report :strategy) + '(span-patch owner-rerender root-rerender))) + (should-not (plist-get report :lazy-scroll-deferred-patch))) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "new row 30" plain)))))) + +(ert-deftest ebox-visible-empty-scroll-child-content-update-settles-window () + "An empty fixed box in a visible scene must not be mistaken for hidden." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + `(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + (flex :id "scene" :width (viewport) :height 4 + :align-items center :justify-content center + (box :id "target" :content "SEED" :width (180) :height 3 + :overflow hidden :bgcolor "#DDEAE4")) + ,@(cl-loop for index below 40 + collect + `(box :content ,(format "row %02d" index) + :height 1)))))) + (ebox-viewport-width 300) + (ebox-viewport-height 4) + (ebox-scroll-lazy-prefix-lookahead-lines 12) + root-id target-id) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root") + target-id (ebox-test--selector-region-id + (current-buffer) "#target")) + (should (plist-get (ebox-scroll-state root-id) + :render-content-prefix)) + (ebox-region-update target-id :content "") + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :scroll-state-patch)) + (should-not + (member (ebox--buffer-root-node-id (current-buffer)) + (plist-get report :owner-ids)))) + (ebox-region-update target-id :content "NOW VISIBLE") + (let ((report (ebox-test--buffer-update-report)) + (state (ebox-scroll-state root-id))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :scroll-state-patch)) + (should-not + (member (ebox--buffer-root-node-id (current-buffer)) + (plist-get report :owner-ids))) + (should-not (plist-get report :lazy-scroll-deferred-patch)) + (should-not (plist-get state :lazy-scroll-prefix-dirty)) + (should-not (plist-get state + :lazy-scroll-window-refresh-required))) + (should (string-match-p + "NOW VISIBLE" + (buffer-substring-no-properties (point-min) (point-max)))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-lazy-scroll-ignores-updates-to-sibling-status-box () + "A sibling update must not materialize an unrelated lazy scroll state." + (ebox-test--reset-runtime-state) + (let* ((rows (cl-loop for index below 40 + collect `(box :id ,(format "row-%02d" index) + :content ,(format "row %02d" index) + :height 1))) + (layout + (ebox-build + `(column :width (viewport) + (box :id "rail" :content "ready" :width (viewport) + :height 1) + (box :id "root" :width (viewport) + :height (viewport-height) :overflow scroll + (column ,@rows))))) + (ebox-viewport-width 240) + (ebox-viewport-height 3) + (ebox-scroll-lazy-prefix-lookahead-lines 0) + rail-id root-id materialize-count) + (ebox-test--with-rendered-buffer layout + (setq rail-id (ebox-test--selector-region-id + (current-buffer) "#rail") + root-id (ebox-test--selector-region-id + (current-buffer) "#root")) + (let* ((state (ebox-scroll-state root-id)) + (materialize (plist-get state :materialize-content-lines))) + (should materialize) + (should-not (plist-get state :content-lines-complete-p)) + (should (plist-get state :rendered-content-lines)) + (plist-put + state :materialize-content-lines + (lambda (&rest arguments) + (cl-incf materialize-count) + (apply materialize arguments))) + (puthash root-id state ebox--scroll-global-state)) + (ebox-region-update rail-id :content "updated") + (should (= (or materialize-count 0) 0)) + (let ((state (ebox-scroll-state root-id))) + (should-not (plist-get state :content-lines-complete-p)) + (should (plist-get state :rendered-content-lines))) + (should (string-match-p + "updated" + (buffer-substring-no-properties (point-min) (point-max))))))) + +(ert-deftest ebox-visible-scroll-marker-window-detects-owner-stack () + "Visible marker checks should recognize owner-stack-only regions." + (ebox-test--reset-runtime-state) + (with-temp-buffer + (let* ((region-id 4242) + (line (propertize "owned visible line" + 'ebox-content-owners (list region-id))) + start end state) + (insert line) + (setq start (copy-marker (point-min) nil)) + (setq end (copy-marker (point-max) t)) + (setq state (list :content-span-markers + (vector (cons start end)))) + (should (ebox--scroll-state-region-visible-in-window-p + state (list region-id))) + (set-marker start nil) + (set-marker end nil)))) + +(ert-deftest ebox-scroll-marker-window-visible-set-is-authoritative () + "A cached visible region set should avoid marker text fallback scans." + (ebox-test--reset-runtime-state) + (with-temp-buffer + (let* ((region-id 4343) + (line (propertize "owned visible line" + 'ebox-content-owners (list region-id))) + (empty-visible-set (make-hash-table :test 'equal)) + start end state + (scans 0)) + (insert line) + (setq start (copy-marker (point-min) nil)) + (setq end (copy-marker (point-max) t)) + (setq state (list :content-window-region-id-set empty-visible-set + :content-span-markers (vector (cons start end)))) + (let ((original (symbol-function 'ebox--pos-region-ids))) + (cl-letf (((symbol-function 'ebox--pos-region-ids) + (lambda (&rest args) + (cl-incf scans) + (apply original args)))) + (should-not (ebox--scroll-state-region-visible-in-window-p + state (list region-id))))) + (should (= scans 0)) + (set-marker start nil) + (set-marker end nil)))) + +(ert-deftest ebox-scroll-updates-rendered-window-extents-when-window-moves () + "Scrolling should keep lightweight rendered window extents current." + (ebox-test--reset-runtime-state) + (let* ((children (cl-loop for idx below 20 + collect `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1))) + (layout (ebox-build + `(box :id "root" :height 2 :overflow scroll + (column ,@children)))) + root-id) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (should (plist-get (ebox-scroll-state root-id) + :content-window-region-id-set)) + (should (ebox--scroll-region-by root-id 1)) + (let ((state (ebox-scroll-state root-id))) + (should (plist-get state :rendered-window-start-marker)) + (should (plist-get state :rendered-window-end-marker)) + (should-not (plist-get state :content-span-markers)) + (should-not (plist-get state :content-window-region-id-set)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) + +(ert-deftest ebox-scroll-reveal-indexes-visible-child-paint () + "A child revealed by scrolling should paint the live buffer immediately." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id "root" :width (180) :height 2 :overflow scroll + (column + (box :id "row-0" :content "row 0" :height 1) + (box :id "row-1" :content "row 1" :height 1) + (box :id "row-2" :content "row 2" :height 1) + (box :id "target" :content "target" :height 1) + (box :id "row-4" :content "row 4" :height 1))))) + root-id target-id) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (setq target-id + (ebox-test--selector-region-id (current-buffer) "#target")) + (should (ebox--scroll-region-by root-id 3 48)) + (ebox-region-update target-id :color "#abcdef") + (should (ebox--region-find target-id 'content)) + (let* ((report (ebox-test--buffer-update-report)) + (span (car (ebox--region-find target-id 'content))) + (face (and span (get-text-property (car span) 'face)))) + (should (eq (plist-get report :strategy) 'paint-patch)) + (should (> (or (plist-get report :span-count) 0) 0)) + (should (ebox-test--face-has-key-value-p + face :foreground "#abcdef"))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (should (ebox--scroll-region-by root-id -3 48)) + (should (ebox--scroll-region-by root-id 3 48)) + (let* ((span (car (ebox--region-find target-id 'content))) + (face (and span (get-text-property (car span) 'face)))) + (should (ebox-test--face-has-key-value-p + face :foreground "#abcdef"))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-scroll-reveal-keeps-visible-geometry-indexed () + "Geometry updates on a revealed child should not fall back to broad scans." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id "root" :width (180) :height 2 :overflow scroll + (column + (box :content "row 0" :height 1) + (box :content "row 1" :height 1) + (box :content "row 2" :height 1) + (box :id "target" :content "target" :width (120) :height 1 + :box-sizing border-box :padding-left (2)))))) + root-id target-id + (span-scans 0) + (full-extent-refreshes 0)) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (setq target-id + (ebox-test--selector-region-id (current-buffer) "#target")) + (should (ebox--scroll-region-by root-id 2 48)) + (let ((original-scan + (symbol-function 'ebox--region-set-line-spans-by-scan)) + (original-extents + (symbol-function 'ebox--refresh-buffer-box-extents))) + (cl-letf (((symbol-function 'ebox--region-set-line-spans-by-scan) + (lambda (&rest args) + (cl-incf span-scans) + (apply original-scan args))) + ((symbol-function 'ebox--refresh-buffer-box-extents) + (lambda (&rest args) + (cl-incf full-extent-refreshes) + (apply original-extents args)))) + (ebox-region-update target-id :padding-left (list 12)))) + (should (= span-scans 0)) + (should (= full-extent-refreshes 0)) + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'span-patch)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-scroll-role-markers-do-not-paint-a-displaced-row () + "Scroll-displaced marker spans should not paint their replacement row." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id "root" :height 3 :overflow scroll + (column + (box :content "row 0" :height 1) + (box :content "row 1" :height 1) + (box :id "target" :content "target" :height 1) + (box :id "row-3" :content "row 3" :height 1) + (box :content "row 4" :height 1))))) + root-id target-id row-3-id + (full-role-refreshes 0) + (property-scans 0)) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (setq target-id + (ebox-test--selector-region-id (current-buffer) "#target")) + (setq row-3-id + (ebox-test--selector-region-id (current-buffer) "#row-3")) + (let ((stale-role-table (ebox-buffer--region-role-span-table))) + (should (hash-table-p stale-role-table)) + (should (ebox--scroll-region-by root-id 1 48)) + (should-not (ebox-buffer--region-role-span-table)) + (ebox--set-buffer-region-role-span-table + (current-buffer) stale-role-table) + (should (eq (ebox--region-ids-role-table-status + stale-role-table (list target-id)) + 'stale)) + (should-not + (ebox--region-ids-visible-via-role-table-p (list target-id))) + (let ((original-refresh + (symbol-function 'ebox-buffer-refresh-region-role-spans)) + (original-scan + (symbol-function 'ebox--region-ids-visible-by-property-scan-p))) + (cl-letf (((symbol-function 'ebox-buffer-refresh-region-role-spans) + (lambda (&rest args) + (cl-incf full-role-refreshes) + (apply original-refresh args))) + ((symbol-function + 'ebox--region-ids-visible-by-property-scan-p) + (lambda (&rest args) + (cl-incf property-scans) + (apply original-scan args)))) + (ebox-region-update target-id :color "#abcdef")))) + (let* ((target-pos + (text-property-any + (point-min) (point-max) 'ebox-content target-id)) + (row-3-pos + (text-property-any + (point-min) (point-max) 'ebox-content row-3-id)) + (target-face + (and target-pos (get-text-property target-pos 'face))) + (row-3-face + (and row-3-pos (get-text-property row-3-pos 'face)))) + (should (= full-role-refreshes 1)) + (should (= property-scans 0)) + (should (ebox-test--face-has-key-value-p + target-face :foreground "#abcdef")) + (should-not (ebox-test--face-has-key-value-p + row-3-face :foreground "#abcdef")))))) + +(ert-deftest ebox-partially-stale-role-spans-rebuild-before-paint () + "A current target span must not hide a stale span on a neighboring line." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(column :id "root" :width (180) + (box :id "target" :content "target" :height 1) + (box :id "neighbor" :content "neighbor" :height 1)))) + (full-role-refreshes 0) + (property-scans 0)) + (ebox-test--with-rendered-buffer layout + (let* ((target-id + (ebox-test--selector-region-id (current-buffer) "#target")) + (neighbor-id + (ebox-test--selector-region-id (current-buffer) "#neighbor")) + (table (ebox-buffer--region-role-span-table)) + (target-key + (ebox-buffer--region-role-key target-id 'content)) + (neighbor-key + (ebox-buffer--region-role-key neighbor-id 'content)) + (valid-span (car (gethash target-key table))) + (displaced-span (car (gethash neighbor-key table)))) + (should valid-span) + (should displaced-span) + (should-not + (= (line-number-at-pos (marker-position (car valid-span))) + (line-number-at-pos (marker-position (car displaced-span))))) + (puthash target-key (list valid-span displaced-span) table) + (should (ebox-buffer--current-region-role-span-p + valid-span target-id 'content)) + (should-not (ebox-buffer--current-region-role-span-p + displaced-span target-id 'content)) + (should (ebox--region-ids-visible-via-role-table-p (list target-id))) + (should (eq (ebox--region-ids-role-table-status + table (list target-id)) + 'stale)) + (let ((original-refresh + (symbol-function 'ebox-buffer-refresh-region-role-spans)) + (original-scan + (symbol-function 'ebox--region-ids-visible-by-property-scan-p))) + (cl-letf (((symbol-function 'ebox-buffer-refresh-region-role-spans) + (lambda (&rest args) + (cl-incf full-role-refreshes) + (apply original-refresh args))) + ((symbol-function + 'ebox--region-ids-visible-by-property-scan-p) + (lambda (&rest args) + (cl-incf property-scans) + (apply original-scan args)))) + (ebox-region-update target-id :color "#abcdef"))) + (let* ((target-pos + (text-property-any + (point-min) (point-max) 'ebox-content target-id)) + (neighbor-pos + (text-property-any + (point-min) (point-max) 'ebox-content neighbor-id)) + (target-face + (and target-pos (get-text-property target-pos 'face))) + (neighbor-face + (and neighbor-pos (get-text-property neighbor-pos 'face)))) + (should (= full-role-refreshes 1)) + (should (= property-scans 0)) + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'paint-patch)) + (should (ebox-test--face-has-key-value-p + target-face :foreground "#abcdef")) + (should-not (ebox-test--face-has-key-value-p + neighbor-face :foreground "#abcdef"))))))) + +(ert-deftest ebox-scroll-hidden-update-keeps-the-visible-window () + "Updating a scrolled-away node should not reuse its stale buffer spans." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id "root" :width (180) :height 2 :overflow scroll + (column + (box :id "target" :content "target" :width (120) :height 1 + :box-sizing border-box :padding-left (2)) + (box :content "row 1" :height 1) + (box :content "row 2" :height 1) + (box :content "row 3" :height 1) + (box :content "row 4" :height 1))))) + root-id target-id) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (setq target-id + (ebox-test--selector-region-id (current-buffer) "#target")) + (should (ebox--scroll-region-by root-id 3 48)) + (let ((visible-before + (buffer-substring-no-properties (point-min) (point-max)))) + (ebox-region-update target-id :padding-left (list 12)) + (should (equal visible-before + (buffer-substring-no-properties + (point-min) (point-max))))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :scroll-state-patch)) + (should (= (plist-get report :patch-count) 1))) + (should-not + (text-property-any + (point-min) (point-max) 'ebox-content target-id)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (let ((visible-before + (buffer-substring-no-properties (point-min) (point-max)))) + (ebox-region-update target-id :width (list 400)) + (should (equal visible-before + (buffer-substring-no-properties + (point-min) (point-max))))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'root-rerender)) + (should (plist-get report :root-rerender)) + (should (plist-get report :scroll-state-patch))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (should (ebox--scroll-region-by root-id -3 48)) + (should + (text-property-any + (point-min) (point-max) 'ebox-content target-id)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-nested-scroll-hidden-geometry-replays-both-caches () + "A hidden inner-scroll update should survive inner and outer replay." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id "root" :width (180) :height 1 :overflow scroll + (column + (box :id "wrapper" :width (180) :height 1 :overflow scroll + (column + (box :id "target" :content "target" :width (120) + :height 1 :box-sizing border-box + :padding-left (2)) + (box :content "inner tail" :height 1))) + (box :content "outer tail" :height 1))))) + root-id wrapper-id target-id) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (setq wrapper-id + (ebox-test--selector-region-id (current-buffer) "#wrapper")) + (setq target-id + (ebox-test--selector-region-id (current-buffer) "#target")) + (should (ebox--scroll-region-by wrapper-id 1 48)) + (should-not + (text-property-any + (point-min) (point-max) 'ebox-content target-id)) + (let ((visible-before + (buffer-substring-no-properties (point-min) (point-max)))) + (ebox-region-update target-id :padding-left (list 12)) + (should (equal visible-before + (buffer-substring-no-properties + (point-min) (point-max))))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'root-rerender)) + (should (plist-get report :root-rerender)) + (should (plist-get report :scroll-state-patch))) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (should (ebox--scroll-region-by root-id 1 48)) + (should (ebox--scroll-region-by root-id -1 48)) + (should-not + (text-property-any + (point-min) (point-max) 'ebox-content target-id)) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (should (ebox--scroll-region-by wrapper-id -1 48)) + (should + (text-property-any + (point-min) (point-max) 'ebox-content target-id)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + +(ert-deftest ebox-scroll-hot-rendered-lookup-uses-content-line-hints () + "A deferred rendered index should inspect target lines, not the full prefix." + (ebox-test--reset-runtime-state) + (let* ((content-lines + (cl-loop for index below 100 + collect (propertize (format "row %d" index) + 'ebox-content index))) + (rendered-lines (mapcar #'copy-sequence content-lines)) + (state (let ((ebox--defer-scroll-content-index t)) + (ebox--scroll-state-set-lines + nil content-lines rendered-lines))) + (content-index + (ebox--scroll-build-region-line-span-index content-lines)) + (full-builds 0) + (full-scans 0) + (membership-builds 0) + (hint-scans 0)) + (plist-put state :region-line-span-index content-index) + (plist-put state :region-line-span-index-deferred nil) + (let ((original-build + (symbol-function 'ebox--scroll-build-region-line-span-index)) + (original-scan + (symbol-function 'ebox--scroll-state-region-line-spans)) + (original-membership-build + (symbol-function 'ebox--scroll-build-content-region-id-set)) + (original-hint-scan + (symbol-function 'ebox--scroll-line-spans-at-index-hints))) + (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) + (lambda (&rest args) + (cl-incf full-builds) + (apply original-build args))) + ((symbol-function 'ebox--scroll-state-region-line-spans) + (lambda (&rest args) + (cl-incf full-scans) + (apply original-scan args))) + ((symbol-function 'ebox--scroll-build-content-region-id-set) + (lambda (&rest args) + (cl-incf membership-builds) + (apply original-membership-build args))) + ((symbol-function 'ebox--scroll-line-spans-at-index-hints) + (lambda (&rest args) + (cl-incf hint-scans) + (apply original-hint-scan args)))) + (let* ((content-spans + (ebox--scroll-hot-content-line-spans state '(70))) + (rendered-spans + (ebox--scroll-hot-rendered-line-spans + state '(70) content-spans))) + (should (= (caar content-spans) 70)) + (should (= (caar rendered-spans) 70))))) + (should (= full-builds 0)) + (should (= full-scans 0)) + (should (= membership-builds 0)) + (should (= hint-scans 0)) + (should-not (plist-get state :rendered-region-line-span-index)) + (should (plist-get state :rendered-region-line-span-index-deferred)))) + +(ert-deftest ebox-hidden-scroll-child-content-update-reuses-prebuilt-index () + "Hidden scroll child updates should reuse a prebuilt line index." + (ebox-test--reset-runtime-state) + (let* ((children (cl-loop for idx below 80 + collect `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1))) + (layout (ebox-build + `(box :id "root" :height 2 :overflow scroll + (column ,@children)))) + root-id target-id + (scan-calls 0) + (build-index-calls 0)) + (ebox-test--with-rendered-buffer layout + (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) + (setq target-id (ebox-test--selector-region-id (current-buffer) + "#row-70")) + (setq next-target-id + (ebox-test--selector-region-id (current-buffer) "#row-71")) + (let ((original (symbol-function 'ebox--scroll-state-region-line-spans)) + (original-build + (symbol-function 'ebox--scroll-build-region-line-span-index))) + (cl-letf (((symbol-function 'ebox--scroll-state-region-line-spans) + (lambda (&rest args) + (cl-incf scan-calls) + (apply original args))) + ((symbol-function 'ebox--scroll-build-region-line-span-index) + (lambda (&rest args) + (cl-incf build-index-calls) + (apply original-build args)))) + (ebox-region-update target-id :content "new 70"))) + (should (= scan-calls 0)) + (should (= build-index-calls 0)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :scroll-state-patch)) + (should (plist-get report :scroll-state-index))) + (let ((original (symbol-function 'ebox--scroll-state-region-line-spans)) + (original-build + (symbol-function 'ebox--scroll-build-region-line-span-index))) + (cl-letf (((symbol-function 'ebox--scroll-state-region-line-spans) + (lambda (&rest args) + (cl-incf scan-calls) + (apply original args))) + ((symbol-function 'ebox--scroll-build-region-line-span-index) + (lambda (&rest args) + (cl-incf build-index-calls) + (apply original-build args)))) + (ebox-region-update next-target-id :content "alt 71"))) + (should (= scan-calls 0)) + (should (= build-index-calls 0)) + (should (ebox--scroll-region-by root-id 70)) + (let ((visible + (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "new 70" visible)) + (should (string-match-p "alt 71" visible)))))) + +(ert-deftest ebox-hidden-scroll-child-visibility-check-skips-live-extents () + "Hidden scroll child updates should not force buffer-wide live extent scans." + (ebox-test--reset-runtime-state) + (let* ((children (cl-loop for idx below 80 + collect `(box :id ,(format "row-%02d" idx) + :content ,(format "row %02d" idx) + :height 1))) + (layout (ebox-build + `(box :id "root" :height 2 :overflow scroll + (column ,@children)))) + target-id + (live-extents-calls 0) + (property-scan-calls 0) + (original (symbol-function 'ebox--live-box-extents)) + (original-property-scan + (symbol-function 'ebox--region-ids-visible-by-property-scan-p))) + (ebox-test--with-rendered-buffer layout + (setq target-id (ebox-test--selector-region-id (current-buffer) + "#row-70")) + (should (hash-table-p (ebox-buffer--region-role-span-table))) + (cl-letf (((symbol-function 'ebox--live-box-extents) + (lambda (&rest args) + (cl-incf live-extents-calls) + (apply original args))) + ((symbol-function 'ebox--region-ids-visible-by-property-scan-p) + (lambda (&rest args) + (cl-incf property-scan-calls) + (apply original-property-scan args)))) + (ebox-region-update target-id :content "new 70")) + (should (= live-extents-calls 0)) + (should (= property-scan-calls 0)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :scroll-state-patch)))))) + +(ert-deftest ebox-small-geometry-span-patch-refreshes-indexes-scoped () + "Small geometry span patches should not rebuild all buffer runtime indexes." + (ebox-test--reset-runtime-state) + (let ((layout (ebox-test--dynamic-update-row-layout)) + (full-extents 0) + (full-roles 0) + (full-scroll 0) + (scoped-property-scans 0)) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id (current-buffer) + "#margin")) + (original-scoped-scan + (symbol-function + 'ebox--refresh-box-extents-for-region-ids-in-spans))) + (cl-letf (((symbol-function 'ebox--refresh-buffer-box-extents) + (lambda (&rest _args) + (cl-incf full-extents) + nil)) + ((symbol-function 'ebox-buffer-refresh-region-role-spans) + (lambda (&rest _args) + (cl-incf full-roles) + nil)) + ((symbol-function 'ebox--refresh-buffer-scroll-content-markers) + (lambda (&rest _args) + (cl-incf full-scroll) + nil)) + ((symbol-function + 'ebox--refresh-box-extents-for-region-ids-in-spans) + (lambda (&rest args) + (cl-incf scoped-property-scans) + (apply original-scoped-scan args)))) + (ebox-region-update region-id :margin-left (list 20))) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (plist-get report :scoped-index-refresh)) + (should (= full-extents 0)) + (should (= full-roles 0)) + (should (= full-scroll 0)) + (should (= scoped-property-scans 0)) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) + +(ert-deftest ebox-wheel-scroll-uses-buffer-scroll-region-without-point () + "Wheel events from fringe-like areas should still scroll the ebox page." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 2 :overflow scroll + (column + (box :content "A" :height 1) + (box :content "B" :height 1) + (box :content "C" :height 1))))) + (root-id (car (ebox-region-ids layout))) + mwheel-called) + (ebox-test--with-rendered-buffer layout + (switch-to-buffer (current-buffer)) + (cl-letf (((symbol-function 'event-start) + (lambda (_event) + (list (selected-window) 'mode-line '(0 . 0) + 0 nil nil nil nil nil nil))) + ((symbol-function 'mwheel-scroll) + (lambda (&rest _args) + (setq mwheel-called t)))) + (ebox--wheel-scroll 'fake-wheel-event 1)) + (should-not mwheel-called) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "B" plain)) + (should (string-match-p "C" plain)))))) + +(ert-deftest ebox-scroll-first-region-ignores-zero-consumption () + "A zero-line scroll result should not consume the scroll event." + (let (visited) + (cl-letf (((symbol-function 'ebox--scroll-region-by) + (lambda (region-id _delta &optional _prefix-budget-lines) + (push region-id visited) + (if (eq region-id 'stalled) 0 1)))) + (should (ebox--scroll-first-region-by '(stalled active) 1))) + (should (equal (nreverse visited) '(stalled active))))) + +(ert-deftest ebox-scroll-progress-requires-real-delta-or-pending () + "Scroll progress is either a non-zero consumed line count or lazy pending." + (should (ebox--scroll-progress-p 1)) + (should (ebox--scroll-progress-p -1)) + (should (ebox--scroll-progress-p 'pending)) + (should-not (ebox--scroll-progress-p 0)) + (should-not (ebox--scroll-progress-p nil))) + +(ert-deftest ebox-smooth-wheel-scroll-moves-immediately-then-drains () + "Smooth wheel scrolling should respond immediately and drain pending lines." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 1 :overflow scroll + (column + (box :content "A" :height 1) + (box :content "B" :height 1) + (box :content "C" :height 1))))) + (root-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (let ((ebox-wheel-smooth-scroll-lines-per-tick 1) + (ebox-wheel-smooth-scroll-interval 60.0)) + (should (ebox--smooth-scroll-region-by root-id 2)) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (ebox--smooth-scroll-tick root-id) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 2)) + (should-not (gethash root-id ebox--smooth-scroll-state-table)))))) + +(ert-deftest ebox-smooth-wheel-scroll-coalesces-while-timer-active () + "Further wheel input should queue behind the active timer, not edit at once." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 1 :overflow scroll + (column + (box :content "A" :height 1) + (box :content "B" :height 1) + (box :content "C" :height 1) + (box :content "D" :height 1))))) + (root-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (let ((ebox-wheel-smooth-scroll-lines-per-tick 1) + (ebox-wheel-smooth-scroll-interval 60.0)) + (should (ebox--smooth-scroll-region-by root-id 2)) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (should (gethash root-id ebox--smooth-scroll-state-table)) + (should (ebox--smooth-scroll-region-by root-id 2)) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (while (gethash root-id ebox--smooth-scroll-state-table) + (ebox--smooth-scroll-tick root-id)) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 3)))))) + +(ert-deftest ebox-smooth-wheel-scroll-clamps-pending-to-range () + "Large smooth wheel input should not leave an oversized timer backlog." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 1 :overflow scroll + (column + (box :content "A" :height 1) + (box :content "B" :height 1) + (box :content "C" :height 1) + (box :content "D" :height 1))))) + (root-id (car (ebox-region-ids layout)))) + (ebox-test--with-rendered-buffer layout + (let ((ebox-wheel-smooth-scroll-lines-per-tick 1) + (ebox-wheel-smooth-scroll-interval 60.0)) + (should (ebox--smooth-scroll-region-by root-id 1000)) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (should (<= (plist-get + (gethash root-id ebox--smooth-scroll-state-table) + :pending) + 2)) + (while (gethash root-id ebox--smooth-scroll-state-table) + (ebox--smooth-scroll-tick root-id)) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 3)))))) + +(ert-deftest ebox-page-scroll-bubbles-from-child-to-scrollable-ancestor () + "Page scrolling over a child box should scroll the nearest scrollable ancestor." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height 3 :overflow scroll + (column + (box :id "child" :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 "F" :height 1))))) + (root-id (car (ebox-region-ids layout))) + fallback-called) + (ebox-test--with-rendered-buffer layout + (goto-char (point-min)) + (search-forward "A") + (backward-char) + (cl-letf (((symbol-function 'window-body-height) + (lambda (&optional _window _pixelwise) 5)) + ((symbol-function 'scroll-up-command) + (lambda (&optional _arg) + (setq fallback-called t)))) + (ebox-scroll-page-down)) + (should-not fallback-called) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 3)) + (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "D" plain)) + (should (string-match-p "F" plain)) + (should-not (string-match-p "A" plain)))))) + +(ert-deftest ebox-scroll-rerender-preserves-viewport-height-context () + "Scroll rerender fallback should keep viewport-height boxes clipped." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "root" :height (viewport-height) :overflow scroll + (column + (box :id "child" :content "A" :height 1) + (box :content "B" :height 1) + (box :content "C" :height 1) + (box :content "D" :height 1))))) + (root-id (car (ebox-region-ids layout)))) + (let ((ebox-viewport-height 2)) + (ebox-test--with-rendered-buffer layout + (should (= (plist-get (ebox--scroll-get-state root-id) + :content-height) + 2)) + (goto-char (point-min)) + (search-forward "A") + (backward-char) + (ebox-scroll-down 1) + (should (= (plist-get (ebox--scroll-get-state root-id) + :content-height) + 2)) + (should (= (plist-get (ebox--scroll-get-state root-id) + :scroll-offset) + 1)) + (let ((plain (buffer-substring-no-properties + (point-min) (point-max)))) + (should (string-match-p "B" plain)) + (should (string-match-p "C" plain)) + (should-not (string-match-p "A" plain))))))) + +(ert-deftest ebox-wheel-scroll-falls-back-at-scroll-boundary () + "Mouse wheel should bubble to the buffer when inner scroll is at its edge." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A\nB\nC" + :height 1 + :overflow 'scroll)) + (region-id (car (ebox-region-ids box))) + mwheel-args) + (ebox-test--with-rendered-buffer box + (should (ebox--scroll-region-by region-id 3)) + (cl-letf (((symbol-function 'ebox--wheel-region-ids) + (lambda (_event) (list region-id))) + ((symbol-function 'mwheel-scroll) + (lambda (&rest args) + (setq mwheel-args args)))) + (ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg)) + (should (equal mwheel-args '(fake-wheel-event fake-arg)))))) + +(ert-deftest ebox-scroll-map-keeps-wheel-on-event-aware-handler () + "Mouse wheel bindings should use event-aware handlers, not line fallbacks." + (should (eq (lookup-key ebox-scroll-map [wheel-down]) + #'ebox-wheel-scroll-down)) + (should (eq (lookup-key ebox-scroll-map [wheel-up]) + #'ebox-wheel-scroll-up)) + (should (eq (lookup-key ebox-scroll-map [mouse-5]) + #'ebox-wheel-scroll-down)) + (should (eq (lookup-key ebox-scroll-map [mouse-4]) + #'ebox-wheel-scroll-up)) + (should (eq (lookup-key ebox-scroll-map [right-fringe wheel-down]) + #'ebox-wheel-scroll-down)) + (should (eq (lookup-key ebox-scroll-map [right-fringe wheel-up]) + #'ebox-wheel-scroll-up)) + (should (eq (lookup-key ebox-scroll-map [mode-line wheel-down]) + #'ebox-wheel-scroll-down)) + (should (eq (lookup-key ebox-scroll-map [mode-line wheel-up]) + #'ebox-wheel-scroll-up)) + (should (eq (lookup-key ebox-scroll-map [vertical-scroll-bar wheel-down]) + #'ebox-wheel-scroll-down)) + (should (eq (lookup-key ebox-scroll-map [vertical-scroll-bar wheel-up]) + #'ebox-wheel-scroll-up)) + (should (eq (lookup-key ebox-scroll-map [remap scroll-up-command]) + #'ebox-scroll-page-down)) + (should (eq (lookup-key ebox-scroll-map [remap scroll-down-command]) + #'ebox-scroll-page-up)) + (should (eq (lookup-key ebox-scroll-map (kbd "C-v")) + #'ebox-scroll-page-down)) + (should (eq (lookup-key ebox-scroll-map (kbd "")) + #'ebox-scroll-page-down)) + (should (eq (lookup-key ebox-scroll-map (kbd "M-v")) + #'ebox-scroll-page-up)) + (should (eq (lookup-key ebox-scroll-map (kbd "")) + #'ebox-scroll-page-up)) + (should (eq (lookup-key ebox-scroll-map (kbd "n")) + #'ebox-scroll-down)) + (should (eq (lookup-key ebox-scroll-map (kbd "p")) + #'ebox-scroll-up))) + +(ert-deftest ebox-buffer-mode-overrides-pixel-scroll-bindings () + "Ebox buffers should keep their own scroll bindings under pixel scrolling." + (unless (fboundp 'pixel-scroll-precision-mode) + (ert-skip "pixel-scroll-precision-mode is unavailable")) + (let ((was-enabled pixel-scroll-precision-mode)) + (unwind-protect + (with-temp-buffer + (pixel-scroll-precision-mode 1) + (ebox-buffer-mode 1) + (should (eq (key-binding [wheel-down]) + #'ebox-wheel-scroll-down)) + (should (eq (key-binding [wheel-up]) + #'ebox-wheel-scroll-up)) + (should (eq (key-binding [right-fringe wheel-down]) + #'ebox-wheel-scroll-down)) + (should (eq (key-binding [mode-line wheel-down]) + #'ebox-wheel-scroll-down)) + (should (eq (key-binding [vertical-scroll-bar wheel-down]) + #'ebox-wheel-scroll-down)) + (should (eq (key-binding (kbd "")) + #'ebox-scroll-page-down)) + (should (eq (key-binding (kbd "")) + #'ebox-scroll-page-up))) + (unless was-enabled + (pixel-scroll-precision-mode -1))))) + +(ert-deftest ebox-border-box-max-height-includes-vertical-padding () + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A\nB\nC\nD" + :box-sizing 'border-box + :max-height 3 + :padding '(1 0) + :overflow 'hidden)) + (plain (substring-no-properties (ebox-render box)))) + (should (= (ebox-string-height plain) 3)) + (should-not (string-match-p "C" plain)) + (should-not (string-match-p "D" plain)))) + +(ert-deftest ebox-content-box-min-height-wins-when-min-exceeds-max () + "Content-box height conflicts should use the minimum as the used height." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A" + :box-sizing 'content-box + :min-height 5 + :max-height 3 + :padding '(1 0) + :overflow 'hidden)) + (rendered (ebox-render box))) + (should (= (ebox--content-height box 1) 5)) + (should (= (ebox-string-height rendered) 7)))) + +(ert-deftest ebox-border-box-min-height-wins-when-min-exceeds-max () + "Border-box height conflicts should use the minimum as the outer height." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "A" + :box-sizing 'border-box + :min-height 5 + :max-height 3 + :padding '(1 0) + :overflow 'hidden)) + (rendered (ebox-render box))) + (should (= (ebox--content-height box 1) 3)) + (should (= (ebox-string-height rendered) 5)))) + +(ert-deftest ebox-border-box-max-width-includes-horizontal-padding-and-border () + (ebox-test--reset-runtime-state) + (let ((box (ebox-create :content "abc abc abc abc abc abc abc abc abc abc" + :box-sizing 'border-box + :max-width '(100) + :padding-inline '(20) + :border-left '(10) + :border-right '(10) + :overflow 'hidden))) + (should (<= (ebox--total-pixel box) 100)))) + +(ert-deftest ebox-auto-width-without-viewport-uses-max-content-across-all-lines () + "Omitted :width should fall back to the widest unwrapped content line." + (ebox-test--reset-runtime-state) + (let* ((content "short\nmuch wider line") + (expected (apply #'max + (mapcar #'ebox--string-pixel-width + (ebox-string-lines content)))) + (box (ebox-create :content content)) + (rendered (ebox-render box)) + (widths (mapcar #'ebox--string-pixel-width + (ebox-string-lines rendered)))) + (should (= (ebox--content-pixel box) expected)) + (should (cl-every (lambda (width) (= width expected)) widths)))) + +(ert-deftest ebox-auto-width-stretches-to-viewport-when-available () + "Omitted :width and :width auto should fill the viewport margin box." + (ebox-test--reset-runtime-state) + (let ((ebox-viewport-width 240)) + (dolist (width-value '(nil auto)) + (let ((box (apply #'ebox-create + (append (list :content "short line wraps into viewport" + :padding-inline '(20) + :border-left '(10) + :border-right '(10) + :margin-inline '(5)) + (when width-value + (list :width width-value)))))) + (should (= (ebox--total-pixel box) 240)))))) + +(ert-deftest ebox-explicit-width-can-overflow-containing-block () + "A definite width remains definite even when the containing block is narrower." + (ebox-test--reset-runtime-state) + (let* ((ebox-viewport-width 120) + (content "alpha beta gamma delta") + (box (ebox-create :content content :width '(220) :wrap-mode 'word)) + (rendered (ebox-render box)) + (widths (mapcar #'ebox--string-pixel-width + (ebox-string-lines rendered)))) + (should (cl-some (lambda (width) (> width ebox-viewport-width)) + widths)) + (should (cl-every (lambda (width) (= width 220)) widths)))) + +(ert-deftest ebox-column-wrapper-width-reflows-auto-child-layout () + "A column wrapper width should contain auto-sized children." + (ebox-test--reset-runtime-state) + (let* ((ebox-viewport-width 1000) + (node (ebox-build + '(column :width (120) + (box :content "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega" + :wrap-mode word)))) + (rendered (ebox-render node)) + (plain (substring-no-properties rendered)) + (widths (mapcar #'ebox--string-pixel-width + (ebox-string-lines rendered)))) + (should (cl-every (lambda (width) (<= width 120)) widths)) + (dolist (word '("alpha" "beta" "gamma" "delta" "epsilon" "theta" + "omega")) + (should (string-match-p word plain))) + (should (> (ebox-string-height rendered) 1)))) + +(ert-deftest ebox-wrapper-child-layout-keeps-pixel-width-with-wide-font () + "Engine pixel widths must not be reparsed as public character widths." + (ebox-test--reset-runtime-state) + (let ((ebox-viewport-width 1000)) + (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7))) + (let* ((node (ebox-build + '(column :width (400) + (box :content "X")))) + (widths (mapcar #'ebox--string-pixel-width + (ebox-string-lines (ebox-render node))))) + (should (equal widths '(400))))))) + +(ert-deftest ebox-column-wrapper-width-reflows-auto-flex-layout () + "A column wrapper width should become the viewport for nested auto flex." + (ebox-test--reset-runtime-state) + (let* ((ebox-viewport-width 1000) + (node (ebox-build + '(column :width (240) + (flex :flex-wrap wrap :column-gap (10) + (box :content "A" :width (100)) + (box :content "B" :width (100)) + (box :content "C" :width (100)))))) + (rendered (ebox-render node)) + (plain-lines (ebox-string-lines + (substring-no-properties rendered))) + (widths (mapcar #'ebox--string-pixel-width + (ebox-string-lines rendered)))) + (should (cl-every (lambda (width) (<= width 240)) widths)) + (should (string-match-p "A.*B" (car plain-lines))) + (should (cl-some (lambda (line) (string-match-p "C" line)) + (cdr plain-lines))))) + +(ert-deftest ebox-column-containing-block-does-not-expand-to-overflow-child () + "An overflowing definite child should not widen unrelated column rows." + (ebox-test--reset-runtime-state) + (let* ((ebox-viewport-width 200) + (node (ebox-column + (ebox-create :content "Auto row") + (ebox-create :content "Fixed row" :width '(300)))) + (widths (mapcar #'ebox--string-pixel-width + (ebox-string-lines (ebox-render node))))) + (should (equal widths '(200 300))))) + +(ert-deftest ebox-width-keywords-resolve-intrinsic-content-sizes () + "auto, min-content, max-content, and none should keep CSS-like meanings." + (ebox-test--reset-runtime-state) + (let* ((content "aa bbbb") + (max-content (ebox--string-pixel-width content)) + (min-content (ebox--string-pixel-width "bbbb"))) + (should (= (ebox--content-pixel + (ebox-create :content content :width 'auto)) + max-content)) + (should (= (ebox--content-pixel + (ebox-create :content content :width 'max-content)) + max-content)) + (should (= (ebox--content-pixel + (ebox-create :content content :width 'min-content)) + min-content)) + (should (= (ebox--content-pixel + (ebox-create :content content + :width '(80) + :max-width 'none)) + 80)) + (should-error (ebox-create :content content :width 'none) + :type 'error))) + +(ert-deftest ebox-max-content-wrapper-measures-composite-child-intrinsically () + "A max-content wrapper should not stretch its composite child to the viewport." + (ebox-test--reset-runtime-state) + (let* ((ebox-viewport-width 480) + (lines '("Compact" "Natural width")) + (expected + (apply #'max (mapcar #'ebox--string-pixel-width lines))) + (node + (ebox-create + :width 'max-content + :ebox-content-node + (apply #'ebox-column + (mapcar (lambda (line) + (ebox-create :content line)) + lines)))) + (widths + (mapcar #'ebox--string-pixel-width + (ebox-string-lines (ebox-render node))))) + (should (equal widths (make-list (length lines) expected))))) + +(ert-deftest ebox-definite-width-skips-unused-max-content-measurement () + "A definite preferred width should not evaluate its unused auto fallback." + (ebox-test--reset-runtime-state) + (let ((box (ebox-create :content "measurement must stay cold" + :width '(80))) + (measurements 0) + (original (symbol-function 'ebox--content-max-pixel))) + (cl-letf (((symbol-function 'ebox--content-max-pixel) + (lambda (candidate) + (cl-incf measurements) + (funcall original candidate)))) + (should (= (ebox--content-pixel box) 80)) + (should (= measurements 0))))) + +(ert-deftest ebox-definite-width-still-resolves-intrinsic-constraints () + "Intrinsic min/max constraints must still measure definite-width content." + (ebox-test--reset-runtime-state) + (let* ((content "intrinsic constraint") + (expected (ebox--string-pixel-width content)) + (box (ebox-create :content content + :width '(20) + :min-width 'max-content)) + (measurements 0) + (original (symbol-function 'ebox--content-max-pixel))) + (cl-letf (((symbol-function 'ebox--content-max-pixel) + (lambda (candidate) + (cl-incf measurements) + (funcall original candidate)))) + (should (= (ebox--content-pixel box) expected)) + (should (= measurements 1))))) + +(ert-deftest ebox-auto-width-keeps-max-content-fallback-without-viewport () + "An unresolved auto width should retain its intrinsic max-content fallback." + (ebox-test--reset-runtime-state) + (let ((box (ebox-create :content "auto fallback")) + (measurements 0) + (original (symbol-function 'ebox--content-max-pixel))) + (cl-letf (((symbol-function 'ebox--content-max-pixel) + (lambda (candidate) + (cl-incf measurements) + (funcall original candidate)))) + (should (> (ebox--content-pixel box) 0)) + (should (= measurements 1))))) + +(ert-deftest ebox-fit-content-width-clamps-between-min-and-max-content () + "fit-content() should clamp by min-content and max-content." + (ebox-test--reset-runtime-state) + (let* ((content "aa widest") + (min-content (ebox--string-pixel-width "widest")) + (max-content (ebox--string-pixel-width content)) + (between (+ min-content (/ (- max-content min-content) 2)))) + (should (< min-content between)) + (should (< between max-content)) + (should (= (ebox--content-pixel + (ebox-create :content content + :width (list 'fit-content (list between)))) + between)) + (should (= (ebox--content-pixel + (ebox-create :content content + :width (list 'fit-content (list 1)))) + min-content)) + (should (= (ebox--content-pixel + (ebox-create :content content + :width (list 'fit-content + (list (+ max-content 50))))) + max-content)))) + +(ert-deftest ebox-fit-content-keyword-uses-stretch-when-viewport-is-definite () + "The fit-content keyword should clamp stretch between intrinsic sizes." + (ebox-test--reset-runtime-state) + (let* ((content "aa widest") + (min-content (ebox--string-pixel-width "widest")) + (max-content (ebox--string-pixel-width content)) + (stretch (+ min-content (/ (- max-content min-content) 2))) + (ebox-viewport-width stretch)) + (should (= (ebox--content-pixel + (ebox-create :content content :width 'fit-content)) + stretch)))) + +(ert-deftest ebox-min-and-max-width-keywords-constrain-preferred-width () + "Intrinsic keywords should work on min-width and max-width too." + (ebox-test--reset-runtime-state) + (let* ((content "aa widest") + (min-content (ebox--string-pixel-width "widest")) + (max-content (ebox--string-pixel-width content)) + (between (+ min-content (/ (- max-content min-content) 2)))) + (should (= (ebox--content-pixel + (ebox-create :content content + :width '(1) + :min-width 'max-content)) + max-content)) + (should (= (ebox--content-pixel + (ebox-create :content content + :width (list (+ max-content 50)) + :max-width 'min-content)) + min-content)) + (should (= (ebox--content-pixel + (ebox-create :content content + :width '(1) + :min-width (list 'fit-content + (list between)))) + between)) + (should (= (ebox--content-pixel + (ebox-create :content content + :width (list (+ max-content 50)) + :max-width (list 'fit-content + (list between)))) + between)))) + +(ert-deftest ebox-stretch-and-contain-width-fill-viewport-margin-box () + "stretch and contain should resolve against the current viewport." + (ebox-test--reset-runtime-state) + (let ((ebox-viewport-width 240)) + (dolist (keyword '(stretch contain)) + (let ((box (ebox-create :content "x" + :width keyword + :padding-inline '(20) + :border-left '(10) + :border-right '(10) + :margin-inline '(5)))) + (should (= (ebox--total-pixel box) 240)))))) + +(ert-deftest ebox-max-width-clamps-used-width-before-content-minimum () + "A max-width smaller than one glyph should constrain used width." + (ebox-test--reset-runtime-state) + (cl-letf (((symbol-function 'ebox--string-pixel-width) + (lambda (_string) 60)) + ((symbol-function 'ebox--max-char-pixel) + (lambda (_string) 60))) + (let ((box (ebox-create :content "W" + :max-width '(20) + :wrap-mode 'char))) + (should (= (ebox--content-pixel box) 20)) + (should (= (ebox--total-pixel box) 20))))) + +(ert-deftest ebox-min-width-wins-when-min-width-exceeds-max-width () + "CSS width constraints make min-width override max-width conflicts." + (ebox-test--reset-runtime-state) + (let ((box (ebox-create :content "short" + :width '(40) + :min-width '(120) + :max-width '(80)))) + (should (= (ebox--content-pixel box) 120)) + (should (= (ebox--total-pixel box) 120)))) + +(ert-deftest ebox-width-properties-reject-negative-values () + "Width, min-width, and max-width should reject negative lengths." + (ebox-test--reset-runtime-state) + (should-error (ebox-create :content "bad" :width '(-1)) :type 'error) + (should-error (ebox-create :content "bad" :min-width '(-1)) :type 'error) + (should-error (ebox-create :content "bad" :max-width '(-1)) :type 'error)) + +(ert-deftest ebox-edge-properties-reject-negative-values () + "Public edge properties and engine longhands should reject negatives." + (ebox-test--reset-runtime-state) + (dolist (props '((:padding -1) + (:padding-left-pixel -1) + (:margin -1) + (:margin-top-height -1) + (:border-width -1) + (:border-top-width -1) + (:border-left-pixel -1))) + (should-error + (apply #'ebox-create (append '(:content "bad") props)) + :type 'error))) + +(ert-deftest ebox-line-min-content-skips-discarded-space-measurements () + "Literal spaces split runs and plain ASCII reuses exact character widths." + (let ((line (copy-sequence "fi x")) + (ebox--display-cache-validated t) + (ebox--char-width-cache (make-hash-table :test 'eql)) + measured) + (puthash ?i 4 ebox--char-width-cache) + (add-text-properties 0 1 '(face (:height 1.2)) line) + (add-text-properties 3 4 '(display (raise 0.1)) line) + (cl-letf (((symbol-function 'ebox--string-pixel-width) + (lambda (string) + (push (substring-no-properties string) measured) + (pcase (substring-no-properties string) + ("f" 3) + ("x" 2) + (" " 99) + (_ 99))))) + ;; Per-grapheme addition remains intentional: measuring the whole "fi" + ;; could produce a non-additive ligature width and change the contract. + (should (= (ebox--line-min-content-pixel line) 7)) + (should (equal (nreverse measured) '("f" "x")))))) + +(ert-deftest ebox-region-update-valid-paint-and-invalid-edge-is-atomic () + "Invalid geometry should fail before a valid sibling property mutates." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "Atomic" + :width '(120) + :bgcolor "#111111" + :padding-left-pixel 2)) + (region-id (car (ebox-region-ids box)))) + (ebox-test--with-rendered-buffer box + (should-error + (ebox-region-update region-id + :bgcolor "#222222" + :padding-left-pixel -1) + :type 'error) + (should (equal (ebox-get box :bgcolor) "#111111")) + (should (= (ebox-get box :padding-left-pixel) 2))))) + +(ert-deftest ebox-region-update-no-op-skips-rollback-snapshot () + "A legal no-op should return before discovering rollback scope." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id "root" :width (240) :height 1 :overflow scroll + (box :id "target" :content "same" :width (120)))))) + (ebox-test--with-rendered-buffer layout + (let ((target-id + (ebox-test--selector-region-id (current-buffer) "#target"))) + (cl-letf + (((symbol-function 'ebox--node-all-region-ids) + (lambda (&rest _) + (ert-fail "no-op discovered subtree rollback scope"))) + ((symbol-function + 'ebox--scroll-state-region-ids-containing-node-ids) + (lambda (&rest _) + (ert-fail "no-op discovered containing scroll scope")))) + (let ((report + (ebox-region-update target-id :content "same"))) + (should (eq (plist-get report :strategy) 'no-op)) + (should (= (plist-get report :dirty-count) 0)))))))) + +(ert-deftest ebox-region-update-rollback-snapshot-keeps-scroll-owner-box-by-identity () + "A child rollback snapshot should retain its scroll owner's box identity." + (ebox-test--reset-runtime-state) + (let ((layout + (ebox-build + '(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + (box :id "target" :content "paint" :width (120) + :color "#111111") + (box :content "tail" :width (120))))))) + (let ((ebox-viewport-width 240) + (ebox-viewport-height 1)) + (ebox-test--with-rendered-buffer layout + (let* ((root-id + (ebox-test--selector-region-id (current-buffer) "#root")) + (target-id + (ebox-test--selector-region-id (current-buffer) "#target")) + (scroll-box + (plist-get (ebox--scroll-get-state root-id) :box)) + (original-copy-tree (symbol-function 'copy-tree))) + (should scroll-box) + (cl-letf + (((symbol-function 'copy-tree) + (lambda (tree &optional vectors-and-records) + (when (eq tree scroll-box) + (ert-fail "deep-copied containing scroll owner box")) + (funcall original-copy-tree tree vectors-and-records)))) + (ebox-region-update target-id :color "#222222")) + (should (equal + (ebox-get + (gethash target-id ebox--region-box-table) :color) + "#222222"))))))) + +(ert-deftest ebox-region-update-rolls-back-when-rerender-fails () + "A failed local rerender must leave model, buffer, and report unchanged." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "target" :content "before" :width (120)))) + (report-before 'keep-report)) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (region-id (ebox-test--selector-region-id buffer "#target")) + (box (gethash region-id ebox--region-box-table)) + (box-before (copy-tree box)) + (text-before (buffer-string))) + (ebox-test--set-buffer-update-report buffer report-before) + (cl-letf (((symbol-function 'ebox--rerender-region-for-dirty-kind) + (lambda (&rest _) + (error "Injected rerender failure")))) + (should-error + (ebox-region-update region-id :content "after") + :type 'error)) + (should (equal box box-before)) + (should (equal-including-properties (buffer-string) text-before)) + (should (eq (ebox-buffer-update-report buffer) report-before)))))) + +(ert-deftest ebox-region-update-rolls-back-after-scroll-sync-fails () + "A post-publication scroll sync failure must roll back all published state." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "root" :width (viewport) :height (viewport-height) + :overflow scroll + (column :width (viewport) + (box :id "target" :content "before" :width (120) :height 1) + (box :content "tail" :width (120) :height 1))))) + (report-before 'keep-scroll-report)) + (let ((ebox-viewport-width 260) + (ebox-viewport-height 2) + (ebox-scroll-lazy-prefix-lookahead-lines 0)) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (root-id (ebox-test--selector-region-id buffer "#root")) + (target-id (ebox-test--selector-region-id buffer "#target")) + (target-box (gethash target-id ebox--region-box-table)) + (target-box-before (copy-tree target-box)) + (scroll-state-before (copy-tree (ebox--scroll-get-state root-id))) + (runtime-state (ebox--buffer-render-state buffer)) + (runtime-root-before + (copy-tree (plist-get runtime-state :root-node))) + (runtime-revision-before + (plist-get runtime-state :runtime-revision)) + (text-before (buffer-string))) + (ebox-test--set-buffer-update-report buffer report-before) + (cl-letf (((symbol-function 'ebox--sync-scroll-state-lines-for-node) + (lambda (&rest _) + (error "Injected scroll sync failure")))) + (should-error + (ebox-region-update target-id :content "after") + :type 'error)) + (should (equal target-box target-box-before)) + (should (equal (ebox--scroll-get-state root-id) + scroll-state-before)) + (should (equal-including-properties (buffer-string) text-before)) + (should (eq (ebox--buffer-render-state buffer) runtime-state)) + (should (equal (plist-get runtime-state :root-node) + runtime-root-before)) + (should (equal (plist-get runtime-state :runtime-revision) + runtime-revision-before)) + (should (eq (ebox-buffer-update-report buffer) + report-before))))))) + +(ert-deftest ebox-region-update-rolls-back-new-scroll-state-after-post-failure () + "Rollback must tombstone scroll state created during a failed update." + (ebox-test--reset-runtime-state) + (let* ((layout + (ebox-build + '(box :id "target" :content "before\nhidden" :width (120) + :height 1 :overflow visible))) + (report-before 'keep-new-scroll-report)) + (ebox-test--with-rendered-buffer layout + (let* ((buffer (current-buffer)) + (target-id (ebox-test--selector-region-id buffer "#target")) + (target-box (gethash target-id ebox--region-box-table)) + (target-box-before (copy-tree target-box)) + (runtime-state (ebox--buffer-render-state buffer)) + (runtime-root-before + (copy-tree (plist-get runtime-state :root-node))) + (runtime-revision-before + (plist-get runtime-state :runtime-revision)) + (text-before (buffer-string))) + (should-not (ebox--scroll-get-state target-id)) + (ebox-test--set-buffer-update-report buffer report-before) + (cl-letf (((symbol-function 'ebox--schedule-buffer-runtime-prewarm) + (lambda (&rest _) + (error "Injected post-publication failure")))) + (should-error + (ebox-region-update target-id :overflow 'scroll) + :type 'error)) + (should-not (ebox--scroll-get-state target-id)) + (should (equal target-box target-box-before)) + (should (equal-including-properties (buffer-string) text-before)) + (should (eq (ebox--buffer-render-state buffer) runtime-state)) + (should (equal (plist-get runtime-state :root-node) + runtime-root-before)) + (should (equal (plist-get runtime-state :runtime-revision) + runtime-revision-before)) + (should (eq (ebox-buffer-update-report buffer) report-before)))))) + +(ert-deftest ebox-region-update-success-path-uses-local-transaction-snapshot () + "Successful local updates must not take full-buffer or global-table snapshots." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(box :id "target" :content "before" :width (120)))) + (buffer-string-calls 0) + (hash-key-calls 0) + (copy-hash-table-calls 0) + (original-buffer-string (symbol-function 'buffer-string)) + (original-hash-keys (symbol-function 'ebox-incremental--hash-keys)) + (original-copy-hash-table (symbol-function 'copy-hash-table))) + (ebox-test--with-rendered-buffer layout + (let ((region-id (ebox-test--selector-region-id + (current-buffer) "#target"))) + (cl-letf (((symbol-function 'buffer-string) + (lambda () + (cl-incf buffer-string-calls) + (funcall original-buffer-string))) + ((symbol-function 'ebox-incremental--hash-keys) + (lambda (&rest args) + (cl-incf hash-key-calls) + (apply original-hash-keys args))) + ((symbol-function 'copy-hash-table) + (lambda (&rest args) + (cl-incf copy-hash-table-calls) + (apply original-copy-hash-table args)))) + (ebox-region-update region-id :content "after")) + (should (string-match-p + "after" + (buffer-substring-no-properties (point-min) (point-max)))) + (should (= buffer-string-calls 0)) + (should (= hash-key-calls 0)) + (should (= copy-hash-table-calls 0)))))) + +(ert-deftest ebox-cjk-wraps-by-character-width-in-word-mode () + (ebox-test--reset-runtime-state) + (let* ((content "你好世界") + (width (* 2 (ebox--string-pixel-width "你"))) + (lines (ebox-string-lines + (substring-no-properties + (ebox-render + (ebox-create :content content + :width (list width) + :wrap-mode 'word)))))) + (should (= (length lines) 2)) + (should (string-prefix-p "你好" (nth 0 lines))) + (should (string-prefix-p "世界" (nth 1 lines))) + (dolist (line lines) + (should (<= (ebox--string-pixel-width line) width))))) + +(ert-deftest ebox-wrap-keeps-combining-mark-with-base-character () + (ebox-test--reset-runtime-state) + (let* ((cluster "é") + (text (concat cluster cluster)) + (width (ebox--string-pixel-width cluster))) + (should (equal (mapcar #'substring-no-properties + (ebox--wrap-line text width 'char)) + (list cluster cluster))))) + +(ert-deftest ebox-word-wrap-skips-overflowing-separator-at-line-break () + (ebox-test--reset-runtime-state) + (let* ((text "normal behaves stretch-like for auto cross size in this DSL.") + (width (ebox--string-pixel-width "stretch-like for")) + (lines (ebox--wrap-line text width 'word))) + (dolist (line lines) + (should (<= (ebox--string-pixel-width line) width))) + (should-not (cl-some (lambda (line) + (string-prefix-p " " line)) + lines)))) + +(ert-deftest ebox-wrap-keeps-emoji-zwj-and-regional-indicator-clusters () + (ebox-test--reset-runtime-state) + (let ((family "👨‍👩‍👧‍👦") + (china "🇨🇳") + (usa "🇺🇸")) + (should (equal (mapcar #'substring-no-properties + (ebox--wrap-line (concat family family) + (ebox--string-pixel-width family) + 'char)) + (list family family))) + (should (equal (mapcar #'substring-no-properties + (ebox--wrap-line (concat china usa) + (ebox--string-pixel-width china) + 'char)) + (list china usa))))) + +(ert-deftest ebox-kp-wrap-mode-invokes-ekp-justification () + (ebox-test--reset-runtime-state) + (let (require-called justify-args) + (cl-letf (((symbol-function 'require) + (lambda (feature &optional _filename _noerror) + (when (eq feature 'ekp) + (setq require-called t)) + t)) + ((symbol-function 'ekp-pixel-justify) + (lambda (text width) + (setq justify-args (list text width)) + text))) + (ebox-render (ebox-create :content "abc" + :width '(100) + :wrap-mode 'kp))) + (should require-called) + (should (equal justify-args (list "abc" 100))))) + +(ert-deftest ebox-kp-wrap-mode-respects-text-align () + "KP-wrapped lines should honor the box's horizontal text alignment." + (ebox-test--reset-runtime-state) + (cl-letf (((symbol-function 'require) + (lambda (feature &optional _filename _noerror) + (when (eq feature 'ekp) t))) + ((symbol-function 'ekp-pixel-justify) + (lambda (&rest _) + "abc\nx"))) + (dolist (align '(center right)) + (let* ((box (ebox-create :content "ignored" + :width '(100) + :wrap-mode 'kp + :text-align align)) + (width (ebox--content-pixel box)) + (rendered (ebox-render box)) + (line (car (ebox-string-lines rendered))) + (expected (ebox--pixel-reach "abc" width align))) + (should (equal (get-text-property 0 'display line) + (get-text-property 0 'display expected))))))) + +(ert-deftest ebox-kp-wrap-preserves-content-width-for-ragged-lines () + (ebox-test--reset-runtime-state) + (cl-letf (((symbol-function 'require) + (lambda (feature &optional _filename _noerror) + (when (eq feature 'ekp) t))) + ((symbol-function 'ekp-pixel-justify) + (lambda (_text width) + (concat (ebox-pixel-space width) "\nshort")))) + (let* ((rendered (ebox-render + (ebox-create :content "ignored" + :width '(100) + :wrap-mode 'kp))) + (widths (mapcar #'ebox-string-pixel-width + (ebox-string-lines rendered)))) + (should (equal widths '(100 100)))))) + +(ert-deftest ebox-content-face-keeps-foreground-and-background () + (ebox-test--reset-runtime-state) + (let* ((rendered (ebox-render + (ebox-create :content "Face" + :width 40 + :color "#abcdef" + :bgcolor "#123456"))) + (face (ebox-test--first-content-face rendered))) + (should (ebox-test--face-has-key-value-p face :foreground "#abcdef")) + (should (ebox-test--face-has-key-value-p face :background "#123456")))) + +(ert-deftest ebox-border-faces-keep-side-specific-properties () + (ebox-test--reset-runtime-state) + (let* ((rendered (ebox-render + (ebox-create :content "Border" + :width 60 + :padding '(1 1) + :border '(1 "#ff0000")))) + (left-face (ebox-test--face-at-property rendered 'ebox-bl)) + (right-face (ebox-test--face-at-property rendered 'ebox-br)) + (top-face (ebox-test--face-at-property rendered 'ebox-bt)) + (bottom-face (ebox-test--face-at-property rendered 'ebox-bb))) + (should-not (ebox-test--face-has-key-p left-face :inverse-video)) + (should (ebox-test--face-has-key-value-p left-face :background "#ff0000")) + (should-not (ebox-test--face-has-key-p right-face :inverse-video)) + (should (ebox-test--face-has-key-value-p right-face :background "#ff0000")) + (should (ebox-test--face-has-key-value-p top-face :overline "#ff0000")) + (should (ebox-test--face-has-key-value-p + bottom-face + :underline + '(:position t :color "#ff0000"))))) + +(ert-deftest ebox-border-faces-omit-unspecified-frame-colors () + (ebox-test--reset-runtime-state) + (let ((original-frame-parameter (symbol-function 'frame-parameter))) + (dolist (sentinel '(unspecified unspecified-fg unspecified-bg + "unspecified" "unspecified-fg" "unspecified-bg")) + (cl-letf (((symbol-function 'frame-parameter) + (lambda (frame parameter) + (if (eq parameter 'foreground-color) + sentinel + (funcall original-frame-parameter frame parameter))))) + (let* ((rendered (ebox-render + (ebox-create :content "Border" + :width 60 + :padding '(1 1) + :border t))) + (left-face (ebox-test--face-at-property rendered 'ebox-bl)) + (right-face (ebox-test--face-at-property rendered 'ebox-br)) + (top-face (ebox-test--face-at-property rendered 'ebox-bt)) + (bottom-face (ebox-test--face-at-property rendered 'ebox-bb))) + (should (ebox-test--face-has-key-value-p + left-face :inverse-video t)) + (should (ebox-test--face-has-key-value-p + right-face :inverse-video t)) + (should (ebox-test--face-has-key-value-p top-face :overline t)) + (should (ebox-test--face-has-key-value-p + bottom-face :underline '(:position t)))))))) + +(ert-deftest ebox-horizontal-borders-cover-side-border-corners () + (ebox-test--reset-runtime-state) + (require 'face-remap) + (let* ((text-scale-mode-amount 2) + (rendered (ebox-render + (ebox-create :content "Border" + :width 120 + :padding '(0 (8)) + :border '(1 "#ff0000")))) + (lines (ebox-string-lines rendered)) + (top (car lines)) + (bottom (car (last lines))) + (top-left (get-text-property 0 'face top)) + (top-right (get-text-property (1- (length top)) 'face top)) + (bottom-left (get-text-property 0 'face bottom)) + (bottom-right (get-text-property (1- (length bottom)) 'face bottom))) + (dolist (face (list top-left top-right)) + (should (ebox-test--face-has-key-value-p + face :background "#ff0000")) + (should (ebox-test--face-has-key-value-p face :overline "#ff0000"))) + (dolist (face (list bottom-left bottom-right)) + (should (ebox-test--face-has-key-value-p + face :background "#ff0000")) + (should (ebox-test--face-has-key-value-p + face :underline '(:position t :color "#ff0000")))))) + +(ert-deftest ebox-horizontal-borders-cover-side-border-corners-when-shrunk () + (ebox-test--reset-runtime-state) + (require 'face-remap) + (let* ((text-scale-mode-amount -2) + (rendered (ebox-render + (ebox-create :content "Border" + :width 120 + :padding '(0 (8)) + :border '(1 "#ff0000")))) + (lines (ebox-string-lines rendered)) + (top (car lines)) + (bottom (car (last lines))) + (top-left (get-text-property 0 'face top)) + (top-right (get-text-property (1- (length top)) 'face top)) + (bottom-left (get-text-property 0 'face bottom)) + (bottom-right (get-text-property (1- (length bottom)) 'face bottom))) + (dolist (face (list top-left top-right)) + (should (ebox-test--face-has-key-value-p + face :background "#ff0000")) + (should (ebox-test--face-has-key-value-p face :overline "#ff0000"))) + (dolist (face (list bottom-left bottom-right)) + (should (ebox-test--face-has-key-value-p + face :background "#ff0000")) + (should (ebox-test--face-has-key-value-p + face :underline '(:position t :color "#ff0000")))))) + +(ert-deftest ebox-horizontal-borders-do-not-cover-horizontal-margins () + (ebox-test--reset-runtime-state) + (let* ((rendered (ebox-render + (ebox-create :content "Border" + :width 80 + :margin '(0 (8) 0 (8)) + :border '(1 "#ff0000")))) + (lines (ebox-string-lines rendered)) + (top (car lines)) + (bottom (car (last lines))) + (top-left-margin (get-text-property 0 'face top)) + (top-left-border (get-text-property 1 'face top)) + (top-right-border (get-text-property (- (length top) 2) 'face top)) + (top-right-margin (get-text-property (1- (length top)) 'face top)) + (bottom-left-margin (get-text-property 0 'face bottom)) + (bottom-left-border (get-text-property 1 'face bottom)) + (bottom-right-border (get-text-property (- (length bottom) 2) 'face bottom)) + (bottom-right-margin (get-text-property (1- (length bottom)) 'face bottom))) + (dolist (face (list top-left-border top-right-border)) + (should (ebox-test--face-has-key-value-p + face :background "#ff0000")) + (should (ebox-test--face-has-key-value-p face :overline "#ff0000"))) + (dolist (face (list bottom-left-border bottom-right-border)) + (should (ebox-test--face-has-key-value-p + face :background "#ff0000")) + (should (ebox-test--face-has-key-value-p + face :underline '(:position t :color "#ff0000")))) + (dolist (face (list top-left-margin top-right-margin)) + (should-not (ebox-test--face-has-key-p face :overline))) + (dolist (face (list bottom-left-margin bottom-right-margin)) + (should-not (ebox-test--face-has-key-p face :underline))))) + +(ert-deftest ebox-pixel-cache-invalidates-when-display-signature-changes () + (ebox-test--reset-runtime-state) + (cl-letf (((symbol-function 'ebox--display-signature) + (let ((signature 'first)) + (lambda () signature)))) + (clrhash ebox--char-width-cache) + (ebox--clear-string-pixel-width-cache) + (setq ebox--space-pixel-cache nil) + (setq ebox--display-signature-cache nil) + (ebox--char-pixel-width ?A) + (should (= (hash-table-count ebox--char-width-cache) 1)) + (should (> (hash-table-count ebox--string-pixel-width-cache) 0)) + (cl-letf (((symbol-function 'ebox--display-signature) + (lambda () 'second))) + (ebox--ensure-display-cache-current) + (should (= (hash-table-count ebox--char-width-cache) 0)) + (should (= (hash-table-count ebox--string-pixel-width-cache) 0)) + (should-not ebox--space-pixel-cache) + (should (equal ebox--display-signature-cache 'second))))) + +(ert-deftest ebox-display-signature-includes-face-remapping () + (ebox-test--reset-runtime-state) + (let ((plain (ebox--display-signature)) + (face-remapping-alist '((default (:height 1.4))))) + (should-not (equal plain (ebox--display-signature))))) + +(ert-deftest ebox-pixel-width-respects-text-scale-with-fixed-spaces () + (ebox-test--reset-runtime-state) + (require 'face-remap) + (let* ((plain (ebox--string-pixel-width "AAAA")) + (fixed (ebox-pixel-space 30)) + (text-scale-mode-amount 2) + (scaled (ebox--string-pixel-width "AAAA")) + (scaled-with-fixed (ebox--string-pixel-width + (concat "AAAA" fixed)))) + (should (> scaled plain)) + (should (= scaled-with-fixed (+ scaled 30))))) + +(ert-deftest ebox-pixel-width-caches-plain-lines-during-render-context () + "Repeated plain text width checks should share the render-local cache." + (ebox-test--reset-runtime-state) + (let ((ebox--display-cache-validated t) + (ebox--render-string-pixel-width-cache (make-hash-table :test 'equal)) + (ebox--string-pixel-width-cache (make-hash-table :test 'equal)) + (ebox--string-pixel-width-cache-ring + (make-vector ebox--string-pixel-width-cache-max-entries nil)) + (ebox--string-pixel-width-cache-ring-index 0) + (calls 0)) + (cl-letf (((symbol-function 'string-pixel-width) + (lambda (string) + (cl-incf calls) + (length string)))) + (should (= (ebox--string-pixel-width "cached") 6)) + (should (= (ebox--string-pixel-width "cached") 6)) + (should (= calls 1))))) + +(ert-deftest ebox-pixel-width-reuses-plain-lines-across-render-transactions () + "Stable display transactions should share plain string measurements." + (ebox-test--reset-runtime-state) + (let ((ebox--display-signature-cache 'stable) + (probe (format "cross-render-%S" (make-symbol "probe"))) + (calls 0)) + (cl-letf (((symbol-function 'ebox--display-signature) + (lambda () 'stable)) + ((symbol-function 'string-pixel-width) + (lambda (string) + (cl-incf calls) + (length string)))) + (dotimes (_ 2) + (ebox--with-validated-display-cache + (ebox--string-pixel-width probe))) + (should (= calls 1))))) + +(ert-deftest ebox-pixel-width-reuses-face-only-lines-with-face-isolation () + "Display-scoped measurements should reuse equal faces without collisions." + (ebox-test--reset-runtime-state) + (let ((ebox--display-signature-cache 'stable) + (bold (propertize "face-cache" 'face 'bold)) + (italic (propertize "face-cache" 'face 'italic)) + (calls 0)) + (cl-letf (((symbol-function 'ebox--display-signature) + (lambda () 'stable)) + ((symbol-function 'string-pixel-width) + (lambda (string) + (cl-incf calls) + (if (eq (get-text-property 0 'face string) 'bold) + 10 + 20)))) + (should + (= (ebox--with-validated-display-cache + (ebox--string-pixel-width bold)) + 10)) + (should + (= (ebox--with-validated-display-cache + (ebox--string-pixel-width bold)) + 10)) + (should + (= (ebox--with-validated-display-cache + (ebox--string-pixel-width italic)) + 20)) + (should (= calls 2))))) + +(ert-deftest ebox-persistent-pixel-width-cache-stays-bounded () + "Display-scoped reusable string widths should evict at their entry cap." + (ebox-test--reset-runtime-state) + (let ((ebox--display-cache-validated t) + (ebox--render-string-pixel-width-cache nil) + (ebox--string-pixel-width-cache (make-hash-table :test 'equal)) + (ebox--string-pixel-width-cache-ring + (make-vector ebox--string-pixel-width-cache-max-entries nil)) + (ebox--string-pixel-width-cache-ring-index 0)) + (cl-letf (((symbol-function 'string-pixel-width) + (lambda (string) (length string)))) + (dotimes (index (+ ebox--string-pixel-width-cache-max-entries 17)) + (ebox--string-pixel-width (format "measurement-%d" index)))) + (should + (= (hash-table-count ebox--string-pixel-width-cache) + ebox--string-pixel-width-cache-max-entries)))) + +(ert-deftest ebox-string-max-pixel-width-works-without-render-cache () + "Multiline max-width measurement should not require a render-local cache." + (ebox-test--reset-runtime-state) + (let ((ebox--display-cache-validated t) + (ebox--render-string-max-pixel-width-cache nil)) + (cl-letf (((symbol-function 'ebox--string-pixel-width) + (lambda (line) + (length (substring-no-properties line))))) + (should (= (ebox--string-max-pixel-width "wide\nn") 4))))) + +(ert-deftest ebox-flex-string-width-caches-rendered-strings () + "Repeated flex width checks should reuse render-local rendered string widths." + (ebox-test--reset-runtime-state) + (let ((ebox--display-cache-validated t) + (ebox--render-string-pixel-width-cache (make-hash-table :test 'equal)) + (ebox--render-string-max-pixel-width-cache (make-hash-table :test 'eq)) + (rendered (concat (propertize "wide" 'face 'bold) + "\n" + (propertize "narrow" 'display '(space :width (2))))) + (calls 0)) + (cl-letf (((symbol-function 'ebox--string-pixel-width) + (lambda (line) + (cl-incf calls) + (length (substring-no-properties line))))) + (should (= (ebox--flex-string-width rendered) 6)) + (should (= (ebox--flex-string-width rendered) 6)) + (should (= calls 2))))) + +(ert-deftest ebox-flex-recache-skips-visited-source-subtrees () + "Repeated source recache calls should not walk the same subtree twice." + (ebox-test--reset-runtime-state) + (let* ((source (ebox-column + (ebox-create :content "A") + (ebox-create :content "B"))) + (ebox--render-recached-source-node-cache + (make-hash-table :test 'eq)) + (original-puthash (symbol-function 'puthash)) + (region-table-writes 0)) + (ebox-region-ids source) + (cl-letf (((symbol-function 'puthash) + (lambda (key value table) + (when (eq table ebox--region-box-table) + (cl-incf region-table-writes)) + (funcall original-puthash key value table)))) + (ebox--flex-recache-source-boxes source) + (ebox--flex-recache-source-boxes source)) + (should (= region-table-writes 2)))) + +(ert-deftest ebox-face-height-width-detection-handles-shrinking-text () + (ebox-test--reset-runtime-state) + (let ((ebox--face-height-width-cache nil) + (factor 0.7)) + (cl-letf (((symbol-function 'string-pixel-width) + (lambda (string) + (if (get-text-property 0 'face string) + 70 + 101)))) + (should (ebox--face-height-affects-width-p factor)) + (should (= (ebox--scaled-string-pixel-width "probe" factor) + 70))))) + +(ert-deftest ebox-buffer-property-patches-split-disjoint-property-changes () + "Root publication should touch only disjoint property-aware changes." + (let* ((old (concat (propertize "a" 'face 'bold) "bcd")) + (new (concat (propertize "a" 'face 'italic) + "b" + (propertize "c" 'display '(space :width (12))) + "d")) + (reordered (propertize "x" 'alpha 1 'beta '(2))) + (same (propertize "x" 'beta '(2) 'alpha 1))) + (should + (equal (ebox-buffer--minimal-property-patches old new) + '((:old-start 0 :old-end 1 :new-start 0 :new-end 1) + (:old-start 2 :old-end 3 :new-start 2 :new-end 3)))) + (should-not (ebox-buffer--minimal-property-patches reordered same)))) + +(ert-deftest ebox-buffer-property-patches-trim-length-change-boundaries () + "A length-changing publication should retain its exact common edges." + (should + (equal (ebox-buffer--minimal-property-patches "abXYcd" "abZcd") + '((:old-start 2 :old-end 4 :new-start 2 :new-end 3))))) + +(ert-deftest ebox-buffer-property-patches-preserve-internal-common-runs () + "Text edits should not absorb unchanged runs inside a length change." + (should + (equal (ebox-buffer--minimal-property-patches + "abXcdYef" "abZcdWQef") + '((:old-start 2 :old-end 3 :new-start 2 :new-end 3) + (:old-start 5 :old-end 6 :new-start 5 :new-end 7))))) + +(ert-deftest ebox-buffer-property-patches-align-equal-length-moves () + "Equal-length text shifts should retain aligned unchanged characters." + (should + (equal (ebox-buffer--minimal-property-patches "abc" "bca") + '((:old-start 0 :old-end 1 :new-start 0 :new-end 0) + (:old-start 3 :old-end 3 :new-start 2 :new-end 3))))) + +(ert-deftest ebox-buffer-myers-runs-preserve-shortest-edit-distance () + "Compressed alignment runs should retain the exact Myers edit distance." + (cl-labels + ((strings-of-length + (length) + (if (zerop length) + '("") + (cl-loop for prefix in (strings-of-length (1- length)) + append (list (concat prefix "a") + (concat prefix "b"))))) + (reference-distance + (old new) + (let ((memo (make-hash-table :test 'equal))) + (cl-labels + ((visit + (old-position new-position) + (or (gethash (cons old-position new-position) memo) + (let ((distance + (cond + ((= old-position (length old)) + (- (length new) new-position)) + ((= new-position (length new)) + (- (length old) old-position)) + ((= (aref old old-position) + (aref new new-position)) + (visit (1+ old-position) (1+ new-position))) + (t + (1+ (min (visit (1+ old-position) new-position) + (visit old-position + (1+ new-position)))))))) + (puthash (cons old-position new-position) + distance memo) + distance)))) + (visit 0 0))))) + (let ((strings + (cl-loop for length from 0 to 4 + append (strings-of-length length)))) + (dolist (old strings) + (dolist (new strings) + (let ((old-position 0) + (new-position 0) + (edits 0)) + (dolist (run (ebox-buffer--myers-text-matches old new)) + (let ((run-old (nth 0 run)) + (run-new (nth 1 run)) + (run-length (nth 2 run))) + (cl-incf edits (- run-old old-position)) + (cl-incf edits (- run-new new-position)) + (should + (equal (substring old run-old (+ run-old run-length)) + (substring new run-new (+ run-new run-length)))) + (setq old-position (+ run-old run-length) + new-position (+ run-new run-length)))) + (cl-incf edits (- (length old) old-position)) + (cl-incf edits (- (length new) new-position)) + (should (= edits (reference-distance old new))))))))) + +(ert-deftest ebox-buffer-root-property-patches-publish-only-changed-spans () + "Applying root patches should preserve exact output and report actual writes." + (with-temp-buffer + (let* ((old (concat (propertize "a" 'face 'bold) "bcd")) + (new (concat (propertize "a" 'face 'italic) + "b" + (propertize "c" 'display '(space :width (12))) + "d"))) + (insert old) + (let ((result + (ebox-buffer--replace-root-with-property-patches + '((1 . 5)) new))) + (should (equal-including-properties (buffer-string) new)) + (should (equal (plist-get result :published-buffer-spans) + '((1 . 2) (3 . 4)))) + (should-not (plist-get result :full-root-required-p)) + (should (equal (plist-get result :new-buffer-spans) + '((1 . 5)))))))) + +(ert-deftest ebox-buffer-rust-root-patches-bypass-full-string-diff () + "Rust-origin patches should publish directly without a complete target string." + (with-temp-buffer + (let* ((old (concat (propertize "a" 'face 'bold) "bcd")) + (replacement-a (propertize "a" 'face 'italic)) + (replacement-c + (propertize "c" 'display '(space :width (12)))) + (native-patch + (list :native-patch t + :patch-origin 'rust + :base-character-count 4 + :target-character-count 4 + :target-line-count 1 + :patches + (list + (list :old-start 0 :old-end 1 + :new-start 0 :new-end 1 + :replacement replacement-a) + (list :old-start 2 :old-end 3 + :new-start 2 :new-end 3 + :replacement replacement-c))))) + (insert old) + (cl-letf (((symbol-function 'ebox-buffer--minimal-property-patches) + (lambda (&rest _) + (ert-fail "Rust patch path rebuilt the complete diff")))) + (let ((result + (ebox-buffer--apply-root-property-patches + '((1 . 5)) native-patch))) + (should + (equal-including-properties + (buffer-string) + (concat replacement-a "b" replacement-c "d"))) + (should (equal (plist-get result :published-buffer-spans) + '((1 . 2) (3 . 4)))) + (should (eq (plist-get result :patch-origin) 'rust)) + (should (= (plist-get result :full-frame-bytes) 0))))))) + +(ert-deftest ebox-visibility-hidden-suppresses-ink-and-keeps-footprint () + "A hidden box renders blank with identical text, lengths, and metadata." + (let* ((make-box + (lambda (visibility) + (ebox-create :content "Hello" :width (list 90) :border t + :border-color "#AA3311" :bgcolor "#223344" + :color "#FFFFFF" :padding '(0 1) + :visibility visibility))) + (visible (ebox-render (funcall make-box 'visible))) + (hidden (ebox-render (funcall make-box 'hidden)))) + (should (equal (substring-no-properties visible) + (substring-no-properties hidden))) + (should (equal (mapcar #'length (ebox-string-lines visible)) + (mapcar #'length (ebox-string-lines hidden)))) + ;; No character carries a face, every character renders under a + ;; display spec, and each line's blank cover is measured to the + ;; visible line's exact pixel width. + (dotimes (index (length hidden)) + (unless (eq (aref hidden index) ?\n) + (should-not (get-text-property index 'face hidden)) + (should (get-text-property index 'display hidden)))) + (cl-loop for visible-line in (ebox-string-lines visible) + for hidden-line in (ebox-string-lines hidden) + do (should (= (ebox-string-pixel-width visible-line) + (ebox-string-pixel-width hidden-line)))) + ;; Region metadata still identifies the same owner surfaces at the + ;; content characters. + (let ((position (string-match "Hello" + (substring-no-properties visible)))) + (should position) + (should (get-text-property position 'ebox-content-owner visible)) + (should (get-text-property position 'ebox-content-owner hidden))))) + +(ert-deftest ebox-visibility-toggle-is-local-and-restores-exactly () + "Hiding and showing one region never widens beyond its owner. +The buffer text never changes; showing again restores the visible +publication with properties intact." + (let* ((buffer (generate-new-buffer " *ebox-visibility*")) + (root (ebox-create + :width (list 300) + :ebox-content-node + (ebox-column + (ebox-create :content "Header line" :id 'header + :width (list 300)) + (ebox-create :content "Secret content" :id 'secret + :width (list 300) :border t + :bgcolor "#224466" :color "#FFFFFF") + (ebox-create :content "Footer line" :id 'footer + :width (list 300)))))) + (unwind-protect + (progn + (ebox-render-to-buffer buffer root) + (let ((before (with-current-buffer buffer (buffer-string)))) + (ebox-selector-update-buffer buffer "#secret" + :visibility 'hidden) + (let ((report (ebox-test--buffer-update-report))) + (should (memq (plist-get report :strategy) + '(span-patch owner-rerender paint-patch))) + (should (= (plist-get report :patch-count) 1))) + (should (equal (with-current-buffer buffer + (substring-no-properties (buffer-string))) + (substring-no-properties before))) + (ebox-selector-update-buffer buffer "#secret" + :visibility 'visible) + (let ((report (ebox-test--buffer-update-report))) + (should (memq (plist-get report :strategy) + '(span-patch owner-rerender paint-patch))) + (should (= (plist-get report :patch-count) 1))) + (should (equal-including-properties + (with-current-buffer buffer (buffer-string)) + before)))) + (kill-buffer buffer)))) + +(ert-deftest ebox-parent-border-color-patch-never-recolors-descendants () + "A parent border-color paint patch touches only the owner's chrome. +Border surfaces belong to each box: a full render never propagates an +ancestor border color into a descendant's border runs, so the cheap +face patch must match that exactly (issue014)." + (ebox-test--reset-runtime-state) + (let* ((make-tree + (lambda (parent-color) + (ebox-create + :id 'parent :width (list 400) + :border (list '(4) 'solid parent-color) + :padding '(1 (12)) + :ebox-content-node + (ebox-column + (ebox-create :id 'child :content "CHILD" :width (list 200) + :border '((4) solid "#00AA00")))))) + (visual-runs + (lambda (buffer role) + (with-current-buffer buffer + (let (runs (position (point-min))) + (while (< position (point-max)) + (when (get-text-property position role) + (push (list (line-number-at-pos position) + (get-text-property position role) + (ebox--face-visual-attributes + (get-text-property position 'face))) + runs)) + (setq position + (or (next-single-property-change position role) + (point-max)))) + (nreverse runs))))) + (patched (generate-new-buffer " *i14-patched*")) + (reference (generate-new-buffer " *i14-reference*"))) + (unwind-protect + (progn + (ebox-render-to-buffer patched (funcall make-tree "#AA0000")) + (ebox-selector-update-buffer patched "#parent" + :border-color "#0000AA") + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'paint-patch)) + (ebox-test--reset-runtime-state) + (ebox-render-to-buffer reference (funcall make-tree "#0000AA")) + (dolist (role '(ebox-bl ebox-br ebox-bt ebox-bb)) + (ert-info ((format "border role %S" role)) + (should (equal + (mapcar (lambda (run) (cons (car run) (caddr run))) + (funcall visual-runs patched role)) + (mapcar (lambda (run) (cons (car run) (caddr run))) + (funcall visual-runs reference role))))))) + (kill-buffer patched) + (kill-buffer reference)))) + +;;; ebox-core-render-tests.el ends here diff --git a/tests/ebox-docs-contract-tests.el b/tests/ebox-docs-contract-tests.el new file mode 100644 index 0000000..7d7cd83 --- /dev/null +++ b/tests/ebox-docs-contract-tests.el @@ -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 diff --git a/tests/ebox-dsl-tests.el b/tests/ebox-dsl-tests.el new file mode 100644 index 0000000..e76e6a7 --- /dev/null +++ b/tests/ebox-dsl-tests.el @@ -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 diff --git a/tests/ebox-examples-tests.el b/tests/ebox-examples-tests.el new file mode 100644 index 0000000..709797a --- /dev/null +++ b/tests/ebox-examples-tests.el @@ -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 diff --git a/tests/ebox-flex-tests.el b/tests/ebox-flex-tests.el new file mode 100644 index 0000000..ee0716f --- /dev/null +++ b/tests/ebox-flex-tests.el @@ -0,0 +1,1222 @@ +;;; ebox-flex-tests.el --- Ebox flex layout tests -*- lexical-binding: t; -*- + +(require 'cl-lib) +(require 'ert) + +(load-file (expand-file-name "../ebox.el" (file-name-directory (or load-file-name buffer-file-name)))) + +(defun ebox-flex-test--render (node) + "Render NODE once for flex tests." + (ebox-render node)) + +(defun ebox-flex-test--plain-lines (node) + "Return rendered NODE as plain lines." + (ebox-string-lines (substring-no-properties (ebox-flex-test--render node)))) + +(defun ebox-flex-test--line-widths (node) + "Return rendered NODE pixel widths line by line." + (mapcar #'ebox--string-pixel-width + (ebox-string-lines (ebox-flex-test--render node)))) + +(defun ebox-flex-test--region-property-width (string region-id property) + "Return max PROPERTY span width for REGION-ID in STRING." + (let ((pos 0) + (len (length string)) + (max-width 0)) + (while (< pos len) + (if (equal (get-text-property pos property string) region-id) + (let ((end (or (next-single-property-change + pos property string len) + len))) + (setq max-width + (max max-width + (string-pixel-width (substring string pos end)))) + (setq pos end)) + (setq pos (or (next-single-property-change + pos 'ebox-content string len) + len)))) + max-width)) + +(defun ebox-flex-test--region-content-width (string region-id) + "Return max content span width for REGION-ID in STRING." + (ebox-flex-test--region-property-width string region-id 'ebox-content)) + +(defun ebox-flex-test--content-widths (node) + "Return max content widths for NODE region ids." + (let* ((ids (ebox-region-ids node)) + (rendered (ebox-flex-test--render node))) + (mapcar (lambda (id) + (ebox-flex-test--region-content-width rendered id)) + ids))) + +(defun ebox-flex-test--face-has-key-value-p (face key value) + "Return non-nil when FACE contains KEY with VALUE." + (cond + ((null face) nil) + ((and (listp face) (keywordp (car face))) + (equal (plist-get face key) value)) + ((listp face) + (cl-some (lambda (item) + (ebox-flex-test--face-has-key-value-p item key value)) + face)) + (t nil))) + +(defun ebox-flex-test--face-at-property (string property) + "Return first face in STRING at PROPERTY." + (let ((pos 0) + (len (length string)) + face) + (while (and (< pos len) (not face)) + (when (get-text-property pos property string) + (setq face (get-text-property pos 'face string))) + (setq pos (or (next-single-property-change pos property string) + (1+ pos)))) + face)) + +(defun ebox-flex-test--property-values (string property) + "Return distinct values for PROPERTY in STRING." + (let ((pos 0) + (len (length string)) + values) + (while (< pos len) + (when-let ((value (get-text-property pos property string))) + (unless (memq value values) + (setq values (append values (list value))))) + (setq pos (1+ pos))) + values)) + +(ert-deftest ebox-flex-module-preserves-item-participation () + "Extracted flex code should preserve item participation semantics." + (let* ((node (ebox-build + '(flex :width (240) + (item :flex-grow 1 + (box :content "Grow" :width (40))) + (item (box :content "Fixed" :width (40)))))) + (rendered (ebox-render node))) + (should (string-match-p "Grow" rendered)) + (should (string-match-p "Fixed" rendered)))) + +(ert-deftest ebox-flex-accepts-flex-props-on-direct-child-box () + "Flex item participation props may live directly on child boxes." + (let* ((layout (ebox-flex + :width 300 + (ebox-create :content "A" :flex-grow 1 :flex-basis '(80)) + (ebox-create :content "B" :flex-grow 1 :flex-basis '(80)))) + (rendered (let ((ebox-viewport-width 300)) + (ebox-render layout))) + (widths (mapcar #'ebox--string-pixel-width + (ebox-string-lines rendered)))) + (should (cl-every (lambda (width) (<= width 300)) widths)) + (should (string-match-p "A" (substring-no-properties rendered))) + (should (string-match-p "B" (substring-no-properties rendered))))) + +(ert-deftest ebox-flex-grows-items-to-fill-row-container () + "Flex grow should distribute extra row width by grow factors." + (let* ((layout + (ebox-flex :width '(300) + (ebox-flex-item + (ebox-create :content "A" :width '(80)) + :flex-grow 1 :flex-basis '(80)) + (ebox-flex-item + (ebox-create :content "B" :width '(80)) + :flex-grow 2 :flex-basis '(80)))) + (widths (ebox-flex-test--content-widths layout))) + (should (= (length widths) 2)) + (should (= (apply #'+ widths) 300)) + (should (> (cadr widths) (car widths))) + (should (equal (ebox-flex-test--line-widths layout) '(300))))) + +(ert-deftest ebox-flex-margin-box-target-subtracts-inline-margins-for-border-box () + "A row flex target should include a border-box item's inline margins." + (let ((layout + (ebox-flex :width '(200) :align-items 'flex-start + (ebox-create :content "A" + :width '(40) + :box-sizing 'border-box + :margin-left '(20) + :margin-right '(20) + :flex-grow 1)))) + (should (equal (ebox-flex-test--line-widths layout) '(200))))) + +(ert-deftest ebox-flex-margin-box-target-subtracts-block-margins-for-border-box () + "A column flex target should include a border-box item's block margins." + (let ((layout + (ebox-flex :width '(80) :height 6 + :flex-direction 'column + :align-items 'flex-start + (ebox-create :content "A" + :width '(40) + :height 1 + :box-sizing 'border-box + :margin-top 1 + :margin-bottom 1 + :flex-grow 1)))) + (should (= (ebox-string-height (ebox-flex-test--render layout)) 6)))) + +(ert-deftest ebox-flex-margin-box-target-preserves-content-box-outer-sides () + "Content-box flex targets should subtract padding, borders, and margins." + (let ((layout + (ebox-flex :width '(200) :align-items 'flex-start + (ebox-create :content "A" + :width '(40) + :box-sizing 'content-box + :padding-left '(10) + :padding-right '(10) + :border-left '(5) + :border-right '(5) + :margin-left '(20) + :margin-right '(20) + :flex-grow 1)))) + (should (equal (ebox-flex-test--line-widths layout) '(200))))) + +(ert-deftest ebox-flex-row-render-uses-sized-line-concat () + "Row flex rendering should not remeasure known-width line parts." + (let* ((layout + (ebox-flex :width '(360) + (ebox-create :content "Alpha" :width '(80) :height 2) + (ebox-create :content "Beta" :width '(80) :height 2) + (ebox-create :content "Gamma" :width '(80) :height 2))) + (concat-calls 0) + (original (symbol-function 'ebox--lines-concat-horizontal))) + (cl-letf (((symbol-function 'ebox--lines-concat-horizontal) + (lambda (&rest args) + (cl-incf concat-calls) + (apply original args)))) + (should (equal (ebox-flex-test--line-widths layout) '(360 360)))) + (should (= concat-calls 0)))) + +(ert-deftest ebox-flex-shrinks-items-by-shrink-factors () + "Flex shrink should remove more width from larger shrink factors." + (let* ((layout + (ebox-flex :width '(150) + (ebox-flex-item + (ebox-create :content "A" :width '(100)) + :flex-shrink 1 :flex-basis '(100)) + (ebox-flex-item + (ebox-create :content "B" :width '(100)) + :flex-shrink 3 :flex-basis '(100)))) + (widths (ebox-flex-test--content-widths layout))) + (should (= (length widths) 2)) + (should (= (apply #'+ widths) 150)) + (should (> (car widths) (cadr widths))))) + +(ert-deftest ebox-flex-basis-supports-content-and-intrinsic-keywords () + "Flex basis content and intrinsic keywords should use content-derived sizes." + (let* ((content "aa bbbb") + (max-content (ebox--string-pixel-width content)) + (min-content (ebox--string-pixel-width "bbbb")) + (layout + (ebox-flex :width '(300) + (ebox-flex-item + (ebox-create :content content :width '(160) :wrap-mode 'word) + :flex-basis 'content) + (ebox-flex-item + (ebox-create :content content :width '(160) :wrap-mode 'word) + :flex-basis 'min-content))) + (widths (ebox-flex-test--content-widths layout))) + (should (equal widths (list max-content min-content))))) + +(ert-deftest ebox-flex-grow-redistributes-space-after-max-width-clamp () + "A max-width-clamped item should freeze and leave space for flexible siblings." + (let* ((layout + (ebox-flex :width '(200) + (ebox-flex-item + (ebox-create :content "A" :width '(60) :max-width '(80)) + :flex-grow 1 :flex-basis '(60)) + (ebox-flex-item + (ebox-create :content "B" :width '(60)) + :flex-grow 1 :flex-basis '(60)))) + (widths (ebox-flex-test--content-widths layout))) + (should (equal widths '(80 120))) + (should (equal (ebox-flex-test--line-widths layout) '(200))))) + +(ert-deftest ebox-flex-grow-starts-from-flex-base-before-min-width-clamp () + "Grow distribution should start from flex base sizes, not hypothetical sizes." + (let* ((layout + (ebox-flex :width '(200) + (ebox-flex-item + (ebox-create :content "A" :width '(50) :min-width '(80)) + :flex-grow 1 :flex-basis '(50)) + (ebox-flex-item + (ebox-create :content "B" :width '(50)) + :flex-grow 1 :flex-basis '(50)))) + (widths (ebox-flex-test--content-widths layout))) + (should (equal widths '(100 100))) + (should (equal (ebox-flex-test--line-widths layout) '(200))))) + +(ert-deftest ebox-flex-grow-factors-below-one-partially-fill-free-space () + "Grow factors whose sum is below 1 should leave some free space unclaimed." + (let* ((layout + (ebox-flex :width '(300) + (ebox-flex-item + (ebox-create :content "A" :width '(50)) + :flex-grow 0.25) + (ebox-flex-item + (ebox-create :content "B" :width '(50)) + :flex-grow 0.25))) + (widths (ebox-flex-test--content-widths layout))) + (should (equal widths '(100 100))) + (should (equal (ebox-flex-test--line-widths layout) '(300))))) + +(ert-deftest ebox-flex-shrink-redistributes-space-after-min-width-clamp () + "A min-width-clamped item should freeze and push shrinkage to siblings." + (let* ((layout + (ebox-flex :width '(150) + (ebox-flex-item + (ebox-create :content "A" :width '(100) :min-width '(80)) + :flex-shrink 1 :flex-basis '(100)) + (ebox-flex-item + (ebox-create :content "B" :width '(100)) + :flex-shrink 1 :flex-basis '(100)))) + (widths (ebox-flex-test--content-widths layout))) + (should (equal widths '(80 70))) + (should (equal (ebox-flex-test--line-widths layout) '(150))))) + +(ert-deftest ebox-flex-shrink-starts-from-flex-base-before-max-width-clamp () + "Shrink distribution should start from flex base sizes, not hypothetical sizes." + (let* ((layout + (ebox-flex :width '(150) + (ebox-flex-item + (ebox-create :content "A" :width '(100) :max-width '(80) + :wrap-mode 'word) + :flex-shrink 1 :flex-basis '(100)) + (ebox-flex-item + (ebox-create :content "B" :width '(100) :wrap-mode 'word) + :flex-shrink 1 :flex-basis '(100)))) + (widths (ebox-flex-test--content-widths layout))) + (should (equal widths '(75 75))) + (should (equal (ebox-flex-test--line-widths layout) '(150))))) + +(ert-deftest ebox-flex-shrink-factors-below-one-partially-shrink-overflow () + "Shrink factors whose sum is below 1 should leave some overflow unclaimed." + (let* ((layout + (ebox-flex :width '(150) + (ebox-flex-item + (ebox-create :content "A" :width '(100)) + :flex-shrink 0.25) + (ebox-flex-item + (ebox-create :content "B" :width '(100)) + :flex-shrink 0.25))) + (widths (ebox-flex-test--content-widths layout))) + (should (equal widths '(87 88))) + (should (equal (ebox-flex-test--line-widths layout) '(175))))) + +(ert-deftest ebox-flex-properties-reject-negative-values () + "Flex width, basis, grow, and shrink factors should reject negatives." + (should-error + (ebox-flex-test--render + (ebox-flex :width '(-1) + (ebox-create :content "A" :width '(20)))) + :type 'error) + (should-error + (ebox-flex-test--render + (ebox-flex + (ebox-flex-item (ebox-create :content "A" :width '(20)) + :flex-grow -1))) + :type 'error) + (should-error + (ebox-flex-test--render + (ebox-flex + (ebox-flex-item (ebox-create :content "A" :width '(20)) + :flex-shrink -1))) + :type 'error) + (should-error + (ebox-flex-test--render + (ebox-flex + (ebox-flex-item (ebox-create :content "A" :width '(20)) + :flex-basis '(-1)))) + :type 'error)) + +(ert-deftest ebox-flex-wraps-rows-with-column-gap () + "Row wrap should create new flex lines using column gaps." + (let* ((layout + (ebox-flex :width '(210) :flex-wrap 'wrap :column-gap '(10) + (ebox-create :content "A" :width '(100)) + (ebox-create :content "B" :width '(100)) + (ebox-create :content "C" :width '(100)))) + (lines (ebox-flex-test--plain-lines layout))) + (should (= (length lines) 2)) + (should (string-match-p "A.*B" (car lines))) + (should (string-match-p "C" (cadr lines))) + (should (equal (ebox-flex-test--line-widths layout) '(210 210))))) + +(ert-deftest ebox-flex-row-wrap-measures-column-items-at-natural-width () + "Column flex items should not stretch to the full row before line breaking." + (let* ((layout + (ebox-build + '(flex :flex-flow (row wrap) :column-gap (12) :width (800) + (column + (box :content "one" :width (220)) + (box :content "A" :width (42))) + (column + (box :content "two" :width (220)) + (box :content "B" :width (42))) + (column + (box :content "three" :width (220)) + (box :content "C" :width (42)))))) + (lines (ebox-flex-test--plain-lines layout))) + (should (= (length lines) 2)) + (should (string-match-p "one.*two.*three" (car lines))) + (should (string-match-p "A.*B.*C" (cadr lines))))) + +(ert-deftest ebox-flex-rerenders-composite-item-for-assigned-viewport () + "Composite flex items should re-render internals for final item width." + (let* ((layout + (ebox-build + '(flex :flex-flow (row wrap) :column-gap (12) :width (800) + (column + (box :content "wrap" :id "title" :max-width (293) + :padding (0 (10)) :border "#5E7F6A" + :text-align center) + (flex :id "body" :max-width (293) :padding (0 (8)) + :border "#5E7F6A" :border-top-p nil + :flex-wrap wrap :column-gap (8) + (item :flex-shrink 0 + (box :content "A 118" :width (118))) + (item :flex-shrink 0 + (box :content "B 118" :width (118))) + (item :flex-shrink 0 + (box :content "C 118" :width (118)))))))) + (rendered (ebox-flex-test--render layout)) + (ids (ebox-region-ids layout)) + (title-width (ebox-flex-test--region-property-width + rendered (car ids) 'ebox-bt)) + (body-width (ebox-flex-test--region-property-width + rendered (cadr ids) 'ebox-bb))) + (should (> title-width 200)) + (should (> body-width 200)))) + +(ert-deftest ebox-flex-wrapper-bounds-preformatted-child-layout () + "Flex container boxes should not let child layout overflow widen them." + (let* ((ebox-viewport-width 500) + (layout + (ebox-build + '(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" + :text-align center) + (flex :max-width (440) :padding (0 (8)) + :border "#6F6A95" :border-top-p nil + :flex-flow (row wrap) :gap (1 (12)) + (item (box :content "A" :width (170))) + (item (box :content "B" :width (170))) + (item (box :content "C wraps" :width (170))))) + (column + (box :content ":flex-flow (column wrap)" :max-width (440) + :padding (0 (10)) :border "#6F6A95" + :text-align center) + (flex :max-width (440) :padding (0 (8)) + :border "#6F6A95" :border-top-p nil + :flex-flow (column wrap) :gap (1 (12)) + (item (box :content "A" :width (140))) + (item (box :content "B" :width (140))) + (item (box :content "C new column" :width (140)))))))) + (rendered (ebox-flex-test--render layout)) + (line-widths (ebox-flex-test--line-widths layout)) + (border-widths + (cl-loop for id in (ebox-flex-test--property-values rendered + 'ebox-bb) + collect (ebox-flex-test--region-property-width + rendered id 'ebox-bb)))) + (should (cl-every (lambda (width) (<= width ebox-viewport-width)) + line-widths)) + (should (cl-some (lambda (width) (> width 400)) border-widths)))) + +(ert-deftest ebox-flex-wraps-with-max-width-clamped-hypothetical-size () + "Line collection should use max-width-clamped hypothetical main sizes." + (let* ((layout + (ebox-flex :width '(210) :flex-wrap 'wrap :column-gap '(10) + (ebox-flex-item + (ebox-create :content "A" :width '(200) :max-width '(100)) + :flex-basis '(200)) + (ebox-flex-item + (ebox-create :content "B" :width '(200) :max-width '(100)) + :flex-basis '(200)))) + (lines (ebox-flex-test--plain-lines layout)) + (widths (ebox-flex-test--content-widths layout))) + (should (= (length lines) 1)) + (should (equal widths '(100 100))) + (should (equal (ebox-flex-test--line-widths layout) '(210))))) + +(ert-deftest ebox-flex-omitted-width-stretches-to-viewport-when-bound () + "A row flex container without :width should use the current viewport." + (let* ((ebox-viewport-width 240) + (layout + (ebox-flex :flex-wrap 'wrap :column-gap '(10) + (ebox-create :content "A" :width '(100)) + (ebox-create :content "B" :width '(100)) + (ebox-create :content "C" :width '(100))))) + (should (equal (ebox-flex-test--line-widths layout) '(240 240))))) + +(ert-deftest ebox-flex-stretch-width-is-independent-of-container-wrapper () + "Stretch width should use the viewport with or without a neutral wrapper." + (let* ((ebox-viewport-width 200) + (plain + (ebox-flex :width 'stretch + (ebox-create :content "A" :width '(40)))) + (padded + (ebox-flex :width 'stretch :padding 0 + (ebox-create :content "A" :width '(40))))) + (should-not (plist-get plain :box)) + (should (plist-get padded :box)) + (should (equal (mapcar #'ebox-flex-test--line-widths + (list plain padded)) + '((200) (200)))))) + +(ert-deftest ebox-flex-indefinite-width-keeps-natural-size-across-wrapper () + "Indefinite width should stay natural with or without a neutral wrapper." + (let* ((ebox-viewport-width nil) + (cases + (mapcar + (lambda (width) + (let ((plain + (ebox-flex :width width + (ebox-create :content "A" :width '(40)))) + (padded + (ebox-flex :width width :padding 0 + (ebox-create :content "A" :width '(40))))) + (should-not (plist-get plain :box)) + (should (plist-get padded :box)) + (list width plain padded))) + '(stretch auto))) + (results + (mapcar + (lambda (case) + (list (car case) + (car (ebox-flex-test--line-widths (nth 1 case))) + (car (ebox-flex-test--line-widths (nth 2 case))))) + cases))) + (should (equal results + '((stretch 40 40) + (auto 40 40)))))) + +(ert-deftest ebox-flex-omitted-wrapper-width-uses-viewport-content-box () + "A styled flex container should lay out inside its viewport-sized box." + (let* ((ebox-viewport-width 240) + (layout + (ebox-flex :padding-inline '(20) + :border-left '(10) + :border-right '(10) + :justify-content 'space-between + (ebox-create :content "A" :width '(40)) + (ebox-create :content "B" :width '(40)))) + (rendered (ebox-flex-test--render layout)) + (display-widths + (cl-loop for index below (length rendered) + for display = (get-text-property index 'display rendered) + when (and (consp display) + (eq (car display) 'space)) + collect (car (plist-get (cdr display) :width))))) + (should (equal (ebox-flex-test--line-widths layout) '(240))) + (should (member 100 display-widths)))) + +(ert-deftest ebox-flex-viewport-wrapper-width-uses-content-box () + "A viewport-sized styled flex container should use its content box for lines." + (let* ((ebox-viewport-width 240) + (layout + (ebox-flex :width '(viewport) + :padding-inline '(20) + :border-left '(10) + :border-right '(10) + :flex-wrap 'wrap + (ebox-flex-item (ebox-create :content "A" :width '(180))) + (ebox-flex-item (ebox-create :content "B" :width '(180)))))) + (should (cl-every (lambda (width) (<= width ebox-viewport-width)) + (ebox-flex-test--line-widths layout))))) + +(ert-deftest ebox-flex-auto-height-is-independent-of-container-wrapper () + "Auto height should preserve natural child height across wrapper branches." + (let* ((plain + (ebox-flex :width '(80) :height 'auto + (ebox-create :content "A\nB" :width '(40)))) + (padded + (ebox-flex :width '(80) :height 'auto :padding 0 + (ebox-create :content "A\nB" :width '(40)))) + (results + (mapcar + (lambda (layout) + (condition-case err + (ebox-string-height (ebox-flex-test--render layout)) + (error err))) + (list plain padded)))) + (should-not (plist-get plain :box)) + (should (plist-get padded :box)) + (should (equal results '(2 2))))) + +(ert-deftest ebox-flex-reuses-measured-non-box-child-render () + "Flex should not fully rerender a measured non-box child during one render." + (let* ((child (ebox-column + (ebox-create :content "A" :width '(100)) + (ebox-create :content "B" :width '(100)))) + (layout (ebox-flex :width '(240) :flex-wrap 'wrap + child + (ebox-column + (ebox-create :content "C" :width '(100)) + (ebox-create :content "D" :width '(100))))) + (calls 0) + (original-render (symbol-function 'ebox-render))) + (cl-letf (((symbol-function 'ebox-render) + (lambda (node) + (when (eq node child) + (cl-incf calls)) + (funcall original-render node)))) + (ebox-render layout)) + (should (= calls 1)))) + +(ert-deftest ebox-flex-reuses-measured-box-render-when-size-unchanged () + "Flex should reuse a measured box when target main size is unchanged." + (let* ((box (ebox-create :content "A" :width '(80))) + (layout + (ebox-flex :width '(220) :height 3 + :flex-wrap 'nowrap :align-items 'flex-start + box + (ebox-create :content "B" :width '(80)))) + (calls 0) + (original (symbol-function 'ebox--render-box))) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (candidate) + (when (equal (ebox-get candidate :content) "A") + (cl-incf calls)) + (funcall original candidate)))) + (ebox-render layout)) + (should (= calls 1)))) + +(ert-deftest ebox-flex-reuses-natural-sized-render-when-cross-is-unchanged () + "Final cross sizing should reuse an equal natural-sized item render." + (let* ((box (ebox-create :content "A" :width '(80) :flex-grow 1)) + (region-id (car (ebox-region-ids box))) + (layout (ebox-flex :width '(220) :align-items 'flex-start box)) + (calls 0) + (original (symbol-function 'ebox--render-box)) + rendered) + (cl-letf (((symbol-function 'ebox--render-box) + (lambda (candidate) + (when (equal (ebox-get candidate :content) "A") + (cl-incf calls)) + (funcall original candidate)))) + (setq rendered (ebox-render layout))) + (should (equal (mapcar #'ebox--string-pixel-width + (ebox-string-lines rendered)) + '(220))) + (should (= (ebox-flex-test--region-content-width rendered region-id) + 220)) + (should (= calls 2)))) + +(ert-deftest ebox-flex-reuses-natural-non-box-render-before-cross-padding () + "Final cross padding should reuse a naturally sized non-box render." + (let* ((source (ebox-column + (ebox-create :content "A" :width '(80)) + (ebox-create :content "A2" :width '(80)))) + (layout + (ebox-flex :width '(220) + (ebox-flex-item source :flex-grow 1 :flex-basis '(80)) + (ebox-create :content "B" :width '(80) :height 4))) + (calls 0) + (original (symbol-function 'ebox-render)) + rendered) + (cl-letf (((symbol-function 'ebox-render) + (lambda (node) + (when (eq node source) + (cl-incf calls)) + (funcall original node)))) + (setq rendered (ebox-render layout))) + (should (= (ebox-string-height rendered) 4)) + (should (equal (mapcar #'ebox--string-pixel-width + (ebox-string-lines rendered)) + '(220 220 220 220))) + (should (= calls 2)))) + +(ert-deftest ebox-flex-natural-reuse-preserves-stretched-visible-overflow-ownership () + "Stretch sizing should absorb visible overflow into the final box region." + (let* ((box (ebox-create :content "A\nB" :width '(80) :height 1 + :overflow 'visible :flex-grow 1)) + (region-id (car (ebox-region-ids box))) + (rendered (ebox-render (ebox-flex :width '(120) box))) + (lines (ebox-string-lines rendered))) + (should (= (length lines) 2)) + (should (= (ebox-flex-test--region-content-width + (nth 1 lines) region-id) + 120)))) + +(ert-deftest ebox-flex-natural-reuse-preserves-stretched-scroll-state () + "Stretch sizing should publish the final box geometry to scroll state." + (let* ((ebox-viewport-width 120) + (ebox-viewport-height 2) + (layout + (ebox-build + '(flex :width (120) + (box :id "root" :width (80) :height (viewport-height) + :overflow scroll :flex-grow 1 + (column :width (80) + (box :content "A" :height 1) + (box :content "B" :height 1) + (box :content "C" :height 1) + (box :content "D" :height 1)))))) + (root-id + (plist-get (car (ebox-selector-query-all layout "#root")) + :region-id))) + (ebox-render layout) + (let ((state (ebox-scroll-state root-id))) + (should (equal (ebox-get (plist-get state :box) :height) 2)) + (should-not (plist-get state :content-lines-complete-p)) + (should-not (plist-get state :render-content-prefix))))) + +(ert-deftest ebox-flex-natural-row-padding-uses-rendered-overflow-width () + "Natural cross padding should preserve a row item's rendered overflow width." + (let* ((viewport-child + (ebox-create :content "A" :width '(viewport) + :box-sizing 'content-box + :padding-left '(10) :padding-right '(10))) + (source + (ebox-column viewport-child + (ebox-create :content "A2" :width '(20)))) + (layout + (ebox-flex :width '(240) + (ebox-flex-item source :flex-basis '(20)) + (ebox-create :content "B" :width '(20) :height 4))) + (widths + (mapcar #'ebox--string-pixel-width + (ebox-string-lines (ebox-render layout))))) + (should (= (length widths) 4)) + (should (= (nth 2 widths) (nth 0 widths))) + (should (= (nth 3 widths) (nth 0 widths))))) + +(ert-deftest ebox-flex-sized-persistent-hit-populates-pass-local-cache () + "Persistent sized renders should become reusable in the current pass." + (let* ((source (ebox-create :content "A" :width '(80))) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (first (car (ebox--flex-collect-items (list source) 'row 220))) + (main (plist-get first :target)) + (cache-key (list 'row main nil 'flex-start))) + (ebox--flex-render-sized-entry first 'row main nil 'flex-start) + (should (assoc cache-key (plist-get first :render-cache))) + (let ((second (car (ebox--flex-collect-items (list source) 'row 220)))) + (should-not (assoc cache-key (plist-get second :render-cache))) + (ebox--flex-render-sized-entry second 'row main nil 'flex-start) + (should (assoc cache-key (plist-get second :render-cache)))))) + +(defun ebox-flex-test--face-signature (string) + "Return a printed list of every face value carried by STRING." + (let ((faces nil) + (pos 0) + (len (length string))) + (while (< pos len) + (push (get-text-property pos 'face string) faces) + (setq pos (or (next-single-property-change pos 'face string) len))) + (format "%S" (nreverse faces)))) + +(ert-deftest ebox-flex-measurement-cache-honors-content-properties () + "Property-only content changes must invalidate flex measurement reuse." + (let* ((child (ebox-create + :content (propertize "AB" 'face '(:foreground "red")) + :width '(80))) + (layout (ebox-flex :width '(160) child)) + (ebox--render-cache-table (make-hash-table :test 'equal))) + (let ((ebox--render-cache-signature-cache (make-hash-table :test 'eq))) + (ebox-render layout)) + (plist-put child :content + (propertize "AB" 'face '(:foreground "blue"))) + (let* ((ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (faces (ebox-flex-test--face-signature (ebox-render layout)))) + (should (string-match-p "blue" faces)) + (should-not (string-match-p "red" faces))))) + +(ert-deftest ebox-flex-fixed-basis-wrapped-box-skips-intrinsic-render () + "Known flex metrics should not render a wrapped fixed-basis box twice." + (let* ((source + (ebox-create + :content "A responsive wrapped child" + :width '(viewport) :min-width 0 :height 4 :wrap-mode 'word)) + (item + (ebox-flex-item + source :flex-grow 1 :flex-shrink 1 :flex-basis '(80))) + (layout (ebox-flex :width '(240) :height 4 item)) + (ebox-viewport-width 240) + (measurement-renders 0) + fast slow) + (let ((original + (symbol-function 'ebox--flex-render-source-for-measurement))) + (cl-letf (((symbol-function 'ebox--flex-render-source-for-measurement) + (lambda (&rest arguments) + (cl-incf measurement-renders) + (apply original arguments)))) + (setq fast (ebox-render layout)))) + (should (zerop measurement-renders)) + (cl-letf (((symbol-function 'ebox--flex-unrendered-measurement) + (lambda (&rest _) nil))) + (setq slow (ebox-render layout))) + (should (equal-including-properties fast slow)))) + +(ert-deftest ebox-flex-fixed-basis-nowrap-box-keeps-intrinsic-measurement () + "No-wrap auto minimums should retain their intrinsic render measurement." + (let* ((source + (ebox-create :content "UNBREAKABLE" :min-width 0 :height 1 + :wrap-mode nil)) + (props + (ebox--flex-item-props + (ebox-flex-item source :flex-basis '(40))))) + (should-not (ebox--flex-unrendered-measurement source props 'row)))) + +(ert-deftest ebox-flex-bounded-auto-basis-keeps-intrinsic-measurement () + "Clipping must not replace an auto-basis item's intrinsic contribution." + (let* ((source + (ebox-create :content "UNBREAKABLE" :width '(120) :height 2 + :min-width 0 :wrap-mode nil :overflow 'hidden)) + (props (ebox--flex-item-props source))) + (should (eq (plist-get props :flex-basis) 'auto)) + (should-not (ebox--flex-unrendered-measurement source props 'row)))) + +(ert-deftest ebox-flex-yielding-prewarm-renders-one-sized-item-per-slice () + "Fixed flex cards should prewarm incrementally without changing output." + (let* ((children + (cl-loop for label in '("Alpha" "Beta" "Gamma") + collect + (ebox-flex-item + (ebox-create :content label :height 3 :min-width 0 + :wrap-mode 'word :overflow 'hidden) + :flex-grow 1 :flex-shrink 1 :flex-basis '(60)))) + (layout (apply #'ebox-flex + (append '(:width (240) :height 3 + :flex-flow (row nowrap)) + children))) + (ebox-viewport-width 240) + baseline warmed) + ;; Establish stable region ids before comparing propertized output. + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache (make-hash-table :test 'eq))) + (setq baseline (ebox-render layout))) + (let ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (ebox--scroll-window-prewarm-state (make-hash-table :test 'eq)) + (measurement-count 0) + (sized-render-count 0) + (original-measurement + (symbol-function 'ebox--flex-item-measurement)) + (original-sized-render + (symbol-function 'ebox--flex-render-sized-entry))) + (cl-letf (((symbol-function 'ebox--flex-item-measurement) + (lambda (&rest arguments) + (cl-incf measurement-count) + (apply original-measurement arguments))) + ((symbol-function 'ebox--flex-render-sized-entry) + (lambda (&rest arguments) + (cl-incf sized-render-count) + (apply original-sized-render arguments)))) + (should (ebox--flex-prewarm-sized-item layout 240)) + (should (= measurement-count 1)) + (should (zerop sized-render-count)) + (should (ebox--flex-prewarm-sized-item layout 240)) + (should (= measurement-count 2)) + (should (zerop sized-render-count)) + (should (ebox--flex-prewarm-sized-item layout 240)) + (should (= measurement-count 3)) + (should (zerop sized-render-count)) + (should (ebox--flex-prewarm-sized-item layout 240)) + (should (= sized-render-count 1)) + (should (ebox--flex-prewarm-sized-item layout 240)) + (should (= sized-render-count 2)) + (should (ebox--flex-prewarm-sized-item layout 240)) + (should (= sized-render-count 3)) + (should-not (ebox--flex-prewarm-sized-item layout 240)) + (setq warmed (ebox-render layout))) + (should (equal-including-properties baseline warmed))))) + +(ert-deftest ebox-flex-repeated-responsive-sized-render-uses-scoped-cache () + "Repeated composite item geometry should ignore the ambient root width." + (let* ((leaf (ebox-create :content "A" :width '(viewport))) + (source + (ebox-column leaf (ebox-create :content "tail" :width '(40)))) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--flex-sized-render-observation-table + (make-hash-table :test 'eq :weakness 'key)) + (calls 0) + (original (symbol-function 'ebox-render))) + (cl-labels + ((render-at + (ambient-width item-width) + (let ((ebox-viewport-width ambient-width) + (ebox--render-cache-allow-viewport-dependent nil) + (ebox--render-cache-signature-cache + (make-hash-table :test 'eq))) + (ebox--flex-render-sized-entry + (list :source source) 'row item-width nil 'flex-start)))) + (cl-letf (((symbol-function 'ebox-render) + (lambda (node) + (when (eq node source) + (cl-incf calls)) + (funcall original node)))) + ;; The first occurrence observes the geometry, the second stores it, + ;; and a third pass at another root width reuses the scoped result. + (render-at 300 120) + (render-at 600 120) + (render-at 900 120) + (should (= calls 2)) + ;; A source mutation changes the body signature even though geometry + ;; and the repeated-context observation remain warm. + (ebox-put leaf :content "B") + (render-at 900 120) + (should (= calls 3)) + ;; A different local viewport earns its own observation and cache. + (render-at 300 130) + (render-at 600 130) + (render-at 900 130) + (should (= calls 5)))))) + +(ert-deftest ebox-flex-column-natural-cache-respects-final-cross-viewport () + "Column flex must render composite items in the final cross viewport." + (let* ((viewport-child + (ebox-create :content "VP" :width '(viewport))) + (source + (ebox-column + viewport-child + (ebox-create :content "F" :width '(100)))) + (region-id (car (ebox-region-ids viewport-child))) + (layout + (ebox-flex :height 4 + :flex-direction 'column + :align-items 'stretch + (ebox-flex-item source :flex-grow 1 :flex-basis 2))) + (rendered (ebox-render layout))) + ;; Two children keep the column as a composite stack. + (should (eq (plist-get source :ebox-type) 'stack)) + (should (= (ebox-flex-test--region-content-width rendered region-id) + 100)))) + +(ert-deftest ebox-flex-nowrap-explicit-cross-skips-natural-cross-measure () + "Nowrap containers with fixed cross size should not measure natural cross." + (let* ((layout + (ebox-flex :width '(220) :height 4 :flex-wrap 'nowrap + (ebox-create :content "A" :width '(80)) + (ebox-create :content "B" :width '(80)))) + (calls 0) + (original (symbol-function 'ebox--flex-line-cross))) + (cl-letf (((symbol-function 'ebox--flex-line-cross) + (lambda (&rest args) + (cl-incf calls) + (apply original args)))) + (ebox-render layout)) + (should (= calls 0)))) + +(ert-deftest ebox-flex-aligns-items-on-row-cross-axis () + "Align-items center should move short row items within the line height." + (let* ((layout + (ebox-flex :width '(180) :height 3 :align-items 'center + (ebox-create :content "A" :width '(80) :height 1) + (ebox-create :content "B\nB\nB" :width '(80) :height 3))) + (lines (ebox-flex-test--plain-lines layout))) + (should (= (length lines) 3)) + (should (= (cl-position-if (lambda (line) + (string-match-p "A" line)) + lines) + 1)))) + +(ert-deftest ebox-flex-honors-order-and-reverse-directions () + "Order and reverse directions should change visual item order." + (let* ((ordered + (ebox-flex + (ebox-flex-item (ebox-create :content "A" :width '(40)) :order 2) + (ebox-flex-item (ebox-create :content "B" :width '(40)) :order 1))) + (column-reverse + (ebox-flex :flex-direction 'column-reverse + (ebox-create :content "A" :width '(40)) + (ebox-create :content "B" :width '(40))))) + (should (string-match-p "B.*A" + (car (ebox-flex-test--plain-lines ordered)))) + (should (string-match-p "B" + (car (ebox-flex-test--plain-lines column-reverse)))))) + +(ert-deftest ebox-flex-align-self-overrides-align-items () + "Align-self should override the container cross-axis alignment." + (let* ((layout + (ebox-flex :width '(180) :height 3 :align-items 'flex-start + (ebox-flex-item + (ebox-create :content "A" :width '(80) :height 1) + :align-self 'flex-end) + (ebox-create :content "B\nB\nB" :width '(80) :height 3))) + (lines (ebox-flex-test--plain-lines layout))) + (should (= (length lines) 3)) + (should (= (cl-position-if (lambda (line) + (string-match-p "A" line)) + lines) + 2)))) + +(ert-deftest ebox-flex-wrap-single-line-align-content-keeps-natural-cross-size () + "A wrapped one-line container should still use multi-line align-content rules." + (let* ((layout + (ebox-flex :width '(180) :height 4 + :flex-wrap 'wrap + :align-content 'flex-start + :align-items 'center + (ebox-create :content "A" :width '(60) :height 1))) + (lines (ebox-flex-test--plain-lines layout))) + (should (= (length lines) 4)) + (should (= (cl-position-if (lambda (line) + (string-match-p "A" line)) + lines) + 0)))) + +(ert-deftest ebox-flex-applies-container-box-properties () + "Flex container visual box properties should render around the flex layout." + (let* ((layout + (ebox-flex :width '(160) :box-sizing 'border-box + :padding '(1 (8)) + :border '(1 "#ff0000") + :bgcolor "#101010" + (ebox-create :content "A" :width '(60)))) + (rendered (ebox-flex-test--render layout)) + (left-face (ebox-flex-test--face-at-property rendered 'ebox-bl)) + (right-face (ebox-flex-test--face-at-property rendered 'ebox-br)) + (top-face (ebox-flex-test--face-at-property rendered 'ebox-bt)) + (bottom-face (ebox-flex-test--face-at-property rendered 'ebox-bb)) + (content-face (ebox-flex-test--face-at-property rendered 'ebox-content))) + (should (equal (ebox-flex-test--line-widths layout) '(160 160 160))) + (should (ebox-flex-test--face-has-key-value-p + left-face :background "#ff0000")) + (should (ebox-flex-test--face-has-key-value-p + right-face :background "#ff0000")) + (should (ebox-flex-test--face-has-key-value-p top-face :overline "#ff0000")) + (should (ebox-flex-test--face-has-key-value-p + bottom-face + :underline + '(:position t :color "#ff0000"))) + (should (ebox-flex-test--face-has-key-value-p + content-face :background "#101010")))) + +(ert-deftest ebox-flex-container-box-preserves-blank-child-bottom-border () + "A visual flex container should preserve child bottom borders on blank lines." + (let* ((child (ebox-create :content "A" + :width '(80) + :height 2 + :padding '(0 (6)) + :border '(1 "#00aa00"))) + (child-id (car (ebox-region-ids child))) + (layout (ebox-flex :width '(140) + :padding '(1 (4)) + :border '(1 "#444444") + (ebox-flex-item child :flex-basis '(80)))) + (rendered (ebox-flex-test--render layout))) + (should (memq child-id + (ebox-flex-test--property-values rendered 'ebox-bb))))) + +(ert-deftest ebox-flex-container-box-wrapper-preserves-rendered-layout () + "A flex container box wrapper should not rewrap the rendered flex 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* ((layout + (ebox-flex :width '(260) :height 3 :border "gray" + :flex-flow '(row wrap) + :justify-content 'space-between + (ebox-create :content "sample box two" + :height 3 + :box-sizing 'border-box + :padding '(0 (8)) + :border t + :wrap-mode nil) + (ebox-create :content "sample box three" + :height 3 + :box-sizing 'border-box + :padding '(0 (8)) + :border t + :wrap-mode nil))) + (plain (substring-no-properties (ebox-flex-test--render layout)))) + (should-not (string-match-p "REWRAPPED" plain)) + (should (string-match-p "sample box two" plain)) + (should (string-match-p "sample box three" plain))))) + +(ert-deftest ebox-flex-item-applies-box-properties-with-flex-metadata () + "An item wrapper should keep flex metadata and apply visual box properties." + (let* ((layout + (ebox-flex :width '(180) + (ebox-flex-item + (ebox-create :content "A" :width '(60)) + :flex 1 + :box-sizing 'border-box + :padding '(0 (6)) + :border '(1 "#00aa00")))) + (rendered (ebox-flex-test--render layout)) + (left-face (ebox-flex-test--face-at-property rendered 'ebox-bl)) + (right-face (ebox-flex-test--face-at-property rendered 'ebox-br)) + (top-face (ebox-flex-test--face-at-property rendered 'ebox-bt)) + (bottom-face (ebox-flex-test--face-at-property rendered 'ebox-bb))) + (should (equal (ebox-flex-test--line-widths layout) '(180))) + (should (string-match-p "A" (substring-no-properties rendered))) + (should (ebox-flex-test--face-has-key-value-p + left-face :background "#00aa00")) + (should (ebox-flex-test--face-has-key-value-p + right-face :background "#00aa00")) + (should (ebox-flex-test--face-has-key-value-p top-face :overline "#00aa00")) + (should (ebox-flex-test--face-has-key-value-p + bottom-face + :underline + '(:position t :color "#00aa00"))))) + +(ert-deftest ebox-flex-item-wrapper-preserves-preformatted-layout () + "An item wrapper with box properties 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* ((layout + (ebox-flex :width '(220) + (ebox-flex-item + (ebox-column + (ebox-create :content "Top" :width '(80)) + (ebox-create :content "Bottom" :width '(80))) + :width '(180) + :box-sizing 'border-box + :border t))) + (plain (substring-no-properties (ebox-flex-test--render layout)))) + (should-not (string-match-p "REWRAPPED" plain)) + (should (string-match-p "Top" plain)) + (should (string-match-p "Bottom" plain))))) + +(ert-deftest ebox-build-flex-item-applies-box-properties-with-flex-metadata () + "The DSL item form should keep flex metadata and visual box properties." + (let* ((layout + (ebox-build + '(flex :width (180) + (item :flex 1 + :box-sizing border-box + :padding (0 (6)) + :border (1 "#00aa00") + (box :content "A" :width (60)))))) + (rendered (ebox-flex-test--render layout)) + (top-face (ebox-flex-test--face-at-property rendered 'ebox-bt)) + (bottom-face (ebox-flex-test--face-at-property rendered 'ebox-bb))) + (should (equal (ebox-flex-test--line-widths layout) '(180))) + (should (string-match-p "A" (substring-no-properties rendered))) + (should (ebox-flex-test--face-has-key-value-p top-face :overline "#00aa00")) + (should (ebox-flex-test--face-has-key-value-p + bottom-face + :underline + '(:position t :color "#00aa00"))))) + +(ert-deftest ebox-flex-neutral-size-properties-keep-child-identity () + "Width, height, and box-sizing alone should not wrap a flex container." + (dolist (props '((:width (120)) + (:height 2) + (:box-sizing border-box))) + (let ((layout + (apply #'ebox-flex + (append props + (list (ebox-create :content "A" :width '(40)) + (ebox-create :content "B" :width '(40))))))) + (should-not (plist-get layout :box)) + (should (= (length (ebox-region-ids layout)) 2))))) + +(ert-deftest ebox-flex-box-sizing-alone-keeps-child-region-ids () + "Box-sizing by itself should not wrap a flex container." + (let ((layout + (ebox-flex :width '(120) :box-sizing 'border-box + (ebox-create :content "A" :width '(40)) + (ebox-create :content "B" :width '(40))))) + (should (= (length (ebox-region-ids layout)) 2)) + (should (equal (ebox-flex-test--line-widths layout) '(120))))) + +(ert-deftest ebox-flex-item-box-wrapper-preserves-direct-child-item-props () + "Wrapping an item in box properties should not hide child flex metadata." + (let* ((layout + (ebox-flex :width '(160) + (ebox-flex-item + (ebox-create :content "A" :width '(60) + :flex-grow 1 :flex-basis '(60)) + :box-sizing 'border-box + :border t) + (ebox-create :content "B" :width '(40)))) + (ids (ebox-region-ids layout)) + (rendered (ebox-flex-test--render layout)) + (wrapper-width + (ebox-flex-test--region-property-width + rendered (car ids) 'ebox-content-owner)) + (right-width + (ebox-flex-test--region-content-width rendered (car (last ids))))) + (should (= (length ids) 3)) + (should (> wrapper-width 60)) + (should (> wrapper-width right-width)))) + +(ert-deftest ebox-flex-wraps-before-shrinking-no-wrap-item-below-minimum () + "A no-wrap flex item should keep its rendered minimum width when wrapping." + (let* ((layout + (ebox-flex :width '(160) + :flex-flow '(row wrap) + :column-gap '(10) + (ebox-flex-item + (ebox-create :content "wide no-wrap label" + :width '(150) + :box-sizing 'border-box + :wrap-mode nil) + :flex 1) + (ebox-create :content "B" :width '(120)))) + (lines (ebox-flex-test--plain-lines layout))) + (should (= (length lines) 2)) + (should (cl-every (lambda (width) (<= width 160)) + (ebox-flex-test--line-widths layout))) + (should (string-match-p "wide no-wrap label" (car lines))) + (should (string-match-p "B" (cadr lines))))) + +(ert-deftest ebox-flex-supports-standard-value-matrix () + "Every supported flex value should render without falling out of the model." + (dolist (direction '(row row-reverse column column-reverse)) + (should (stringp + (ebox-render + (ebox-flex :width '(220) :height 4 :flex-direction direction + (ebox-create :content "A" :width '(60) :height 1) + (ebox-create :content "B" :width '(60) :height 1)))))) + (dolist (wrap '(nowrap wrap wrap-reverse)) + (should (stringp + (ebox-render + (ebox-flex :width '(130) :height 4 :flex-wrap wrap + (ebox-create :content "A" :width '(60) :height 1) + (ebox-create :content "B" :width '(60) :height 1) + (ebox-create :content "C" :width '(60) :height 1)))))) + (dolist (justify '(flex-start flex-end center space-between space-around space-evenly start end left right normal)) + (should (stringp + (ebox-render + (ebox-flex :width '(220) :justify-content justify + (ebox-create :content "A" :width '(60)) + (ebox-create :content "B" :width '(60))))))) + (dolist (align '(stretch flex-start flex-end center baseline normal start end self-start self-end)) + (should (stringp + (ebox-render + (ebox-flex :width '(220) :height 4 + :align-items align + (ebox-create :content "A" :width '(60) :height 1) + (ebox-create :content "B" :width '(60) :height 1)))))) + (dolist (align '(stretch flex-start flex-end center space-between space-around space-evenly baseline normal start end)) + (should (stringp + (ebox-render + (ebox-flex :width '(130) :height 5 :flex-wrap 'wrap + :align-content align + (ebox-create :content "A" :width '(60) :height 1) + (ebox-create :content "B" :width '(60) :height 1) + (ebox-create :content "C" :width '(60) :height 1))))))) + +(ert-deftest ebox-build-compiles-flex-and-item-dsl () + "The public DSL should expose flex and item forms." + (let* ((node + (ebox-build + '(flex :width (240) :flex-flow (row wrap) :column-gap (10) + (item :flex 1 (box :content "A" :width (80))) + (box :content "B" :width (80) :flex-grow 1 :flex-basis (80))))) + (rendered (ebox-flex-test--render node))) + (should (eq (plist-get node :ebox-type) 'flex)) + (should (= (length (ebox-region-ids node)) 2)) + (should (= (string-pixel-width (car (ebox-string-lines rendered))) 240)) + (should (string-match-p "A.*B" (substring-no-properties rendered))))) + +(provide 'ebox-flex-tests) +;;; ebox-flex-tests.el ends here diff --git a/tests/ebox-grid-tests.el b/tests/ebox-grid-tests.el new file mode 100644 index 0000000..0018f93 --- /dev/null +++ b/tests/ebox-grid-tests.el @@ -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 diff --git a/tests/ebox-package-tests.el b/tests/ebox-package-tests.el new file mode 100644 index 0000000..346d711 --- /dev/null +++ b/tests/ebox-package-tests.el @@ -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 diff --git a/tests/ebox-selector-tests.el b/tests/ebox-selector-tests.el new file mode 100644 index 0000000..9e82197 --- /dev/null +++ b/tests/ebox-selector-tests.el @@ -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 diff --git a/tests/ebox-visual-check-tests.el b/tests/ebox-visual-check-tests.el new file mode 100644 index 0000000..1a0ddc2 --- /dev/null +++ b/tests/ebox-visual-check-tests.el @@ -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