Compare commits
48 Commits
rollback-g
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a64573d42 | ||
|
|
4a25d573c2 | ||
|
|
a8417d56c6 | ||
|
|
79f5bc23d1 | ||
|
|
d19305b16c | ||
|
|
22610ca0f7 | ||
|
|
7c21185215 | ||
|
|
7de9eb16f9 | ||
|
|
1204746e41 | ||
|
|
2b7ffcb0af | ||
|
|
e1f4821e6d | ||
|
|
6ac8ff6e04 | ||
|
|
f89b47b77d | ||
|
|
71154f5f78 | ||
|
|
789e0b2f17 | ||
|
|
b624f46499 | ||
|
|
dae9db7a74 | ||
|
|
e7066b27f4 | ||
|
|
7c0ddbe933 | ||
|
|
21cf79cdfa | ||
|
|
dfa097e988 | ||
|
|
5143d17a56 | ||
|
|
3fadb19121 | ||
|
|
acb5afce82 | ||
|
|
9e581aea2e | ||
|
|
36883aefc7 | ||
|
|
3bd75f90c8 | ||
|
|
727aba2ec7 | ||
|
|
73357dee94 | ||
|
|
39d079b2cc | ||
|
|
14c1698c41 | ||
|
|
220f27803e | ||
|
|
826f195236 | ||
|
|
dac004cd65 | ||
|
|
f87fcd121b | ||
|
|
c9de7e6329 | ||
|
|
c81fa47d31 | ||
|
|
85849c01e7 | ||
|
|
e48af058b5 | ||
|
|
c9131401ea | ||
|
|
b824467791 | ||
|
|
13e360a1a5 | ||
|
|
bc4516e171 | ||
|
|
76bce0bb8d | ||
|
|
9767c30208 | ||
|
|
32b4834322 | ||
|
|
f1f91468aa | ||
|
|
a5cff4fa44 |
47
.github/workflows/ci.yml
vendored
47
.github/workflows/ci.yml
vendored
@ -7,24 +7,34 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
emacs: ['29.1', '30.2']
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check out TP runtime dependency
|
||||
run: git clone --depth 1 https://github.com/Kinneyzhang/tp.git ../tp
|
||||
- name: Install supported Emacs version
|
||||
uses: purcell/setup-emacs@34c6ded44899fd1bf74d2889558befd1750e61a7
|
||||
with:
|
||||
version: ${{ matrix.emacs }}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y emacs-nox python3
|
||||
- name: Fetch locked TP and ECSS dependencies
|
||||
run: make release-deps RELEASE_DEPS_DIR="$RUNNER_TEMP/ebox-deps"
|
||||
|
||||
- name: Run local verification suite
|
||||
run: make ci EMACS=emacs PYTHON=python3
|
||||
run: make ci EMACS=emacs PYTHON=python3 TP_DIR="$RUNNER_TEMP/ebox-deps/tp" ECSS_DIR="$RUNNER_TEMP/ebox-deps/ecss"
|
||||
|
||||
- name: Verify source package installation without optional providers
|
||||
run: make release-check EMACS=emacs RELEASE_DIR="$RUNNER_TEMP/ebox-release" RELEASE_OPTIONS="--dependencies $RUNNER_TEMP/ebox-deps"
|
||||
|
||||
- name: Verify optional Knuth-Plass installation
|
||||
run: make release-check EMACS=emacs RELEASE_DIR="$RUNNER_TEMP/ebox-release-kp" RELEASE_OPTIONS=--with-ekp
|
||||
|
||||
- name: Run package-lint
|
||||
run: make package-lint-install EMACS=emacs
|
||||
run: make package-lint-install EMACS=emacs TP_DIR="$RUNNER_TEMP/ebox-deps/tp" ECSS_DIR="$RUNNER_TEMP/ebox-deps/ecss"
|
||||
|
||||
native-build:
|
||||
strategy:
|
||||
@ -62,6 +72,27 @@ jobs:
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Install Emacs for native integration
|
||||
uses: jcs090218/setup-emacs@54b61ac9c1d7011a54d2b6d66dedbcddf20e2fb9
|
||||
with:
|
||||
version: '30.2'
|
||||
|
||||
- name: Install source packages and execute the native module
|
||||
shell: bash
|
||||
run: |
|
||||
python scripts/ebox-release.py build --directory "$RUNNER_TEMP/ebox-native-release"
|
||||
case "$RUNNER_OS" in
|
||||
Linux) module="$PWD/native/target/release/libebox_native_reflow.so" ;;
|
||||
macOS) module="$PWD/native/target/release/libebox_native_reflow.dylib" ;;
|
||||
Windows) module="$PWD/native/target/release/ebox_native_reflow.dll" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
python scripts/ebox-release.py verify --directory "$RUNNER_TEMP/ebox-native-release" --emacs emacs --native-module "$module"
|
||||
|
||||
- name: Verify installed source can build its native module
|
||||
if: runner.os == 'Linux'
|
||||
run: make release-native-check EMACS=emacs RELEASE_DIR="$RUNNER_TEMP/ebox-native-release"
|
||||
|
||||
native-msrv:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
*.elc
|
||||
.DS_Store
|
||||
__pycache__
|
||||
scripts/ebox-scroll-profile.el
|
||||
native/target/
|
||||
|
||||
51
.runtime-index-plan.md
Normal file
51
.runtime-index-plan.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Runtime persistent index cleanup plan
|
||||
|
||||
1. Lock the persistent index contract: exact `equal` keys, nil values,
|
||||
collision buckets, immutable put/delete, count/map/keys, and legacy hash
|
||||
adapters.
|
||||
2. Add one fixed-depth radix index module and load it before tree, patch-plan,
|
||||
incremental, surface, and native commit consumers.
|
||||
3. Bootstrap node, parent, and region-owner indexes directly into immutable
|
||||
indexes; store native postorder as node IDs. Bind the generated ID format
|
||||
to the exact read-only vector; convert unmarked legacy entries with complete
|
||||
candidate membership validation before retaining that format.
|
||||
4. Change local candidate preparation to path-copy only removed/touched node and
|
||||
affected-region keys. Keep all other state tables on their current path.
|
||||
5. Migrate readers and compatibility entry points to the common adapters.
|
||||
Preserve hash-table inputs at package boundaries.
|
||||
6. Keep the complete topology comparison, including untouched surface objects
|
||||
and source keys. Key-set identity tokens prove only persistent index
|
||||
membership: value updates retain the token, membership changes replace it,
|
||||
and unrelated index lineages use the exact key comparison.
|
||||
7. Verify focused index and candidate regressions, then compile/checkdoc/full
|
||||
ERT serially after coordinating CPU use with the root agent.
|
||||
|
||||
Stop when all production consumers of the three runtime indexes use adapters,
|
||||
local candidates perform no whole-table copy/remap, and the focused plus full
|
||||
validation gates pass. Report remaining full work in region-box/count/source
|
||||
and other state tables.
|
||||
|
||||
## Compressed radix follow-up
|
||||
|
||||
The compiled fixed-depth version passed behavior checks but regressed measured
|
||||
row/theme/filter/pagination latency. It is not accepted for integration.
|
||||
|
||||
1. Preserve the measured fixed-depth module, compiled bytecode, tests, and
|
||||
reviewed patch outside the checkout before editing.
|
||||
2. Add regressions for compressed branching depth and exact key semantics:
|
||||
prefix divergence above an existing branch; colliding 64-bit integer codes;
|
||||
colliding noninteger keys; nil values; deletion collapse and fork isolation;
|
||||
and deletion of the key that supplied a branch's representative code.
|
||||
3. Replace mandatory-depth roots with nil, exact collision leaves, and
|
||||
16-way branch vectors carrying their actual nibble shift and representative
|
||||
code. Branches have at least two children and increasing child shifts.
|
||||
Put returns its membership-change flag without a separate lookup. Delete
|
||||
replaces unary branches with their surviving child.
|
||||
4. Keep key-set tokens, hash-table adapters, native postorder representation,
|
||||
full topology validation, and all publication/rollback paths unchanged.
|
||||
5. After the CPU owner releases its slot, run focused regressions, compiled
|
||||
old/new index cost comparisons, then required package checks serially.
|
||||
Report actual costs to the root agent for full workload measurement.
|
||||
|
||||
Accept this follow-up only after semantic checks pass and measured costs
|
||||
improve; compressed structure alone does not establish end-to-end latency.
|
||||
@ -4,6 +4,13 @@ This repository is the standalone low-level Ebox package. It owns box nodes, sty
|
||||
|
||||
Keep this repository independent from ETAF. Do not add Components, Runtime state, Data Controllers, UI controls, or application examples here. Ebox may call ECSS and TP only through their public APIs; private `ebox--*` functions remain internal to this package.
|
||||
|
||||
When authoring Ebox DSL, prefer the shortest declaration that preserves the intended behavior: omit redundant defaults, put shared inheritable text styles on an existing common parent, and declare only child differences. Keep explicit overrides and properties that an example is teaching. Check the actual layout context before omitting sizes; `auto` and `stretch` are not universal aliases, and background paint showing through is not property inheritance. Follow the authoring rules in `docs/user/ebox-user-guide.en.md` and verify example simplifications at multiple viewport sizes.
|
||||
|
||||
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.
|
||||
|
||||
For legacy public CSS size migrations, use `python3 scripts/migrate-css-sizes.py PATH...` first, then the same invocation with `--write` for reviewed public DSL/style callers. The tool preserves comments, strings, and source formatting, and reports dynamic/ambiguous values for manual review. Do not apply it to measured engine structs merely because they use the same property names. See its `--help` for input, exit-status, and cleanup contracts; use `python3 -m unittest discover -s scripts -p 'test_migrate_css_sizes.py'` to verify the tool. Extend this entry point instead of creating ad hoc migration probes.
|
||||
For mixed test/implementation files, add `--callers ebox-build,ebox-create` (or the actual public API names) to restrict migration to those call subforms. ETAF `(styles ...)` blocks are recognized as literal data; native Emacs `(space ...)` and image display specs retain their own size grammar.
|
||||
|
||||
For standalone packaging or clean installation verification, use `make release-check RELEASE_DIR=/tmp/ebox-release EMACS=/path/to/emacs`; the output directory must not exist. The shared `scripts/ebox-release.py` entry point fetches exact public commits from `release-dependencies.json`, creates deterministic package.el source archives, and installs them in a temporary profile without sibling load paths. Add `RELEASE_OPTIONS=--with-ekp` for the optional KP provider. Use `make release-deps RELEASE_DEPS_DIR=/tmp/ebox-deps` for CI checkouts, or `--dependencies /absolute/deps` to reuse explicit clean pinned checkouts during a build. Use `make native-integration RELEASE_DIR=/tmp/ebox-release NATIVE_MODULE=/absolute/module EMACS=/path/to/emacs` to require actual module loading and native rendering, or `make release-native-check RELEASE_DIR=/tmp/ebox-release EMACS=/path/to/emacs` to build the installed package's bundled native source first. Temporary resources are removed on completion, failure, interruption, or subprocess timeout; output archives and requested dependency checkouts are retained. Extend this entry point instead of adding per-release install scripts. Unit coverage is `make release-tool-tests`; never publish, tag, or replace an existing output directory implicitly.
|
||||
|
||||
86
CHANGELOG.md
86
CHANGELOG.md
@ -2,32 +2,110 @@
|
||||
|
||||
All notable changes to Ebox are documented here.
|
||||
|
||||
## 2.0.0 (Unreleased)
|
||||
## 3.0.0 (Unreleased)
|
||||
|
||||
### Added
|
||||
|
||||
- Direct dependencies on the independent ECSS 0.1.0 cascade engine and TP 1.0.0 retained/reactive text runtime.
|
||||
- Explicit native node capabilities `:help-echo`, `:pointer`, `:hover-style`,
|
||||
and `:keymap`, including callback adapters `ebox-help-create` and
|
||||
`ebox-keymap-create`, nested overrides, explicit-nil blocking, and retained
|
||||
updates. These do not require ETAF or an arbitrary text-property escape hatch.
|
||||
- Optional `ebox-next-interaction` and `ebox-previous-interaction` navigation
|
||||
commands for rendered keymap owners; no default navigation bindings or
|
||||
application state system are installed.
|
||||
- Explicit geometry units `px`, `%`, `vw`, `vh`, `ch`, and `lh`, composable
|
||||
`calc`/`min`/`max`/`clamp` functions, and construction-time axis validation.
|
||||
- A repository-level GPL-3.0-or-later license and consistent Ebox/Rust package
|
||||
declarations, with third-party notices retained.
|
||||
- Locked dependency inputs and a reusable source-archive build/install verifier
|
||||
(`make release-check`), including optional EKP and explicit native-module
|
||||
integration. CI now prepares ECSS as well as TP, tests the minimum supported
|
||||
Emacs version, and configures native loading/execution on all three platforms.
|
||||
- Direct dependencies on the independent ECSS 0.1.0 cascade engine and TP 2.0.0 retained/reactive text runtime.
|
||||
- ECSS-backed property schemas, selectors, stylesheets, inheritance, cascade layers, custom properties, computed values, and provenance for Box, Flex, and Grid nodes.
|
||||
- Surface-scoped region handles through `ebox-region-resolve`, retained TP publication through `ebox-render-to-buffer` and `ebox-display-buffer`, and atomic handle/selector/viewport/scroll updates.
|
||||
- Typography longhands and inherited foreground projection that keep text readable when child boxes override background colors.
|
||||
- A public, nestable framework render-burst boundary that covers pre-commit allocation, shares interactive deferred GC ownership with Ebox commits, and restores batch GC settings without collecting at the boundary.
|
||||
- Structured TP participant registration for every retained publication.
|
||||
|
||||
### Changed
|
||||
|
||||
- `ebox-display-buffer` renders before calling native `display-buffer`, accepts
|
||||
its optional action argument, and returns the rendered buffer. Ebox no longer
|
||||
deletes other windows or selects the result as part of ordinary display.
|
||||
- `:overflow hidden` now also enforces horizontal content width, including
|
||||
composed child output, while retaining the owner's padding and border.
|
||||
Clipping preserves supported text clusters and visible properties, removes
|
||||
clipped child interactions, and fills remaining pixels without an ellipsis.
|
||||
This adapts the GPL-compatible s-pixel approach without a new dependency;
|
||||
partial-glyph masking and stacking are not introduced.
|
||||
- `:wrap-mode kp` explicitly requires the optional EKP package. Missing or
|
||||
incompatible EKP now reports an installation/update error instead of silently
|
||||
selecting word wrapping. Other wrapping modes do not require EKP.
|
||||
- The minimum TP dependency is now 2.0.0 with the final v2 participant protocol.
|
||||
Native keymap ownership, callback identity, and shared hover regions require
|
||||
its property policies; the former TP 1.0.1 transition baseline is insufficient.
|
||||
- Incremental updates reuse proven unchanged style, identity, ownership, and
|
||||
viewport facts. Content and paint changes keep the smallest safe publication
|
||||
scope; geometry or dependency changes still promote to a larger owner when
|
||||
required. TP publishes the changed text interval and preserves rollback.
|
||||
- Hover paint covers the declaring owner's text and padding as one native
|
||||
hover region per rendered line, while preserving physical side borders.
|
||||
Nested explicit hover declarations remain separate regions.
|
||||
- Package metadata now distinguishes builds with the public unmount/revision
|
||||
boundary and SPI v2 compatibility surface from older 2.x development
|
||||
snapshots.
|
||||
- Early 3.0 development used TP 1.0.1's structured transition protocol. The
|
||||
release candidate now requires TP 2.0.0 or newer and always
|
||||
uses the public structured participant API.
|
||||
- SPI initial publication owns widening and an outer editor change group. A
|
||||
failed stage or nonlocal rollback exit restores text, markers, overlays,
|
||||
point, mark, narrowing, undo history, and modified state before it escapes.
|
||||
- ECSS is the only selector matcher and cascade winner. Ebox adapts node trees to ECSS subjects, interprets computed values for measurement/layout/paint, and classifies their layout impact.
|
||||
- TP is the only live buffer writer. Ebox produces retained surface candidates and semantic dirty-owner plans; TP owns diff execution, marker-backed mounts, revisions, transactions, and rollback.
|
||||
- Flex and Grid container/item declarations enter the same ECSS pipeline as ordinary boxes instead of maintaining a parallel style path.
|
||||
- Region updates use opaque surface-scoped handles. The same declarative source may be mounted in multiple buffers without sharing live identity.
|
||||
- A full declarative update that continues a committed native frame now computes
|
||||
a complete selector-local style certificate before stable native eligibility.
|
||||
Other full/local paths retain their existing O(changed) style work. Native
|
||||
updates no longer commit replacement runtime nodes without computed styles,
|
||||
and structural full-frame bootstrap remains available.
|
||||
|
||||
### Removed
|
||||
|
||||
- `ebox-pop-to-buffer`, `ebox-switch-to-buffer`, and the private erase-and-evaluate buffer writer they exposed.
|
||||
- Ebox's duplicate selector parser/matcher and duplicate cascade winner.
|
||||
- Bundled playground fixtures. Executable `.ebox` references live in the independent `ebox-playground` package.
|
||||
- The Ebox transaction route switch, v1 TP participant adapter call, and
|
||||
missing-capability fallback.
|
||||
|
||||
### Migration
|
||||
|
||||
- Install ECSS 0.1.0 and TP 1.0.0, in either order, before Ebox 2.0.0.
|
||||
- Replace legacy geometry literals with explicit units: for example,
|
||||
`:width 80` becomes `:width (ch 80)`, `:width (240)` becomes
|
||||
`:width (px 240)`, and `:height 3` becomes `:height (lh 3)`. Use
|
||||
`python3 scripts/migrate-css-sizes.py PATH...` to preview supported literal
|
||||
rewrites; review reported dynamic or ambiguous values before adding `--write`.
|
||||
Vertical geometry rejects `px`, and `fit-content` is now a bare keyword.
|
||||
- Callers requiring selected or exclusive display must choose that window
|
||||
policy explicitly; ordinary `ebox-display-buffer` follows native display
|
||||
rules. Rendering without display remains `ebox-render-to-buffer`.
|
||||
- Install EKP 1.0.0 or newer only when using `:wrap-mode kp`; its optional native
|
||||
accelerator is independent from Ebox's optional Rust reflow module.
|
||||
- Install ECSS 0.1.0 and TP 2.0.0 or newer, in either order, before Ebox 3.0.0.
|
||||
- Remove any customization of `ebox-transaction-participant-route`; Ebox 3.0.0
|
||||
requires TP's structured participant capability and fails closed when it is
|
||||
missing or malformed.
|
||||
- Replace numeric region-id mutation with `ebox-region-resolve` followed by `ebox-region-update` on the returned handle.
|
||||
- Replace `ebox-pop-to-buffer` or `ebox-switch-to-buffer` with `ebox-display-buffer`, or call `ebox-render-to-buffer` when display is controlled by the caller.
|
||||
- Ebox does not scan previously rendered text to reconstruct runtime identity. Remount the declarative source through Ebox 2.0.
|
||||
- Ebox does not scan previously rendered text to reconstruct runtime identity. Remount the declarative source through Ebox 3.0.
|
||||
|
||||
### Release status and supported boundaries
|
||||
|
||||
- `3.0.0` remains unreleased. These changes prepare source distribution; they
|
||||
do not announce an uploaded package archive or published release tag.
|
||||
- Each mounted buffer owns one viewport layout. Use separate buffers for views
|
||||
requiring independent widths. Vertical geometry remains whole-line based.
|
||||
- Common UI text clusters are preserved, but complete Unicode grapheme
|
||||
segmentation, browser-level bidi, stacking, and absolute positioning are
|
||||
outside the current contract.
|
||||
|
||||
@ -2,32 +2,90 @@
|
||||
|
||||
Ebox 的重要变化记录在这里。
|
||||
|
||||
## 2.0.0(未发布)
|
||||
## 3.0.0(未发布)
|
||||
|
||||
### 新增
|
||||
|
||||
- 直接依赖独立的 ECSS 0.1.0 cascade engine 与 TP 1.0.0 retained/reactive text runtime。
|
||||
- 显式原生节点能力 `:help-echo`、`:pointer`、`:hover-style` 与 `:keymap`,包含回调
|
||||
适配器 `ebox-help-create`、`ebox-keymap-create`、嵌套覆盖、显式 nil 阻断和 retained
|
||||
更新。不需要 ETAF 或任意文本属性逃生入口。
|
||||
- 可选 `ebox-next-interaction`、`ebox-previous-interaction` 导航命令,在渲染后的
|
||||
keymap owner 之间移动;不安装默认导航绑定或应用状态系统。
|
||||
- 显式几何单位 `px`、`%`、`vw`、`vh`、`ch`、`lh`,可组合的
|
||||
`calc`/`min`/`max`/`clamp` 函数,以及构造时的属性方向验证。
|
||||
- 仓库级 GPL-3.0-or-later 许可证与统一的 Ebox/Rust 包声明,保留第三方声明。
|
||||
- 固定依赖输入与可复用的源码归档构建/安装验证器(`make release-check`),包含
|
||||
可选 EKP 和显式 native 模块集成。CI 补齐 ECSS 与 TP、测试最低支持 Emacs 版本,
|
||||
并配置了三个平台的 native 加载与执行。
|
||||
- 直接依赖独立的 ECSS 0.1.0 cascade engine 与 TP 2.0.0 retained/reactive text runtime。
|
||||
- Box、Flex 与 Grid 节点统一使用 ECSS property schema、selector、stylesheet、inheritance、cascade layer、custom property、computed value 与 provenance。
|
||||
- 通过 `ebox-region-resolve` 提供 surface-scoped region handle;`ebox-render-to-buffer` 与 `ebox-display-buffer` 统一使用 retained TP publication;handle、selector、viewport 与 scroll 更新加入同一原子事务。
|
||||
- 增加 typography longhand 与继承 foreground 的投影;子盒子覆盖背景色时仍能保持文字可读。
|
||||
- 增加公开、可嵌套的 framework render-burst 边界,覆盖 commit 前分配,与 Ebox commit 共用交互 deferred GC ownership,并在 batch 边界不主动 GC 的情况下恢复原设置。
|
||||
- 所有 retained publication 统一使用 structured TP participant registration。
|
||||
|
||||
### 变更
|
||||
|
||||
- `ebox-display-buffer` 先完成渲染,再调用原生 `display-buffer`,接受可选 action,
|
||||
返回渲染结果 buffer。普通显示不再删除其他窗口或选中结果窗口。
|
||||
- `:overflow hidden` 现在也限制横向内容宽度,包括组合后的子节点输出,并保留自身
|
||||
padding 与边框。裁剪保留受支持的文本簇和可见属性、移除被裁掉的子节点交互,用空白
|
||||
补齐剩余像素,不加省略号。复用 GPL 兼容的 s-pixel 思路,不增加依赖、半字形遮罩或层叠。
|
||||
- `:wrap-mode kp` 明确要求可选 EKP 包。缺失或不兼容时提示安装/更新,不再静默改用
|
||||
单词换行。其他换行模式不需要 EKP。
|
||||
- TP 最低依赖提升到 2.0.0 与最终 v2 participant 协议。原生 keymap 所有权、回调身份
|
||||
和共享 hover 范围需要其属性策略,原来的 TP 1.0.1 过渡基线不满足这些要求。
|
||||
- 增量更新复用已证明不变的样式、身份、所有权与视口事实。内容和绘制变化保留最小
|
||||
安全发布范围,几何或依赖变化仍在必要时提升 owner。TP 发布实际变化的文本区间,
|
||||
并保留失败回滚。
|
||||
- 每个渲染行内,hover 将声明 owner 的文字与 padding 作为一个原生悬停区域,并保留
|
||||
物理左右边框;子节点显式 hover 声明仍形成独立区域。
|
||||
- 包 metadata 现在会区分具备 public unmount/revision 边界与 SPI v2
|
||||
compatibility surface 的构建,旧的 2.x development snapshot 不会再被误接受。
|
||||
- 早期 3.0 开发使用 TP 1.0.1 的 structured 过渡协议;当前候选版本要求 TP 2.0.0
|
||||
或更新版本,并始终只调用公开 structured participant API。
|
||||
- SPI initial publication 自己负责 widening 与外层 editor change group。stage
|
||||
失败或 rollback 非局部退出向外传播前,会恢复 text、marker、overlay、point、
|
||||
mark、narrowing、undo history 与 modified state。
|
||||
- ECSS 是唯一 selector matcher 与 cascade winner。Ebox 只把节点树适配为 ECSS subject,解释 computed value 的测量、布局与绘制后果,并分类 layout impact。
|
||||
- TP 是唯一 live buffer writer。Ebox 只生成 retained surface candidate 与语义 dirty-owner plan;diff execution、marker-backed mount、revision、transaction 与 rollback 都属于 TP。
|
||||
- Flex/Grid container 与 item declaration 和普通 box 一样进入同一条 ECSS pipeline,不再维护平行 style path。
|
||||
- Region 更新改用 opaque、surface-scoped handle;同一份声明式 source 可以挂载到多个 buffer,而不共享 live identity。
|
||||
- 只有继续已提交 native frame 的 full declarative update 才会在 stable native
|
||||
eligibility 前计算完整 selector-local style certificate;其他 full/local 路径
|
||||
保持原有 O(changed) style work。native update 不再提交缺失 computed style 的
|
||||
replacement runtime,结构性 full-frame bootstrap 继续保留。
|
||||
|
||||
### 删除
|
||||
|
||||
- `ebox-pop-to-buffer`、`ebox-switch-to-buffer`,以及它们暴露的私有 erase-and-evaluate buffer writer。
|
||||
- Ebox 内重复的 selector parser/matcher 与 cascade winner。
|
||||
- 仓库内置 playground fixture;可执行 `.ebox` reference 已迁到独立 `ebox-playground` 包。
|
||||
- Ebox transaction route 开关、v1 TP participant adapter 调用,以及 capability
|
||||
缺失时的 fallback。
|
||||
|
||||
### 迁移
|
||||
|
||||
- ECSS 0.1.0 与 TP 1.0.0 互相独立,安装顺序任意;两者都安装后再安装 Ebox 2.0.0。
|
||||
- 将旧几何字面量改为显式单位:例如 `:width 80` 改为 `:width (ch 80)`,
|
||||
`:width (240)` 改为 `:width (px 240)`,`:height 3` 改为 `:height (lh 3)`。
|
||||
先用 `python3 scripts/migrate-css-sizes.py PATH...` 预览支持的字面量迁移,检查
|
||||
动态或歧义报告后再加 `--write`。纵向几何不接受 `px`,`fit-content` 只使用裸关键词。
|
||||
- 需要选中或独占显示的调用方应显式选择相应窗口策略;普通 `ebox-display-buffer`
|
||||
遵循原生显示规则。只渲染不显示时仍使用 `ebox-render-to-buffer`。
|
||||
- 仅在使用 `:wrap-mode kp` 时安装 EKP 1.0.0 或更高版本;其可选 native 加速器与
|
||||
Ebox 的可选 Rust reflow 模块独立。
|
||||
- ECSS 0.1.0 与 TP 2.0.0 或更高版本互相独立,安装顺序任意;两者都安装后再安装
|
||||
Ebox 3.0.0。
|
||||
- 删除 `ebox-transaction-participant-route` 的自定义设置;Ebox 3.0.0 要求 TP
|
||||
提供 structured participant capability,缺失或格式错误时会 fail closed。
|
||||
- 数字 region id 更新改为先调用 `ebox-region-resolve`,再把返回的 handle 交给 `ebox-region-update`。
|
||||
- `ebox-pop-to-buffer` 或 `ebox-switch-to-buffer` 改为 `ebox-display-buffer`;如果显示行为由调用方控制,则使用 `ebox-render-to-buffer`。
|
||||
- Ebox 不扫描历史渲染文本来重建 runtime identity;请通过 Ebox 2.0 重新 mount 声明式 source。
|
||||
- Ebox 不扫描历史渲染文本来重建 runtime identity;请通过 Ebox 3.0 重新 mount 声明式 source。
|
||||
|
||||
### 发布状态与支持边界
|
||||
|
||||
- `3.0.0` 仍未发布;这些修改准备源码分发,不代表已上传包归档或发布版本 tag。
|
||||
- 每个已挂载 buffer 拥有一套视口布局,需要独立宽度的视图使用不同 buffer。纵向
|
||||
几何仍以完整行为单位。
|
||||
- 保留常见 UI 文本簇,但完整 Unicode 字素分段、浏览器级双向布局、层叠和绝对定位
|
||||
不属于当前契约。
|
||||
|
||||
64
DESIGN.md
64
DESIGN.md
@ -30,9 +30,71 @@ The owners are `ebox-child-range.el` for immutable child-sequence and key indexe
|
||||
|
||||
Use the functions and properties in `docs/user/ebox-api-reference.en.md`; the facade inventory is `ebox-public-api`. Do not call `ebox--*` names from an application or a sibling package. Higher-level Component, reactive, behavior, data, and control concepts belong to ETAF.
|
||||
|
||||
ECSS and TP are required package dependencies, even when an author does not
|
||||
write selectors or stylesheets. EKP is a separate optional typesetting
|
||||
dependency: selecting `:wrap-mode kp` requires its public paragraph layout API.
|
||||
An unavailable or incompatible EKP is an error, not permission to substitute a
|
||||
different wrapping algorithm. Its accelerator is independent of Ebox's Rust
|
||||
reflow module.
|
||||
|
||||
`ebox-display-buffer` renders before delegating window placement to native
|
||||
`display-buffer`. It accepts the caller's display action, returns the buffer,
|
||||
and does not choose an exclusive window layout or select the result. A buffer
|
||||
owns one mounted layout and viewport; independently sized views use distinct
|
||||
buffers rather than competing layouts over the same text.
|
||||
|
||||
## Layout scope
|
||||
|
||||
Ebox supports column- and pixel-based horizontal dimensions, line-based vertical dimensions, padding, margins, pixel borders, colors, overflow, wrapping, row/column/flex formatting, and Grid tracks including fixed, fractional, implicit, gap, placement, span, and alignment behavior. The public typography subset is `:font-family`, CSS-reference-pixel `:font-size`, `:font-weight`, and `:font-style`; `:font-slant` is only an exact parse-time alias of `:font-style`. Emacs faces and text properties belong to the final adapter and are not Ebox author properties. CSS compatibility is intentionally partial: percentages, absolute positioning, z-index, shadows, border radius, full browser typography, and browser-level bidi are outside this package.
|
||||
Ebox supports explicit `px`, `%`, `vw`, `vh`, `ch`, and `lh` dimensions, padding, margins, borders, colors, overflow, wrapping, row/column/flex formatting, and Grid tracks including fixed, fractional, implicit, gap, placement, span, and alignment behavior. The public typography subset is `:font-family`, CSS-reference-pixel `:font-size`, `:font-weight`, and `:font-style`; `:font-slant` is only an exact parse-time alias of `:font-style`. Arbitrary Emacs faces and text-property plists belong to the final adapter and are not Ebox author inputs. Four explicit native capabilities are supported separately: `:help-echo`, `:pointer`, `:hover-style`, and `:keymap`. CSS compatibility is intentionally partial: absolute positioning, z-index, shadows, border radius, full browser typography, and browser-level bidi are outside this package.
|
||||
|
||||
The text-cluster model covers common combining marks, variation selectors,
|
||||
emoji modifiers, ZWJ sequences, and regional-indicator pairs. It is deliberately
|
||||
smaller than complete Unicode grapheme segmentation; neither character count
|
||||
nor this limited segmentation promises arbitrary font-shaping equivalence.
|
||||
|
||||
Optional interaction navigation moves point between currently rendered keymap
|
||||
owners. It reads the committed surface, respects narrowing and invisibility,
|
||||
and installs no key bindings, focus registry, or application state. Ownership
|
||||
of actions and state remains with the caller.
|
||||
|
||||
Horizontal `:overflow hidden` is enforced on the composed content before the
|
||||
owner's padding and border are added. It selects a measured prefix of complete
|
||||
supported text clusters and fills the remaining pixels with space. Fixed
|
||||
display spaces may shrink; glyphs and images are not partially raster-cropped.
|
||||
Clipped child properties do not survive on neutral filler. The algorithm adapts
|
||||
the prefix-and-filler approach from
|
||||
[s-pixel](https://github.com/Kinneyzhang/s-pixel/tree/a2a0d6ae6b3bd71c1a70084d0ea37f56463e5fe0)
|
||||
under GPL-3.0-or-later, with Ebox's cluster/property model and no `s` or
|
||||
`s-pixel` dependency. It does not add stacking or overlap composition.
|
||||
|
||||
## Size composition
|
||||
|
||||
`ebox-size.el` owns pure length validation, dependency discovery, and resolution
|
||||
to fractional pixels, plus a pure cumulative quantization helper used at the
|
||||
display boundaries chosen by layout. Its context contains measured viewport axes, the
|
||||
property's percentage reference, zero-glyph advance, and line height. It does
|
||||
not read windows or nodes, evaluate arbitrary Lisp, choose property defaults,
|
||||
or publish buffers. This keeps unit arithmetic reusable across normal, Flex,
|
||||
Grid, spacing, and border geometry.
|
||||
|
||||
The author format is `(unit number)` and the composable functions are `calc`,
|
||||
`min`, `max`, and `clamp`. Property schemas own allowed units, keywords, and
|
||||
range rules. The [shared unit-constraint table](docs/user/ebox-user-guide.en.md#size-unit-constraints)
|
||||
is the author contract: inline geometry, block geometry, and border paint have
|
||||
distinct unit domains. Validation traverses every function branch and track
|
||||
operand; Flex basis uses its parent main axis. Invalid unit combinations fail
|
||||
during construction, before layout or display.
|
||||
|
||||
Layout supplies containing-block and font measurements and resolves
|
||||
intrinsic/automatic sizes. `fit-content` is a bare keyword only. Removed bare
|
||||
lengths, singleton pixel lists, viewport keywords, and `contain` have no
|
||||
compatibility execution path.
|
||||
|
||||
Missing reference measurements defer resolution rather than becoming zero.
|
||||
Intermediate arithmetic keeps fractions until the formatting/display boundary.
|
||||
The buffer backend materializes block geometry in whole lines, matching the
|
||||
restricted block-unit contract. An empty Box contributes zero automatic
|
||||
content height; explicit height, padding, or borders supply any reserved extent.
|
||||
|
||||
## Verification rule
|
||||
|
||||
|
||||
49
DESIGN.zh.md
49
DESIGN.zh.md
@ -30,9 +30,56 @@ Ebox 是底层空间渲染引擎。它负责把声明式节点树转换为经过
|
||||
|
||||
使用 `docs/user/ebox-api-reference.zh.md` 中的函数与 property;门面清单是 `ebox-public-api`。应用和同级包不得调用 `ebox--*` 私有名称。更高层的 Component、响应式、behavior、data 和 control 概念属于 ETAF。
|
||||
|
||||
ECSS 与 TP 是必需的包依赖,即使作者没有写选择器或样式表也需要。EKP 是独立的可选
|
||||
排版依赖:选择 `:wrap-mode kp` 时需要它的公开段落排版 API。EKP 不可用或不兼容时
|
||||
会报错,不会擅自替换换行算法。它的加速器与 Ebox Rust reflow 模块相互独立。
|
||||
|
||||
`ebox-display-buffer` 先渲染,再把窗口放置交给原生 `display-buffer`。它接受调用方的
|
||||
display action、返回 buffer,不选择独占窗口布局,也不选中结果窗口。一个 buffer
|
||||
拥有一套挂载布局与视口;需要独立尺寸的视图应使用不同 buffer,不在同一段文本上
|
||||
维护互相竞争的布局。
|
||||
|
||||
## 布局范围
|
||||
|
||||
Ebox 支持以列或像素表示的横向尺寸、以行表示的纵向尺寸、padding、margin、像素 border、颜色、overflow、换行、row/column/flex formatting,以及固定、分数、隐式、gap、放置、span 和对齐等 Grid 能力。公共 typography 子集是 `:font-family`、以 CSS reference pixel 表示的 `:font-size`、`:font-weight` 和 `:font-style`;`:font-slant` 只是 `:font-style` 的精确 parse-time alias。Emacs face 与文本属性只属于最终 adapter,不是 Ebox 作者属性。CSS 兼容性有意是部分实现:百分比、绝对定位、z-index、阴影、border radius、完整浏览器 typography 和浏览器级 bidi 不属于本包。
|
||||
Ebox 支持显式 `px`、`%`、`vw`、`vh`、`ch`、`lh` 尺寸、padding、margin、border、颜色、overflow、换行、row/column/flex formatting,以及固定、分数、隐式、gap、放置、span 和对齐等 Grid 能力。公共 typography 子集是 `:font-family`、以 CSS reference pixel 表示的 `:font-size`、`:font-weight` 和 `:font-style`;`:font-slant` 只是 `:font-style` 的精确 parse-time alias。任意 Emacs face 和文本属性 plist 属于最终 adapter,不是 Ebox 作者输入;四种显式原生能力 `:help-echo`、`:pointer`、`:hover-style`、`:keymap` 单独受支持。CSS 兼容性有意是部分实现:绝对定位、z-index、阴影、border radius、完整浏览器 typography 和浏览器级 bidi 不属于本包。
|
||||
|
||||
文本簇模型覆盖常见组合音标、变体选择符、Emoji 修饰符、ZWJ 序列和区域指示符配对。
|
||||
它的范围小于完整 Unicode 字素分段;字符数量或这个有限的分段模型,都不能保证任意
|
||||
字体塑造结果相同。
|
||||
|
||||
可选交互导航在当前渲染的 keymap owner 之间移动 point,读取已提交 surface,尊重
|
||||
narrowing 与不可见性,不安装按键绑定、焦点注册表或应用状态。action 与状态仍属于
|
||||
调用方。
|
||||
|
||||
横向 `:overflow hidden` 在内容组合后、自身 padding 和 border 添加前执行。它选取
|
||||
测量后能容纳的完整受支持文本簇前缀,再用空白补齐剩余像素。固定 display space 可以
|
||||
缩短,字形与图像不做局部位图裁剪;裁掉的子节点属性不会残留在中性填充上。
|
||||
算法按 GPL-3.0-or-later 复用了
|
||||
[s-pixel](https://github.com/Kinneyzhang/s-pixel/tree/a2a0d6ae6b3bd71c1a70084d0ea37f56463e5fe0)
|
||||
的前缀加空白补齐思路,适配 Ebox 的文本簇与属性模型,不增加 `s` 或 `s-pixel` 依赖,
|
||||
也不引入层叠或重叠组合。
|
||||
|
||||
## 尺寸组合
|
||||
|
||||
`ebox-size.el` 负责长度验证、依赖识别、浮点像素换算,并提供累计量化的纯函数,
|
||||
供布局在选定的显示边界调用。上下文显式传入视口两轴、
|
||||
该属性的百分比参照、`0` 字形前进宽度和行高;模块不读取窗口或节点,不求值任意
|
||||
Lisp,不选择属性默认值,也不发布 buffer。Normal、Flex、Grid、间距和边框几何
|
||||
由此复用同一套单位运算。
|
||||
|
||||
作者格式为 `(单位 数值)`,可组合函数是 `calc`、`min`、`max`、`clamp`。
|
||||
property schema 负责允许的单位、关键词和值域。[统一单位约束表](docs/user/ebox-user-guide.zh.md#size-unit-constraints)
|
||||
是作者契约:inline 几何、block 几何和边框绘制采用不同单位集合。验证递归遍历
|
||||
所有函数分支和轨道参数;Flex basis 使用所在父布局主轴。无效单位组合在构造阶段
|
||||
就报错,不延后到布局或显示。
|
||||
|
||||
布局负责提供包含块及字体测量,并解析固有/自动尺寸。`fit-content` 只接受裸关键词。
|
||||
旧的裸长度、单元素像素列表、视口
|
||||
关键词和 `contain` 不保留兼容执行路径。
|
||||
|
||||
参照尺寸缺失时延后解析,不当作零;中间运算保留小数,到格式化/显示边界才量化。
|
||||
buffer 后端按完整行生成 block 几何,与收窄后的纵向单位契约一致。空 Box 的自动
|
||||
内容高度为零;需要占位时由显式高度、padding 或 border 提供对应尺寸。
|
||||
|
||||
## 验证规则
|
||||
|
||||
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
74
Makefile
74
Makefile
@ -1,8 +1,13 @@
|
||||
EMACS ?= emacs
|
||||
CARGO ?= cargo
|
||||
PYTHON ?= python3
|
||||
TP_DIR ?= ../tp
|
||||
ECSS_DIR ?= ../ecss
|
||||
LOAD_EXTRA ?=
|
||||
RELEASE_DIR ?= /tmp/ebox-release
|
||||
RELEASE_DEPS_DIR ?= /tmp/ebox-release-deps
|
||||
RELEASE_OPTIONS ?=
|
||||
NATIVE_MODULE ?=
|
||||
|
||||
EMACS_BATCH = $(EMACS) -Q --batch -L . -L $(ECSS_DIR) -L $(TP_DIR) $(LOAD_EXTRA) --eval '(setq load-prefer-newer t)'
|
||||
EMACS_TEST = $(EMACS_BATCH) -L tests -l tests/ebox-fixtures.el
|
||||
@ -10,10 +15,48 @@ NATIVE_MANIFEST = native/Cargo.toml
|
||||
NATIVE_TARGET ?= $(shell $(EMACS_BATCH) -l ebox-native-reflow.el --eval '(princ (ebox-native-reflow--rust-target))')
|
||||
NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release
|
||||
|
||||
.PHONY: all check ci load compile test checkdoc source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests state-contract-tests layout-boundary-tests layout-boundary-performance patch-plan-tests patch-plan-performance style-schema-tests style-schema-performance spi-tests spi-performance docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install
|
||||
.PHONY: all check ci load compile test checkdoc runtime-index-tests source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests viewport-topology-tests vertical-materialization-tests box-line-flow-tests m0a-tests state-contract-tests layout-boundary-tests layout-boundary-performance patch-plan-tests patch-plan-performance style-schema-tests style-schema-performance spi-tests spi-performance c1b-contract-tests docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install
|
||||
|
||||
.PHONY: node-memo-generation-tests
|
||||
.PHONY: interaction-tests
|
||||
.PHONY: display-tests release-deps release-build release-check release-tool-tests native-integration release-native-check
|
||||
|
||||
all: check
|
||||
|
||||
.PHONY: layer-tests
|
||||
layer-tests:
|
||||
$(EMACS_TEST) -l tests/ebox-composite-tests.el -l tests/ebox-layer-style-tests.el -l tests/ebox-layer-tests.el -l tests/ebox-layer-publication-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
test: layer-tests
|
||||
|
||||
interaction-tests:
|
||||
$(EMACS_TEST) -l tests/ebox-interaction-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
test: interaction-tests
|
||||
|
||||
display-tests:
|
||||
$(EMACS_TEST) -l tests/ebox-display-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
test: display-tests release-tool-tests
|
||||
|
||||
release-deps:
|
||||
$(PYTHON) scripts/ebox-release.py deps --directory "$(RELEASE_DEPS_DIR)" $(RELEASE_OPTIONS)
|
||||
|
||||
release-build:
|
||||
$(PYTHON) scripts/ebox-release.py build --directory "$(RELEASE_DIR)" $(RELEASE_OPTIONS)
|
||||
|
||||
release-check: release-build
|
||||
$(PYTHON) scripts/ebox-release.py verify --directory "$(RELEASE_DIR)" --emacs "$(EMACS)"
|
||||
|
||||
native-integration:
|
||||
$(PYTHON) scripts/ebox-release.py verify --directory "$(RELEASE_DIR)" --emacs "$(EMACS)" --native-module "$(NATIVE_MODULE)"
|
||||
|
||||
release-native-check:
|
||||
$(PYTHON) scripts/ebox-release.py verify --directory "$(RELEASE_DIR)" --emacs "$(EMACS)" --build-native
|
||||
|
||||
release-tool-tests:
|
||||
$(PYTHON) -m unittest discover -s scripts -p 'test_ebox_release.py'
|
||||
|
||||
check: ci
|
||||
|
||||
ci: checkdoc load compile test visual-check diff-check
|
||||
@ -25,7 +68,17 @@ compile:
|
||||
rm -f *.elc tests/*.elc scripts/*.elc
|
||||
$(EMACS_BATCH) --eval '(setq byte-compile-error-on-warn t byte-compile-warnings (quote (not obsolete)))' -l ebox.el --eval '(ebox-byte-compile)'
|
||||
|
||||
test: source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests state-contract-tests layout-boundary-tests patch-plan-tests style-schema-tests spi-tests docs-contract-tests ci-contract-tests
|
||||
test: size-tests runtime-index-tests source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests viewport-topology-tests vertical-materialization-tests box-line-flow-tests node-memo-generation-tests m0a-tests state-contract-tests layout-boundary-tests patch-plan-tests style-schema-tests spi-tests docs-contract-tests ci-contract-tests
|
||||
|
||||
.PHONY: size-tests size-migration-tests
|
||||
size-tests: size-migration-tests
|
||||
$(EMACS_BATCH) -L tests -l tests/ebox-size-tests.el -l tests/ebox-size-style-tests.el -l tests/ebox-css-layout-tests.el -l tests/ebox-css-flex-grid-tests.el -l tests/ebox-css-viewport-tests.el -l tests/ebox-size-native-tests.el -l tests/ebox-zero-height-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
size-migration-tests:
|
||||
python3 -m unittest discover -s scripts -p 'test_migrate_css_sizes.py'
|
||||
|
||||
runtime-index-tests:
|
||||
$(EMACS_BATCH) -l tests/ebox-runtime-index-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
source-tests:
|
||||
$(EMACS_BATCH) -l tests/ebox-source-tests.el -f ert-run-tests-batch-and-exit
|
||||
@ -63,6 +116,21 @@ dsl-tests:
|
||||
flex-tests:
|
||||
$(EMACS_TEST) -l tests/ebox-flex-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
viewport-topology-tests:
|
||||
$(EMACS_TEST) -l tests/ebox-viewport-topology-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
vertical-materialization-tests:
|
||||
$(EMACS_TEST) -l tests/ebox-vertical-materialization-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
box-line-flow-tests:
|
||||
$(EMACS_TEST) -l tests/ebox-box-line-flow-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
node-memo-generation-tests:
|
||||
$(EMACS_TEST) -l tests/ebox-node-memo-generation-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
m0a-tests:
|
||||
$(EMACS_TEST) -l tests/ebox-m0a-inventory-fixture.el -l tests/ebox-m0a-inventory-tests.el -l tests/ebox-m0a-characterization-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
state-contract-tests:
|
||||
$(EMACS_TEST) -l tests/ebox-state-contract-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
@ -87,6 +155,8 @@ style-schema-performance:
|
||||
spi-tests:
|
||||
$(EMACS_TEST) -l tests/ebox-spi-tests.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
c1b-contract-tests: ebox-commit-tests spi-tests package-tests docs-contract-tests
|
||||
|
||||
spi-performance:
|
||||
$(EMACS_TEST) -l tests/ebox-spi-tests.el --eval "(ert-run-tests-batch-and-exit 'ebox-spi-performance-builds-bounded-provider-snapshots)"
|
||||
|
||||
|
||||
165
README.md
165
README.md
@ -7,9 +7,20 @@ application also needs Components, reactive state, behaviors, or lifecycle.
|
||||
|
||||
## Install
|
||||
|
||||
Ebox requires Emacs 29.1 or newer, ECSS, and TP. A package manager should
|
||||
install the declared dependencies. For sibling source checkouts, add the three
|
||||
directories to `load-path`, then load Ebox:
|
||||
The source repository is [geekinney/ebox](https://gitea.gklazycat.heiyu.space/geekinney/ebox).
|
||||
The current `3.0.0` changelog is unreleased; a source checkout is not a published
|
||||
package archive or stable release tag.
|
||||
|
||||
| Dependency | Required for |
|
||||
| --- | --- |
|
||||
| Emacs 29.1 or newer | All Ebox use. |
|
||||
| ECSS 0.1.0 or newer | Style computation and selector semantics; required even without a stylesheet. |
|
||||
| TP 2.0.0 or newer | Retained publication, native property policies, and transactions; required when loading Ebox. |
|
||||
| [EKP 1.0.0 or newer](https://github.com/Kinneyzhang/emacs-kp) | Optional; required only when using `:wrap-mode kp`. |
|
||||
|
||||
ECSS and TP are declared package dependencies. EKP is deliberately optional:
|
||||
ordinary `word`, `char`, and `none` wrapping does not need it. For sibling source
|
||||
checkouts, add the required directories to `load-path`, then load Ebox:
|
||||
|
||||
```elisp
|
||||
(add-to-list 'load-path "/path/to/ecss")
|
||||
@ -20,6 +31,54 @@ directories to `load-path`, then load Ebox:
|
||||
|
||||
Loading Ebox does not create a buffer or build native code.
|
||||
|
||||
For Knuth–Plass paragraph layout, also add the EKP source directory to
|
||||
`load-path`, or install its package, before rendering a Box with `:wrap-mode kp`:
|
||||
|
||||
```elisp
|
||||
(add-to-list 'load-path "/path/to/ekp")
|
||||
(ebox-render
|
||||
(ebox-build '(box :width (ch 40) :wrap-mode kp :overflow hidden
|
||||
"A paragraph laid out with Knuth–Plass line breaking.")))
|
||||
```
|
||||
|
||||
Ebox loads EKP when that wrapping mode is used. A missing or incompatible EKP
|
||||
signals an installation or update error; install EKP or explicitly choose
|
||||
another wrapping mode. Ebox does not silently render using `word`.
|
||||
EKP's optional accelerator is separate
|
||||
from Ebox's optional Rust module.
|
||||
|
||||
### Build and verify an installable archive
|
||||
|
||||
From this checkout, run:
|
||||
|
||||
```sh
|
||||
make release-check EMACS=emacs RELEASE_DIR=/tmp/ebox-release-3.0.0
|
||||
```
|
||||
|
||||
This fetches the exact dependency commits in
|
||||
[`release-dependencies.json`](release-dependencies.json), builds deterministic
|
||||
`package.el` source archives, and installs them into a temporary clean profile
|
||||
to exercise rendering, updates, and selectors. It preserves the archive
|
||||
directory and `manifest.json`, including checksums and source revision/dirty
|
||||
status; it does not publish or change your Emacs profile. Choose a new output
|
||||
directory on each run: existing directories are not overwritten.
|
||||
|
||||
To include and test optional KP layout, use a different output directory and
|
||||
add `RELEASE_OPTIONS=--with-ekp`. Once an archive has passed verification, it can
|
||||
be used as a local package archive:
|
||||
|
||||
```elisp
|
||||
(require 'package)
|
||||
(add-to-list 'package-archives '("ebox-local" . "/tmp/ebox-release-3.0.0/"))
|
||||
(package-refresh-contents)
|
||||
(package-install 'ebox)
|
||||
;; Optional, only if the archive was built with --with-ekp:
|
||||
;; (package-install 'ekp)
|
||||
```
|
||||
|
||||
Tool inputs, cleanup, offline reuse, and native verification are documented in
|
||||
the [maintainer guide](docs/maintainer/ebox-current-implementation-reference.en.md#source-package-distribution).
|
||||
|
||||
## First render
|
||||
|
||||
The ordinary author model has seven entries: a string, `text`, `box`, `row`,
|
||||
@ -32,10 +91,10 @@ field-based child syntax.
|
||||
(ebox-render-to-buffer
|
||||
"*Ebox Example*"
|
||||
(ebox-build
|
||||
'(column :padding (1 2)
|
||||
:border (1 solid "#8A93A6")
|
||||
'(column :padding ((lh 1) (ch 2))
|
||||
:border ((px 1) solid "#8A93A6")
|
||||
(text :color "#263244" "Hello Ebox")
|
||||
(row :item-gap 1
|
||||
(row :item-gap (ch 1)
|
||||
(box "Left")
|
||||
(box "Right")))))
|
||||
```
|
||||
@ -45,6 +104,13 @@ assemble typed nodes with one `ebox-source-builder`, then seal the forest and
|
||||
its source generation as one `CanonicalEboxInput`; that evaluated API is not a
|
||||
second author grammar.
|
||||
|
||||
Typed Box construction containing `ebox-child-range` descriptors passes its
|
||||
open builder explicitly as `:source-builder`. The builder must own the Box
|
||||
and its immediate children's source handles; an empty Range still requires
|
||||
an open builder. It is used only during construction. To compose an existing canonical input,
|
||||
use `ebox-canonical-input-roots` and `ebox-canonical-input-import-roots`;
|
||||
frameworks must not access private canonical fields or dynamic context.
|
||||
|
||||
## Layout choices
|
||||
|
||||
- `box` creates a normal visual box.
|
||||
@ -56,17 +122,72 @@ second author grammar.
|
||||
Flex and Grid participation properties belong directly to a child `box`. They
|
||||
do not require a wrapper node.
|
||||
|
||||
Box geometry uses `(unit number)`: `px`, `%`, `vw`, `vh`, `ch`, and `lh`.
|
||||
For example, `:width (ch 80)` and `:height (calc (- (vh 100) (lh 1)))` mean an
|
||||
80-zero-glyph width and a viewport height minus one line height. `calc`, `min`,
|
||||
`max`, and `clamp` compose lengths at layout time. Size keywords such as `auto`
|
||||
and bare `fit-content` stay symbols. Old bare lengths, singleton pixel lists,
|
||||
and viewport keywords are rejected. Units are checked against the property axis
|
||||
at construction, including every branch of a size function. See the [shared
|
||||
unit-constraint table](docs/user/ebox-user-guide.en.md#size-unit-constraints)
|
||||
for the allowed combinations, keyword defaults, percentage references, and
|
||||
vertical line quantization. An empty Box has zero automatic content height;
|
||||
use `(box :height (lh 1))` to reserve a blank line.
|
||||
|
||||
## Native interaction
|
||||
|
||||
Text and every Box form accept four explicit node capabilities: `:help-echo`
|
||||
for a string or native help function, `:pointer` for a native pointer shape,
|
||||
`:hover-style` for restricted color and text-decoration paint, and `:keymap`
|
||||
for a native Emacs keymap. Box help, pointer, and keymap cover its content,
|
||||
padding, and border; they exclude that Box's margin and structural newlines.
|
||||
Hover shares the declaring node's paint across text and padding, excluding
|
||||
physical left/right borders. Nested explicit
|
||||
values override enclosing capabilities; explicit `nil` clears one capability.
|
||||
|
||||
Use `ebox-help-create` to adapt a zero-argument business function to native help
|
||||
with the hovered buffer as its context. Use `ebox-keymap-create` to bind
|
||||
ordinary zero-argument callbacks to activation
|
||||
keys or custom keys; it supplies the clicked window's buffer for mouse commands.
|
||||
Use `ebox-region-update` with a semantic ID in the current mounted buffer or
|
||||
an explicit region handle to replace or clear these capabilities.
|
||||
Native keymaps support independent Ebox interactions. Optional
|
||||
`ebox-next-interaction` and `ebox-previous-interaction` commands move point
|
||||
between rendered keymap owners without adding default key bindings.
|
||||
Application state and command behavior remain the author's responsibility.
|
||||
See the [interaction guide](docs/user/ebox-user-guide.en.md#native-node-interaction)
|
||||
and the Playground's [interaction lab](../ebox-playground/README.md#native-interaction-lab).
|
||||
|
||||
## Render and update
|
||||
|
||||
- `ebox-render` returns propertized text without publishing a live buffer.
|
||||
- `ebox-render-to-buffer` mounts a retained surface.
|
||||
- `ebox-display-buffer` renders successfully first, then displays the buffer
|
||||
using Emacs display rules and an optional action; it returns the buffer.
|
||||
- `ebox-commit` atomically publishes a newly built canonical input.
|
||||
- `ebox-buffer-update-report` returns the last successful update report.
|
||||
- `ebox-rerender-buffer-with-context` applies an explicit viewport change.
|
||||
- `ebox-surface-buffer-snapshot` explicitly exports the current committed
|
||||
`:input`, `:revision`, and `:mount-id` as one plist.
|
||||
|
||||
Ebox copies canonical input before assigning runtime identity, so one built
|
||||
value may be mounted in multiple buffers without sharing live ownership.
|
||||
|
||||
Snapshots traverse exported data and TP's latest retained diagnostic report
|
||||
only when requested; ordinary updates do not create them.
|
||||
Their canonical input remains usable after later updates or unmounting. Mutable
|
||||
node data and interaction keymaps are detached, while immutable source facts
|
||||
and opaque capabilities (callbacks, records) retain identity. The display environment and
|
||||
external capabilities are not frozen. Querying during a TP transaction or
|
||||
after unmounting signals an error. Compare both mount ID and revision when
|
||||
identifying a committed generation; remounting can restart revision numbers.
|
||||
|
||||
Ebox registers rollback-capable state only through TP 2.0.0's public structured
|
||||
participant API and final v2 protocol. Older transition builds do not provide
|
||||
the native property ownership and hover identity guarantees required by this
|
||||
release. Missing or malformed structured participant capability stops Ebox
|
||||
loading instead of selecting a compatibility writer.
|
||||
|
||||
## Optional native module
|
||||
|
||||
The Rust module accelerates eligible reflow work. It is optional and has an
|
||||
@ -77,12 +198,39 @@ exact Elisp fallback. Ebox never builds it while loading.
|
||||
(ebox-native-build)
|
||||
```
|
||||
|
||||
## Display and text boundaries
|
||||
|
||||
Each mounted buffer has one viewport layout. Showing the same buffer in two
|
||||
windows of different widths does not create two independent layouts. Use
|
||||
separate mounted buffers for independent widths; the same canonical input may
|
||||
be mounted more than once.
|
||||
|
||||
Vertical geometry is materialized in whole lines. Text handling preserves
|
||||
common combining-mark, variation-selector, emoji-modifier, ZWJ, and flag
|
||||
sequences; it does not implement complete Unicode grapheme segmentation or a
|
||||
browser's typography and bidirectional layout. Font availability and native
|
||||
pointer/help display depend on Emacs and the window system. Terminal output
|
||||
cannot reproduce every GUI pixel or pointer effect.
|
||||
|
||||
`:overflow hidden` limits content to the box's measured width and whole-line
|
||||
height, preserving its padding and borders. Horizontal clipping keeps whole
|
||||
supported text clusters and fills unused pixels with space; it does not render
|
||||
partial glyphs or insert an ellipsis. `scroll` remains vertical scrolling.
|
||||
|
||||
The CI configuration covers Emacs 29.1 and 30.2 on Linux, and source-package
|
||||
installation plus native-module loading/execution on Linux, macOS, and Windows
|
||||
with Emacs 30.2. Those jobs define the verification matrix; they are not a claim
|
||||
that every platform's GUI rendering has been visually verified. Consult the
|
||||
workflow results for the particular revision being installed.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make c1b-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make native-rust-tests
|
||||
```
|
||||
@ -90,3 +238,8 @@ make native-rust-tests
|
||||
See the [user guide](docs/user/ebox-user-guide.en.md), the [public API
|
||||
reference](docs/user/ebox-api-reference.en.md), and the sibling
|
||||
[ebox-playground](../ebox-playground/README.md) examples.
|
||||
|
||||
## License
|
||||
|
||||
Ebox-owned code is distributed under GPL-3.0-or-later; see [LICENSE](LICENSE).
|
||||
Third-party files retain their own license notices.
|
||||
|
||||
135
README.zh-CN.md
135
README.zh-CN.md
@ -6,8 +6,18 @@ Component、响应式状态、behavior 或生命周期时,使用同级 ETAF
|
||||
|
||||
## 安装
|
||||
|
||||
Ebox 需要 Emacs 29.1 或更高版本,并依赖 ECSS 与 TP。包管理器应自动安装声明的
|
||||
依赖。使用同级源码 checkout 时,把三个目录加入 `load-path` 后加载 Ebox:
|
||||
源码仓库为 [geekinney/ebox](https://gitea.gklazycat.heiyu.space/geekinney/ebox)。
|
||||
当前 `3.0.0` 更新日志仍标记为未发布;源码 checkout 不代表已经发布的包归档或稳定版本 tag。
|
||||
|
||||
| 依赖 | 使用条件 |
|
||||
| --- | --- |
|
||||
| Emacs 29.1 或更高版本 | 所有 Ebox 使用场景。 |
|
||||
| ECSS 0.1.0 或更高版本 | 样式计算和选择器语义;即使不使用样式表也需要。 |
|
||||
| TP 2.0.0 或更高版本 | retained 发布、原生属性策略与事务;加载 Ebox 时需要。 |
|
||||
| [EKP 1.0.0 或更高版本](https://github.com/Kinneyzhang/emacs-kp) | 可选,仅使用 `:wrap-mode kp` 时需要。 |
|
||||
|
||||
ECSS 与 TP 是包声明的必需依赖。EKP 保持可选,普通 `word`、`char`、`none` 换行不需要它。
|
||||
使用同级源码 checkout 时,把必需的目录加入 `load-path` 后加载 Ebox:
|
||||
|
||||
```elisp
|
||||
(add-to-list 'load-path "/path/to/ecss")
|
||||
@ -18,6 +28,48 @@ Ebox 需要 Emacs 29.1 或更高版本,并依赖 ECSS 与 TP。包管理器应
|
||||
|
||||
加载 Ebox 不会创建 buffer,也不会构建 native 模块。
|
||||
|
||||
使用 Knuth–Plass 段落排版时,先额外将 EKP 源码目录加入 `load-path`,或安装它的包,
|
||||
然后在 Box 上设置 `:wrap-mode kp`:
|
||||
|
||||
```elisp
|
||||
(add-to-list 'load-path "/path/to/ekp")
|
||||
(ebox-render
|
||||
(ebox-build '(box :width (ch 40) :wrap-mode kp :overflow hidden
|
||||
"A paragraph laid out with Knuth–Plass line breaking.")))
|
||||
```
|
||||
|
||||
Ebox 在使用该换行模式时加载 EKP。EKP 缺失或不兼容时会提示安装或更新;请安装 EKP,
|
||||
或显式选择其他换行模式。Ebox 不会静默改用 `word`。EKP 自己的可选加速器与
|
||||
Ebox 的可选 Rust 模块是两回事。
|
||||
|
||||
### 构建并验证可安装归档
|
||||
|
||||
在本仓库中运行:
|
||||
|
||||
```sh
|
||||
make release-check EMACS=emacs RELEASE_DIR=/tmp/ebox-release-3.0.0
|
||||
```
|
||||
|
||||
该入口按 [`release-dependencies.json`](release-dependencies.json) 获取精确的依赖提交,
|
||||
构建可重复的 `package.el` 源码归档,在临时干净配置中安装并验证渲染、更新和选择器。
|
||||
归档目录与 `manifest.json` 会保留,其中记录校验和、源码提交及工作区是否有修改;
|
||||
它不会发布归档或修改你的 Emacs 配置。每次使用新的输出目录,不覆盖已有目录。
|
||||
|
||||
需要同时包含并测试可选 KP 排版时,换一个输出目录并加上 `RELEASE_OPTIONS=--with-ekp`。
|
||||
归档验证通过后,可以作为本地包归档安装:
|
||||
|
||||
```elisp
|
||||
(require 'package)
|
||||
(add-to-list 'package-archives '("ebox-local" . "/tmp/ebox-release-3.0.0/"))
|
||||
(package-refresh-contents)
|
||||
(package-install 'ebox)
|
||||
;; 可选,仅当归档构建时使用了 --with-ekp:
|
||||
;; (package-install 'ekp)
|
||||
```
|
||||
|
||||
工具输入、清理、离线复用与 native 验证见
|
||||
[维护者指南](docs/maintainer/ebox-current-implementation-reference.zh.md#源码包分发)。
|
||||
|
||||
## 第一次渲染
|
||||
|
||||
普通用户只需理解七个 author 入口:字符串、`text`、`box`、`row`、`column`、
|
||||
@ -29,10 +81,10 @@ Ebox 需要 Emacs 29.1 或更高版本,并依赖 ECSS 与 TP。包管理器应
|
||||
(ebox-render-to-buffer
|
||||
"*Ebox Example*"
|
||||
(ebox-build
|
||||
'(column :padding (1 2)
|
||||
:border (1 solid "#8A93A6")
|
||||
'(column :padding ((lh 1) (ch 2))
|
||||
:border ((px 1) solid "#8A93A6")
|
||||
(text :color "#263244" "Hello Ebox")
|
||||
(row :item-gap 1
|
||||
(row :item-gap (ch 1)
|
||||
(box "Left")
|
||||
(box "Right")))))
|
||||
```
|
||||
@ -41,6 +93,12 @@ Ebox 需要 Emacs 29.1 或更高版本,并依赖 ECSS 与 TP。包管理器应
|
||||
`ebox-source-builder` 组合 typed node,再把 forest 与同一代 source facts 封装为一个
|
||||
`CanonicalEboxInput`;这个 evaluated API 不是第二套 author 语法。
|
||||
|
||||
已求值的 Box 构造包含 `ebox-child-range` 时,通过 `:source-builder`
|
||||
显式传入开放的 builder。它必须拥有 Box 及所有直接子节点的 source handle;
|
||||
空 Range 同样需要开放的 builder。builder 只参与构造,不保留在节点中。
|
||||
组合已有 canonical input 时,使用 `ebox-canonical-input-roots` 与
|
||||
`ebox-canonical-input-import-roots`,框架不应读取私有字段或绑定私有动态上下文。
|
||||
|
||||
## 如何选择布局
|
||||
|
||||
- `box` 创建普通视觉盒子;
|
||||
@ -51,17 +109,58 @@ Ebox 需要 Emacs 29.1 或更高版本,并依赖 ECSS 与 TP。包管理器应
|
||||
|
||||
Flex/Grid participation property 直接属于子 `box`,不需要额外 wrapper 节点。
|
||||
|
||||
Box 几何统一写成 `(单位 数值)`,支持 `px`、`%`、`vw`、`vh`、`ch`、`lh`。
|
||||
例如 `:width (ch 80)` 表示 80 个 `0` 字形的前进宽度,
|
||||
`:height (calc (- (vh 100) (lh 1)))` 表示视口高度减一个行高。
|
||||
`calc`、`min`、`max`、`clamp` 在布局时组合长度;`auto`、裸 `fit-content` 等
|
||||
尺寸关键词仍直接写符号。旧的裸长度、单元素像素列表和视口关键词会报错。
|
||||
构造时按属性方向检查单位,包括尺寸函数的所有分支。允许的组合、关键词默认值、
|
||||
百分比参照及纵向行粒度限制见[统一单位约束表](docs/user/ebox-user-guide.zh.md#size-unit-constraints)。
|
||||
空 Box 的自动内容高度为零;需要一行空白时写 `(box :height (lh 1))`。
|
||||
|
||||
## 原生交互
|
||||
|
||||
Text 与所有 Box form 都接受四种显式节点能力:`:help-echo` 提供字符串或原生帮助
|
||||
函数,`:pointer` 选择原生鼠标指针形状,`:hover-style` 设置受限的颜色与文本装饰
|
||||
绘制,`:keymap` 接收原生 Emacs keymap。Box 的帮助、指针和 keymap 范围包含内容、
|
||||
padding 和 border,不包含该 Box 的 margin 和结构性换行符。悬停在文字与 padding
|
||||
间共享声明节点的绘制,不包含物理左右边框。嵌套节点的显式值覆盖外层能力;
|
||||
显式 `nil` 清除对应能力。
|
||||
|
||||
使用 `ebox-help-create` 将零参数业务函数适配为原生帮助,并提供悬停 buffer 上下文。
|
||||
使用 `ebox-keymap-create` 将普通零参数回调绑定到激活键或自定义按键;鼠标命令会
|
||||
自动在被点击窗口的 buffer 中运行。`ebox-region-update` 可以使用当前已挂载 buffer
|
||||
中的语义 ID,或显式 region handle,替换或清除节点能力。原生 keymap 可用于
|
||||
独立的 Ebox 交互;可选的 `ebox-next-interaction`、`ebox-previous-interaction` 命令
|
||||
在渲染后的 keymap owner 之间移动 point,不添加默认按键绑定。应用状态与命令行为
|
||||
由作者管理。参见
|
||||
[交互指南](docs/user/ebox-user-guide.zh.md#native-node-interaction)和 Playground 的
|
||||
[交互实验室](../ebox-playground/README.zh-CN.md#native-interaction-lab)。
|
||||
|
||||
## 渲染与更新
|
||||
|
||||
- `ebox-render` 返回带属性文本,不发布 live buffer;
|
||||
- `ebox-render-to-buffer` 挂载 retained surface;
|
||||
- `ebox-display-buffer` 先完成渲染,再按 Emacs 显示规则与可选 action 显示结果,返回 buffer;
|
||||
- `ebox-commit` 原子发布重新构建的 canonical input;
|
||||
- `ebox-buffer-update-report` 返回最近一次成功更新报告;
|
||||
- `ebox-rerender-buffer-with-context` 应用显式 viewport 变化。
|
||||
- `ebox-surface-buffer-snapshot` 显式导出当前提交的 `:input`、`:revision`
|
||||
与 `:mount-id`,以一个 plist 返回。
|
||||
|
||||
Ebox 会在分配 runtime identity 前复制 canonical input,因此同一个 built value 可以
|
||||
挂载到多个 buffer,而不会共享 live ownership。
|
||||
|
||||
快照只在显式查询时遍历导出数据和 TP 最近的诊断报告,普通更新不会创建快照。其 canonical input 在后续
|
||||
更新、卸载后仍可使用;节点中的可变数据与交互 keymap 独立复制,不可变 source facts
|
||||
与不透明能力(回调、record)保留身份。快照不冻结显示环境或外部能力。TP 事务期间、
|
||||
卸载后查询会报错。识别提交版本时同时比较 mount ID 与 revision,避免重新挂载后
|
||||
revision 从头计数导致混淆。
|
||||
|
||||
Ebox 只通过 TP 2.0.0 的公开 structured participant API 与最终 v2 协议注册可回滚状态。
|
||||
旧的过渡版本不具备本版本要求的原生属性所有权与 hover identity 保证。structured
|
||||
participant capability 缺失或格式错误时,Ebox 会停止加载,不会选择兼容 writer。
|
||||
|
||||
## 可选 native 模块
|
||||
|
||||
Rust 模块只加速符合条件的 reflow;它不是正确性的依赖,并有完全等价的 Elisp
|
||||
@ -72,12 +171,33 @@ fallback。Ebox 加载时不会自动构建它。
|
||||
(ebox-native-build)
|
||||
```
|
||||
|
||||
## 显示与文本边界
|
||||
|
||||
每个已挂载 buffer 只有一套视口布局。同一个 buffer 同时出现在不同宽度的两个窗口中,
|
||||
不会产生两套独立布局。需要各自宽度时,分别挂载到不同 buffer;同一份 canonical input
|
||||
可以挂载多次。
|
||||
|
||||
纵向几何按完整行生成。文本处理保留常见组合音标、变体选择符、Emoji 修饰符、ZWJ 与
|
||||
旗帜序列,但不实现完整 Unicode 字素分段,也不承诺浏览器级字体排版和双向布局。
|
||||
可用字体、原生指针和帮助显示由 Emacs 与窗口系统决定;终端输出无法复现全部 GUI
|
||||
像素或指针效果。
|
||||
|
||||
`:overflow hidden` 将内容限制在盒子测量后的宽度与完整行高度内,保留自身 padding 和
|
||||
边框。横向裁剪保留完整的受支持文本簇,用空白补齐剩余像素,不显示半个字形,也不
|
||||
添加省略号。`scroll` 仍是纵向滚动。
|
||||
|
||||
CI 配置覆盖 Linux 上的 Emacs 29.1、30.2,以及 Linux、macOS、Windows 上使用
|
||||
Emacs 30.2 的源码包安装和 native 模块加载、执行。这些任务定义验证矩阵,不代表
|
||||
所有平台的 GUI 渲染都已做视觉实测;请查看待安装提交对应的 workflow 结果。
|
||||
|
||||
## 验证
|
||||
|
||||
```sh
|
||||
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make c1b-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make native-rust-tests
|
||||
```
|
||||
@ -85,3 +205,8 @@ make native-rust-tests
|
||||
继续阅读[用户指南](docs/user/ebox-user-guide.zh.md)、[公共 API
|
||||
参考](docs/user/ebox-api-reference.zh.md),以及同级
|
||||
[ebox-playground](../ebox-playground/README.md) 示例。
|
||||
|
||||
## 许可证
|
||||
|
||||
Ebox 自有代码按 GPL-3.0-or-later 分发,详见 [LICENSE](LICENSE)。第三方文件保留各自的
|
||||
许可证声明。
|
||||
|
||||
@ -19,8 +19,11 @@ This is the maintainer entry point for the standalone Ebox repository. It descri
|
||||
| `ebox.el` | Public facade, construction helpers, rendering, TP-backed buffer entry points, scrolling, commit, and byte compilation. |
|
||||
| `ebox-cache.el` | Measurement/render cache records, invalidation, and cache reports. |
|
||||
| `ebox-source.el` | Opaque source handles, immutable author source records, and candidate source indexes. |
|
||||
| `ebox-runtime-index.el` | Compressed persistent radix maps and legacy hash-table adapters. |
|
||||
| `ebox-state-contract.el` | Closed retained-state ownership inventory and read-only compatibility-mirror rebuild probes. |
|
||||
| `ebox-style.el` | ECSS property schemas, declarations and cascade, shorthand expansion, computed style, colors, borders, and dirty effects. |
|
||||
| `ebox-interaction.el` | Native help and keymap adapters with target-buffer context, validation and owned copies of the four explicit node capabilities, restricted hover paint compilation, and native surface-property projection with nested explicit-nil precedence. |
|
||||
| `ebox-size.el` | Pure explicit-unit validation, dimensional arithmetic, dependency discovery, and fractional-pixel resolution from injected measurements. |
|
||||
| `ebox-tree.el` | Node traversal, logical child access, ECSS subject adaptation, identity, parent paths, keys, and tree snapshots. |
|
||||
| `ebox-child-range.el` | Immutable weighted segment trie, sparse persistent key trie, Range replacement, and Gate A metrics. |
|
||||
| `ebox-measure.el` | Display-sensitive character/face/pixel measurement and measurement caches. |
|
||||
@ -42,6 +45,32 @@ The package intentionally does not include application Components, UI controls,
|
||||
|
||||
The active contract also covers `Makefile`, `.github/workflows/ci.yml`, `tests/ebox-core-render-tests.el`, `tests/ebox-state-contract-tests.el`, `tests/ebox-layout-boundary-tests.el`, `tests/ebox-patch-plan-tests.el`, `tests/ebox-style-schema-tests.el`, `tests/ebox-spi-tests.el`, `tests/ebox-child-range-tests.el`, `tests/ebox-grid-tests.el`, `tests/ebox-commit-tests.el`, `tests/ebox-surface-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`, and `scripts/ebox-performance-evaluator.el`.
|
||||
|
||||
Runtime index behavior is covered by `tests/ebox-runtime-index-tests.el`.
|
||||
|
||||
`tests/ebox-display-tests.el` covers render-before-display ordering, optional
|
||||
native display actions, and preservation of unrelated windows and selection.
|
||||
`make display-tests` runs it without operating a user's GUI instance.
|
||||
|
||||
The source distribution boundary includes `LICENSE`, `release-dependencies.json`,
|
||||
`scripts/ebox-release.py`, `scripts/ebox-release-smoke.el`, and
|
||||
`scripts/test_ebox_release.py`. The first tool owns locked dependency retrieval,
|
||||
deterministic archives, and isolated installation; the Lisp smoke checks run
|
||||
inside that installation rather than loading sibling checkouts.
|
||||
|
||||
Native node capabilities are covered by `tests/ebox-interaction-tests.el` through
|
||||
`make interaction-tests`. The interactive reference and its command regressions
|
||||
live in the sibling Playground package.
|
||||
|
||||
CSS sizing is covered by `tests/ebox-size-tests.el`,
|
||||
`tests/ebox-size-style-tests.el`, `tests/ebox-css-layout-tests.el`,
|
||||
`tests/ebox-css-flex-grid-tests.el`, `tests/ebox-css-viewport-tests.el`, and
|
||||
`tests/ebox-size-native-tests.el`.
|
||||
`tests/ebox-zero-height-tests.el` covers empty automatic geometry and explicit
|
||||
blank-line sizing through the same `make size-tests` entry point.
|
||||
The supported literal migration tool is `scripts/migrate-css-sizes.py`;
|
||||
`scripts/test_migrate_css_sizes.py` verifies its source-preserving rewrites and
|
||||
ambiguous-input reports.
|
||||
|
||||
## Runtime model
|
||||
|
||||
The normal data flow is:
|
||||
@ -66,6 +95,7 @@ Caller-owned Source Tree
|
||||
| Source/Element Tree | `ebox-child-range.el`, `ebox-tree.el`, `ebox-dsl.el` | Published buffer mutation. |
|
||||
| State Ownership Contract | `ebox-state-contract.el` | Live publication or mutable authority. |
|
||||
| Computed Style | `ebox-style.el` | Layout identity or patch execution. |
|
||||
| Length Values | `ebox-size.el` | Window/node reads, property defaults, choosing display boundaries, or arbitrary Lisp evaluation. |
|
||||
| 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. |
|
||||
@ -78,7 +108,9 @@ Caller-owned Source Tree
|
||||
## 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.
|
||||
- Box geometry uses explicit `(unit number)` data with `px`, `%`, `vw`, `vh`, `ch`, or `lh`; `calc`, `min`, `max`, and `clamp` compose values. Bare geometry numbers, singleton pixel lists, viewport/contain keywords, and parameterized `fit-content` are rejected.
|
||||
- The [author unit-constraint table](../user/ebox-user-guide.en.md#size-unit-constraints) is authoritative. The schema and construction boundary validate every nested branch against its inline, block, parent-main-axis, or border-paint domain before any display work. Empty automatic content has zero height.
|
||||
- Property schemas own allowed keywords and ranges; layout supplies reference measurements. Length arithmetic retains fractions and unresolved dependencies until the appropriate layout/display boundary. Vertical buffer content remains quantized to complete lines.
|
||||
- `ebox-render` uses an ephemeral TP surface and does not publish to a buffer; `ebox-render-to-buffer` mounts a retained TP surface; `ebox-display-buffer` displays that same retained-surface path; and `ebox-commit` prepares Ebox semantics and updates the mount through TP. Ebox exposes no parallel public macro that erases a live buffer and evaluates arbitrary body forms.
|
||||
- Declarative input remains caller-owned. Live mount/commit assigns identity only on a surface-owned copy, so the same source can back multiple buffers.
|
||||
- Logical `:id` values resolve through `ebox-region-resolve` to opaque surface-scoped handles. Handles, not source-tree numeric ids, distinguish the same logical region mounted in different buffers.
|
||||
@ -86,21 +118,46 @@ Caller-owned Source Tree
|
||||
- Keys are local to siblings; visible strings are never used as identity.
|
||||
- Ebox owns logical node-to-subject adaptation and id/class/type candidate indexes; ECSS's public structured matcher is the only selector truth source. Subjects expose built-in id/key plus explicit `:selector-attributes`, never visible content, layout state, or runtime containers.
|
||||
- `ebox-style--property-definitions` is the single Ebox author-property source. Load time derives Ebox's read-only lookup index and one immutable ECSS package schema from it; the surface schema is composed once, and node construction or updates never re-register or copy the whole schema domain.
|
||||
- E1 freezes the closed M2a target classification for retained state: immutable generation facts; generation-bound scroll/native authority; opaque TP client-state custody; one-way compatibility mirrors; and disposable caches. Every inventory row separately records the current v1 storage shape plus its target owner, mutation API, generation binding, rollback, rebuild proof, and cleanup. Current v1 TP client state still holds the whole Ebox state plist until a later checkpoint changes that storage shape. The E1 mirror probes only rebuild and compare projections; they never mutate live state.
|
||||
- E1 freezes the closed M2a target classification for retained state: immutable generation facts; generation-bound scroll/native authority; opaque TP client-state custody; one-way compatibility mirrors; and disposable caches. Every inventory row separately records the M2a-era storage shape plus its target owner, mutation API, generation binding, rollback, rebuild proof, and cleanup. TP client state holds the whole Ebox state plist. The E1 mirror probes only rebuild and compare projections; they never mutate live state.
|
||||
- `ebox-layout.el` has no load or call edge to `ebox-surface.el` or TP. Candidate isolation, inherited-cascade detection, and one-shot materialization enter layout only through the validated port owned by `ebox-render-context.el`; `ebox.el` wires the surface implementation after both sides load.
|
||||
- `ebox-incremental.el` likewise contains no `ebox-surface` symbol or publication call. `ebox-surface.el` installs one immutable read-only context port for live client state, region mounts, cascade proof, and inline-style preparation; batch observation remains in the higher `ebox.el` orchestrator.
|
||||
- `ebox-patch-plan.el` consumes only tentative operation plists plus the immutable generation parent table and returns a deterministic plan artifact. The incremental adapter owns all live-fact preparation. E3 defaults to the pure route while retaining explicit `legacy` and equality-checking `shadow` routes; none of the three routes publishes.
|
||||
- `ebox-style--property-definitions` is also the single used-value projection truth. Non-default engine lowering lives in each property's `:engine-projection`; ECSS metadata exposes the derived `:engine-targets`, and only non-public engine fields may use the small internal exception table.
|
||||
- `ebox-framework-spi-capabilities` returns a fresh immutable v2 provider record declaring paired initial/update stage+rollback, combined participant ordering, same-object legacy reports, initial observation replay, and `tp-transaction-protocol-v1+v2`. Replay derives defensive TP/Ebox provider reports from a completed initial report and carries no mutation authority. Ebox provides no selected port or consumer bootstrap; the existing v1 callback APIs remain available.
|
||||
- `ebox-framework-spi-capabilities` returns a fresh immutable SPI v2 provider record declaring paired initial/update stage+rollback, combined participant ordering, same-object reports, initial observation replay, and the actual accepted TP protocol. `ebox-framework-spi-required-tp-version` exposes the minimum TP 2.0.0 dependency, while `ebox-framework-spi-supported-tp-protocols` lists accepted structured protocol identities; protocol membership alone does not satisfy the minimum package version. Initial TP/Ebox timing is measured during publication, stored as defensive snapshots in the completed report, and replayed without mutation authority; no synthetic zero-duration timing is emitted. Ebox provides no selected port or consumer bootstrap.
|
||||
- Ebox registers every rollback-capable runtime mirror through TP's public
|
||||
`tp-transaction-participate-v2` API. The loaded manifest must advertise that
|
||||
callable API with an accepted structured protocol; missing or malformed
|
||||
capability fails closed during load.
|
||||
- SPI initial publication widens under provider ownership and wraps the TP
|
||||
transaction in an editor change group. On any unsuccessful or nonlocal exit,
|
||||
it restores original overlay identities and bounds together with buffer text,
|
||||
marker identities, point, mark, narrowing, undo history, and modified state.
|
||||
- Framework report finalization is postaccept and no-throw. Initial, full-update, and scoped-update completion faults are retained in the same legacy report as `framework-report-finalization` diagnostics; they cannot invoke framework rollback or escape into an ETAF semantic rollback window.
|
||||
- `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.
|
||||
- Native sizing accepts fixed values and static size arithmetic within the public axis constraints when the resolved geometry is integral in the native representation. Percentage/viewport dependencies, fractional geometry, and vertical intrinsic sizing use the normal Elisp fallback. Accepting these values at the public boundary does not imply native acceleration.
|
||||
- A confirmed retained-native frame owns an immutable layout document together
|
||||
with its exact document revision. Candidate-session forks share that document.
|
||||
A synchronous frame may omit the document only when its base and target
|
||||
document revisions are equal and match the confirmed document, and its TP
|
||||
runtime revision matches the confirmed frame. A changed IR sends a complete
|
||||
replacement document with the next document revision; confirmation promotes
|
||||
the candidate document atomically, while failure or cancellation leaves the
|
||||
parent session unchanged.
|
||||
- A full declarative update continuing a committed native frame computes
|
||||
selector-local styles for every candidate node before stable native
|
||||
eligibility. The `:styles-prepared-p` certificate prevents replacement
|
||||
runtime nodes from losing computed styles while ordinary full/local updates
|
||||
retain O(changed) style work. Topology-changing full-frame bootstrap remains
|
||||
eligible.
|
||||
- Loading Ebox never builds or installs the optional Rust module.
|
||||
- Phase 9 is complete: first mount, declarative commit, handle/selector update, viewport/theme update, batch flush, and scroll update all publish through TP surfaces. Phase 10 has unified selector parsing, matching, and cascade under ECSS; Ebox computes dirty/layout ownership and joins its opaque runtime state to the TP rollback-capable transaction, with no second live buffer executor.
|
||||
- Successful reports preserve the Ebox semantic strategy and planned publication scope, then add `:publication-scope tp-surface`, TP physical operation counts, surface revision, scoped/full-root facts, and retained-object reconciliation counts.
|
||||
|
||||
## Grid contract
|
||||
|
||||
Grid currently covers fixed and pixel tracks, `auto`, fractional tracks, `minmax`, `repeat`, implicit rows and columns, row/column gap, auto-flow, one-based placement, positive spans, item/content alignment, and ordinary buffer rendering and updates. Ebox supports canonical font inputs (`:font-family`, `:font-size`, `:font-weight`, and `:font-style`) plus text-decoration paint; full browser typography is outside the contract. Percentages, absolute positioning, z-index, border radius, shadows, and browser-level bidi are also outside the contract.
|
||||
Grid currently covers explicit-unit and percentage tracks, `auto`, fractional tracks, `minmax`, `repeat`, implicit rows and columns, row/column gap, auto-flow, one-based placement, positive spans, item/content alignment, and ordinary buffer rendering and updates. Ebox supports canonical font inputs (`:font-family`, `:font-size`, `:font-weight`, and `:font-style`) plus text-decoration paint; full browser typography is outside the contract. Absolute positioning, z-index, border radius, shadows, and browser-level bidi are also outside the contract.
|
||||
|
||||
## Active examples and tests
|
||||
|
||||
@ -112,7 +169,17 @@ The standalone package has no bundled playground fixtures. The migrated `.ebox`
|
||||
make load
|
||||
make compile
|
||||
make check
|
||||
make size-tests
|
||||
make size-migration-tests
|
||||
make interaction-tests
|
||||
make display-tests
|
||||
make release-tool-tests
|
||||
make release-deps
|
||||
make release-build
|
||||
make release-check
|
||||
make native-integration
|
||||
make core-tests
|
||||
make runtime-index-tests
|
||||
make child-range-tests
|
||||
make grid-tests
|
||||
make ebox-commit-tests
|
||||
@ -131,6 +198,7 @@ make style-schema-tests
|
||||
make style-schema-performance
|
||||
make spi-tests
|
||||
make spi-performance
|
||||
make c1b-contract-tests
|
||||
make docs-contract-tests
|
||||
make ci-contract-tests
|
||||
make performance-evaluator
|
||||
@ -142,3 +210,94 @@ 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.
|
||||
|
||||
`make size-tests` includes `make size-migration-tests`, so the migration tool's
|
||||
Python regression suite also runs through `make check`.
|
||||
|
||||
## Source package distribution
|
||||
|
||||
Use `scripts/ebox-release.py` through the Make targets above rather than writing
|
||||
another installation probe. `release-dependencies.json` pins public HTTPS URLs
|
||||
and full commit IDs for mandatory TP/ECSS and optional EKP. It is a reproducible
|
||||
build input, not a replacement for each package's minimum version contract.
|
||||
EKP is included only with `--with-ekp`; ordinary archives do not make it an
|
||||
Ebox package dependency.
|
||||
|
||||
Recommended complete verification:
|
||||
|
||||
```sh
|
||||
make release-check EMACS=emacs RELEASE_DIR=/tmp/ebox-release-3.0.0
|
||||
make release-check EMACS=emacs RELEASE_DIR=/tmp/ebox-release-3.0.0-kp RELEASE_OPTIONS=--with-ekp
|
||||
```
|
||||
|
||||
Both commands fetch locked dependencies and build from the current Ebox source
|
||||
files, excluding compiled output and temporary files. The output contains
|
||||
deterministic tar archives, `archive-contents`, and a manifest with SHA-256
|
||||
checksums, dependency commits, and Ebox source revision/dirty status. Modified
|
||||
source is allowed for development verification and is recorded as such; a
|
||||
clean release checkout should be used for a formal release artifact. Neither
|
||||
command uploads files or creates a tag.
|
||||
|
||||
`release-check` builds a new directory and then verifies it. To verify an
|
||||
existing archive without rebuilding or fetching dependencies:
|
||||
|
||||
```sh
|
||||
python3 scripts/ebox-release.py verify --directory /tmp/ebox-release-3.0.0 --emacs emacs
|
||||
```
|
||||
|
||||
`verify` checks archive checksums, uses `emacs -Q --batch`, and lets `package.el`
|
||||
resolve dependencies from that local archive into a disposable package profile.
|
||||
It checks autoloads, required package provenance, standalone loading,
|
||||
render/update/selector behavior, and the real EKP provider when included.
|
||||
No sibling `load-path` or user's installed packages supply missing dependencies.
|
||||
The ordinary archive check does not build or require the Rust module.
|
||||
|
||||
To reuse already fetched dependencies, run `make release-deps` with a fresh
|
||||
`RELEASE_DEPS_DIR`, then pass `RELEASE_OPTIONS="--dependencies /path/to/deps"`
|
||||
to `release-check` or `release-build`. Those checkouts must be clean at the
|
||||
locked commits. Include `--with-ekp` consistently when fetching and building
|
||||
the KP variant. `release-build` stops after producing archives.
|
||||
|
||||
`make native-integration RELEASE_DIR=/path/to/archive EMACS=emacs
|
||||
NATIVE_MODULE=/absolute/path/to/module` verifies an existing archive and requires
|
||||
that exact module to load and produce a native frame. It does not build the
|
||||
module. Supply the artifact for the host OS and architecture.
|
||||
|
||||
Existing output directories are rejected rather than overwritten. Temporary
|
||||
checkouts and package profiles are removed on success, failure, interruption,
|
||||
or subprocess timeout; completed archive/dependency outputs remain at the
|
||||
explicit paths. Nonzero exit means verification is unsuccessful. Use
|
||||
`make release-tool-tests` for tool regressions. This entry point supersedes
|
||||
ad hoc clean-install probes.
|
||||
|
||||
CI runs the Elisp suite and clean installation on Linux with Emacs 29.1 and
|
||||
30.2, including optional EKP installation. The native jobs build and test on
|
||||
Linux, macOS, and Windows, then load and execute the built module through a
|
||||
clean package installation on Emacs 30.2. Rust MSRV build/test jobs remain
|
||||
separate. Configuration is not evidence of a successful remote run: inspect
|
||||
the results for the specific revision, and do not infer GUI visual parity
|
||||
from batch or Rust tests.
|
||||
|
||||
## Migrate literal author sizes
|
||||
|
||||
For old Ebox author literals, use the supported source-aware migration tool
|
||||
instead of another search-and-replace script. Its positional inputs are explicit
|
||||
files or directories; no inputs are inferred from the machine's workspace.
|
||||
Start with a dry run:
|
||||
|
||||
```sh
|
||||
python3 scripts/migrate-css-sizes.py ../ebox-playground/examples
|
||||
```
|
||||
|
||||
Review the proposed changes and ambiguity reports, then add `--write` for the
|
||||
same explicit paths. The tool preserves strings, comments, and formatting and
|
||||
does not evaluate Elisp. Dynamic values and removed/ambiguous constructs need
|
||||
an intentional source edit. Target author data only: internal measured structs
|
||||
can use the same property names with already-resolved numeric engine values.
|
||||
This entry point supersedes temporary literal-rewrite probes.
|
||||
|
||||
Validate the migration tool with:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s scripts -p 'test_migrate_css_sizes.py'
|
||||
```
|
||||
|
||||
@ -19,8 +19,11 @@
|
||||
| `ebox.el` | 公共门面、构造辅助函数、渲染、基于 TP 的 buffer 入口、滚动、commit 与 byte compile。 |
|
||||
| `ebox-cache.el` | 测量/渲染缓存记录、失效和缓存报告。 |
|
||||
| `ebox-source.el` | opaque source handle、不可变 author source record 与 candidate source index。 |
|
||||
| `ebox-runtime-index.el` | 压缩持久 radix map 与旧 hash-table adapter。 |
|
||||
| `ebox-state-contract.el` | 封闭的 retained-state 所有权清单与只读 compatibility-mirror 重建探针。 |
|
||||
| `ebox-style.el` | ECSS property schema、declaration 与 cascade、shorthand 展开、computed style、颜色、border 和 dirty effect。 |
|
||||
| `ebox-interaction.el` | 提供目标 buffer 上下文的原生帮助与 keymap 适配、四种显式节点能力的验证与独立复制、受限悬停绘制编译,以及保留嵌套显式 nil 优先级的原生 surface-property 投影。 |
|
||||
| `ebox-size.el` | 显式单位的纯验证、量纲运算、依赖识别,以及基于注入测量值的浮点像素换算。 |
|
||||
| `ebox-tree.el` | 节点遍历、逻辑子节点访问、ECSS subject 适配、identity、父路径、key 和树 snapshot。 |
|
||||
| `ebox-child-range.el` | 不可变 weighted segment trie、稀疏持久 key trie、Range replacement 与 Gate A metrics。 |
|
||||
| `ebox-measure.el` | display 敏感的字符、face、像素测量与测量缓存。 |
|
||||
@ -42,6 +45,27 @@
|
||||
|
||||
active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-core-render-tests.el`、`tests/ebox-state-contract-tests.el`、`tests/ebox-layout-boundary-tests.el`、`tests/ebox-patch-plan-tests.el`、`tests/ebox-style-schema-tests.el`、`tests/ebox-spi-tests.el`、`tests/ebox-child-range-tests.el`、`tests/ebox-grid-tests.el`、`tests/ebox-commit-tests.el`、`tests/ebox-surface-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` 和 `scripts/ebox-performance-evaluator.el`。
|
||||
|
||||
Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。
|
||||
|
||||
`tests/ebox-display-tests.el` 覆盖先渲染后显示、可选原生 display action,以及无关窗口
|
||||
和选中状态的保持。`make display-tests` 不操作用户的 GUI 实例。
|
||||
|
||||
源码分发边界包括 `LICENSE`、`release-dependencies.json`、`scripts/ebox-release.py`、
|
||||
`scripts/ebox-release-smoke.el` 和 `scripts/test_ebox_release.py`。第一个工具负责固定
|
||||
依赖获取、可重复归档及隔离安装;Lisp smoke 在安装结果内部运行,不加载同级 checkout。
|
||||
|
||||
原生节点能力由 `tests/ebox-interaction-tests.el` 通过 `make interaction-tests` 覆盖。
|
||||
可交互参考示例及其命令回归测试位于同级 Playground 包。
|
||||
|
||||
CSS 尺寸由 `tests/ebox-size-tests.el`、`tests/ebox-size-style-tests.el`、
|
||||
`tests/ebox-css-layout-tests.el`、`tests/ebox-css-flex-grid-tests.el`、
|
||||
`tests/ebox-css-viewport-tests.el` 和 `tests/ebox-size-native-tests.el` 覆盖。
|
||||
`tests/ebox-zero-height-tests.el` 通过同一个 `make size-tests` 入口覆盖空的自动几何
|
||||
和显式空白行尺寸。
|
||||
支持的字面量迁移入口是
|
||||
`scripts/migrate-css-sizes.py`;`scripts/test_migrate_css_sizes.py` 验证它保留
|
||||
源码格式的重写及歧义报告。
|
||||
|
||||
## 运行时模型
|
||||
|
||||
正常数据流是:
|
||||
@ -66,6 +90,7 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
|
||||
| Source/Element Tree | `ebox-child-range.el`、`ebox-tree.el`、`ebox-dsl.el` | 已发布 buffer 的变更。 |
|
||||
| 状态所有权合同 | `ebox-state-contract.el` | Live 发布或可变 authority。 |
|
||||
| Computed Style | `ebox-style.el` | 布局 identity 或 patch 执行。 |
|
||||
| 长度值 | `ebox-size.el` | 窗口/节点读取、属性默认值、决定显示量化边界或任意 Lisp 求值。 |
|
||||
| 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 分配。 |
|
||||
@ -78,7 +103,9 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
|
||||
## 不变量
|
||||
|
||||
- 公共 Ebox 节点是数据;`ebox--*` 名称是私有实现。
|
||||
- `'(420)` 这样的单元素横向 list 表示像素;普通横向数字表示字符列。
|
||||
- Box 几何统一使用 `(单位 数值)`,单位为 `px`、`%`、`vw`、`vh`、`ch`、`lh`,通过 `calc`、`min`、`max`、`clamp` 组合。不接受几何裸数字、单元素像素列表、viewport/contain 关键词和带参数的 `fit-content`。
|
||||
- [作者单位约束表](../user/ebox-user-guide.zh.md#size-unit-constraints) 是统一契约。schema 与构造边界在显示之前,按 inline、block、父布局主轴或边框绘制用途,递归验证所有嵌套分支。空的自动内容高度为零。
|
||||
- property schema 拥有关键词和值域规则,布局注入参照测量值。长度运算保留小数和未解析依赖,到对应布局/显示边界再处理;纵向 buffer 内容仍按完整行量化。
|
||||
- `ebox-render` 使用临时 TP surface,不发布到 buffer;`ebox-render-to-buffer` 挂载 retained TP surface;`ebox-display-buffer` 展示的也是这条 retained-surface 路径;`ebox-commit` 准备 Ebox 语义后通过 TP 更新该 mount。Ebox 不再公开另一套擦除 live buffer 后执行任意 BODY 的宏。
|
||||
- 声明式输入始终由调用者拥有。live mount/commit 只在 surface-owned copy 上分配 identity,因此同一 source 可以挂载到多个 buffer。
|
||||
- 逻辑 `:id` 通过 `ebox-region-resolve` 解析为不透明、surface-scoped 的 handle;同一逻辑 region 挂载到多个 buffer 时,由 handle 而不是 source-tree 数字 id 区分。
|
||||
@ -86,21 +113,41 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
|
||||
- Key 只在兄弟节点中有效;不能用可见字符串作为 identity。
|
||||
- Ebox 只拥有逻辑 node-to-subject 适配与 id/class/type 候选索引;ECSS 的公共结构化 matcher 是唯一 selector 真相源。subject 只暴露内建 id/key 和显式 `:selector-attributes`,绝不暴露可见 content、布局状态或 runtime 容器。
|
||||
- `ebox-style--property-definitions` 是 Ebox 作者属性的唯一来源:加载时一次生成 Ebox 自己的只读查询索引与一个不可变 ECSS package schema;surface schema 只组合一次,node 构造和更新不重复注册或复制整份 schema。
|
||||
- E1 固化封闭的 M2a 目标分类:不可变 generation fact、绑定 generation 的 scroll/native authority、opaque TP client-state custody、单向 compatibility mirror,以及 disposable cache。每一行分别记录当前 v1 存储形态,以及目标 owner、mutation API、generation binding、rollback、rebuild proof 与 cleanup。在后续 checkpoint 改变存储形态前,当前 v1 TP client state 仍保存完整 Ebox state plist。E1 mirror 探针只重建并比较投影,不修改 live state。
|
||||
- E1 固化封闭的 M2a 目标分类:不可变 generation fact、绑定 generation 的 scroll/native authority、opaque TP client-state custody、单向 compatibility mirror,以及 disposable cache。每一行分别记录 M2a 阶段的存储形态,以及目标 owner、mutation API、generation binding、rollback、rebuild proof 与 cleanup。TP client state 保存完整 Ebox state plist。E1 mirror 探针只重建并比较投影,不修改 live state。
|
||||
- `ebox-layout.el` 不再 load 或调用 `ebox-surface.el`/TP。candidate isolation、继承 cascade 判定和一次性 materialization 只能通过 `ebox-render-context.el` 拥有的已校验 port 进入 layout;`ebox.el` 在两侧加载后接线 surface 实现。
|
||||
- `ebox-incremental.el` 同样不再包含任何 `ebox-surface` symbol 或 publication call。`ebox-surface.el` 只安装一个不可变只读 context port,提供 live client state、region mount、cascade proof 与 inline-style preparation;batch observation 留在更高层的 `ebox.el` orchestrator。
|
||||
- `ebox-patch-plan.el` 只消费 tentative operation plist 与不可变 generation parent table,并返回 deterministic plan artifact;所有 live fact 准备仍由 incremental adapter 拥有。E3 默认使用 pure route,同时保留显式 `legacy` 与执行等价检查的 `shadow` route;三条 route 都不能发布。
|
||||
- `ebox-style--property-definitions` 同时也是 used-value projection 的唯一真相源。非默认 engine lowering 写在每个 property 的 `:engine-projection` 中;ECSS metadata 暴露派生的 `:engine-targets`,只有非公共 engine 字段可以进入小型 internal exception table。
|
||||
- `ebox-framework-spi-capabilities` 返回 fresh immutable v2 provider record,声明 paired initial/update stage+rollback、combined participant order、same-object legacy report、initial observation replay 以及 `tp-transaction-protocol-v1+v2`。replay 只从 completed initial report 构造 defensive TP/Ebox provider reports,不带 mutate authority。Ebox 不创建 selected port 或 consumer bootstrap;现有 v1 callback API 保留。
|
||||
- `ebox-framework-spi-capabilities` 返回 fresh immutable SPI v2 provider record,声明 paired initial/update stage+rollback、combined participant order、same-object report、initial observation replay,以及实际接受的 TP protocol。`ebox-framework-spi-required-tp-version` 公开最低 TP 2.0.0 依赖,`ebox-framework-spi-supported-tp-protocols` 列出接受的 structured protocol identity;只满足协议 membership 不等于满足最低包版本。initial TP/Ebox timing 在 publication 期间真实测量,以 defensive snapshot 写入 completed report 后再 replay;不生成虚假的零耗时数据,也不携带 mutate authority。Ebox 不创建 selected port 或 consumer bootstrap。
|
||||
- Ebox 始终通过 TP 的公开 `tp-transaction-participate-v2` API 注册可 rollback 的
|
||||
runtime mirror。加载的 manifest 必须用 accepted structured protocol 声明该可调用
|
||||
API;capability 缺失或格式错误会在加载时 fail closed。
|
||||
- SPI initial publication 由 provider 负责 widen,并在 TP transaction 外包一层
|
||||
editor change group。任何失败或非局部退出都会恢复原 overlay identity/bounds,
|
||||
以及 buffer text、marker identity、point、mark、narrowing、undo history 与
|
||||
modified state。
|
||||
- Framework report finalization 属于 postaccept 且保证不向外抛出。initial、full update 与 scoped update 的 completion fault 都以 `framework-report-finalization` diagnostics 留在同一个 legacy report 中,不能触发 framework rollback,也不能逃逸进 ETAF semantic rollback window。
|
||||
- `owner-rerender` 范围大于 `span-patch`,`span-patch` 大于 `paint-patch`。
|
||||
- Buffer 坐标属于生成它的 generation,变更后必须重新获取。
|
||||
- Grid 使用普通测量与渲染流水线;native reflow 可以拒绝不适合的树并回退到 Elisp,正确性不变。
|
||||
- Native 尺寸路径支持公共轴向约束内的固定值和静态尺寸运算,前提是解析后的几何在 native 表示中是整数。百分比/视口依赖、小数几何和纵向固有尺寸使用普通 Elisp fallback;公共入口接受这些值,不代表 native 会加速它们。
|
||||
- 已确认的 retained-native frame 持有不可变布局文档及其精确文档 revision;候选
|
||||
session 的 fork 共享该文档。同步 frame 只有在文档 base/target revision 相等且
|
||||
命中 confirmed 文档、同时 TP runtime revision 命中 confirmed frame 时,才可
|
||||
省略文档。IR 变化必须携带下一 document revision 的完整 replacement document;
|
||||
confirm 原子晋升候选文档,失败或取消不修改 parent session。
|
||||
- 只有继续已提交 native frame 的 full declarative update,才会在 stable native
|
||||
eligibility 前为全部 candidate node 计算 selector-local style;生成的
|
||||
`:styles-prepared-p` certificate 防止 replacement runtime 丢失 computed style,
|
||||
普通 full/local update 仍保持 O(changed)。topology-changing full-frame bootstrap
|
||||
继续可用。
|
||||
- 加载 Ebox 不会构建或安装可选 Rust 模块。
|
||||
- Phase 9 已完成:首次 mount、声明式 commit、handle/selector 更新、viewport/theme 更新、batch flush 与 scroll 更新全部通过 TP surface 发布。Phase 10 已把 selector 解析、匹配与 cascade 统一到 ECSS;Ebox 只计算 dirty/layout owner,并让不透明 runtime state 参与 TP 的可 rollback transaction,不存在第二个 live buffer executor。
|
||||
- 成功报告会保留 Ebox 语义 strategy 与 planned publication scope,再加入 `:publication-scope tp-surface`、TP 物理 operation 数、surface revision、scoped/full-root 事实和 retained-object reconciliation 统计。
|
||||
|
||||
## Grid 合同
|
||||
|
||||
当前 Grid 支持固定和像素轨道、`auto`、分数轨道、`minmax`、`repeat`、隐式行列、行列 gap、auto-flow、从 1 开始的 placement、正整数 span、item/content 对齐,以及普通 buffer 渲染和更新。Ebox 支持规范字体输入(`:font-family`、`:font-size`、`:font-weight`、`:font-style`)与 text-decoration paint;完整浏览器 typography 不在合同内。百分比、绝对定位、z-index、圆角、阴影和浏览器级 bidi 也不在合同内。
|
||||
当前 Grid 支持显式单位和百分比轨道、`auto`、分数轨道、`minmax`、`repeat`、隐式行列、行列 gap、auto-flow、从 1 开始的 placement、正整数 span、item/content 对齐,以及普通 buffer 渲染和更新。Ebox 支持规范字体输入(`:font-family`、`:font-size`、`:font-weight`、`:font-style`)与 text-decoration paint;完整浏览器 typography 不在合同内。绝对定位、z-index、圆角、阴影和浏览器级 bidi 也不在合同内。
|
||||
|
||||
## Active 示例与测试
|
||||
|
||||
@ -110,7 +157,17 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
|
||||
|
||||
```sh
|
||||
make check
|
||||
make size-tests
|
||||
make size-migration-tests
|
||||
make interaction-tests
|
||||
make display-tests
|
||||
make release-tool-tests
|
||||
make release-deps
|
||||
make release-build
|
||||
make release-check
|
||||
make native-integration
|
||||
make core-tests
|
||||
make runtime-index-tests
|
||||
make child-range-tests
|
||||
make grid-tests
|
||||
make ebox-commit-tests
|
||||
@ -129,6 +186,7 @@ make style-schema-tests
|
||||
make style-schema-performance
|
||||
make spi-tests
|
||||
make spi-performance
|
||||
make c1b-contract-tests
|
||||
make docs-contract-tests
|
||||
make ci-contract-tests
|
||||
make performance-evaluator
|
||||
@ -140,3 +198,74 @@ make diff-check
|
||||
```
|
||||
|
||||
先运行聚焦测试;修改共享渲染、Grid、公共构造器或文档后运行 `make check`。修改 native 后必须运行 Rust 检查与 native 构建。
|
||||
|
||||
`make size-tests` 包含 `make size-migration-tests`,因此迁移工具的 Python 回归
|
||||
测试也会通过 `make check` 执行。
|
||||
|
||||
## 源码包分发
|
||||
|
||||
通过上述 Make 入口使用 `scripts/ebox-release.py`,不另写安装探针。
|
||||
`release-dependencies.json` 为必需的 TP、ECSS 与可选 EKP 固定公开 HTTPS URL 和完整
|
||||
提交 ID。它是可重复构建的输入,不替代各包声明的最低版本契约。只有指定 `--with-ekp`
|
||||
才包含 EKP;普通归档不会把它变成 Ebox 的必需包依赖。
|
||||
|
||||
推荐完整验证:
|
||||
|
||||
```sh
|
||||
make release-check EMACS=emacs RELEASE_DIR=/tmp/ebox-release-3.0.0
|
||||
make release-check EMACS=emacs RELEASE_DIR=/tmp/ebox-release-3.0.0-kp RELEASE_OPTIONS=--with-ekp
|
||||
```
|
||||
|
||||
两条命令都获取固定依赖,从当前 Ebox 源码文件构建,排除编译产物和临时文件。输出包括
|
||||
可重复的 tar 归档、`archive-contents`,以及记录 SHA-256 校验和、依赖提交、Ebox
|
||||
源码提交和工作区修改状态的 manifest。开发验证允许修改过的源码,并明确记录;正式
|
||||
发布产物应使用干净的 release checkout。两条命令都不会上传文件或创建 tag。
|
||||
|
||||
`release-check` 先构建新目录再验证;检查已有归档而不重建或获取依赖时,使用:
|
||||
|
||||
```sh
|
||||
python3 scripts/ebox-release.py verify --directory /tmp/ebox-release-3.0.0 --emacs emacs
|
||||
```
|
||||
|
||||
`verify` 检查归档校验和,用 `emacs -Q --batch` 启动,通过 `package.el` 从本地归档解析
|
||||
依赖并安装到临时包目录,检查 autoload、必需包来源、独立加载、渲染/更新/选择器行为,
|
||||
以及归档包含 EKP 时的真实 provider。缺少依赖不能从同级 `load-path` 或用户已安装包
|
||||
中补齐。普通归档验证不构建或要求 Rust 模块。
|
||||
|
||||
复用已获取依赖时,先给 `make release-deps` 指定新的 `RELEASE_DEPS_DIR`,再给
|
||||
`release-check` 或 `release-build` 传入 `RELEASE_OPTIONS="--dependencies /path/to/deps"`。
|
||||
依赖 checkout 必须位于固定提交且没有修改。KP 变体在获取和构建时均加上 `--with-ekp`。
|
||||
`release-build` 只生成归档。
|
||||
|
||||
`make native-integration RELEASE_DIR=/path/to/archive EMACS=emacs
|
||||
NATIVE_MODULE=/absolute/path/to/module` 验证已有归档,并要求指定模块实际加载、生成
|
||||
native frame。它不构建模块;请提供当前系统与架构对应的产物。
|
||||
|
||||
已有输出目录会被拒绝,不会覆盖。临时 checkout 与包目录在成功、失败、中断或子进程
|
||||
超时后清理,已完成的归档/依赖输出保留在明确指定的路径。非零退出表示验证未成功。
|
||||
工具回归使用 `make release-tool-tests`。这个入口替代临时的干净安装探针。
|
||||
|
||||
CI 在 Linux 上以 Emacs 29.1 和 30.2 运行 Elisp 套件与干净安装,包括可选 EKP 安装。
|
||||
native 任务在 Linux、macOS、Windows 构建和测试,再通过 Emacs 30.2 的干净包安装
|
||||
加载并执行刚构建的模块;Rust MSRV 构建/测试单独运行。配置不代表远程任务已经成功:
|
||||
需要查看具体提交的结果,不能从 batch 或 Rust 测试推断 GUI 视觉效果一致。
|
||||
|
||||
## 迁移作者尺寸字面量
|
||||
|
||||
旧 Ebox 作者字面量使用已有的源码感知迁移工具,不另写一套搜索替换脚本。
|
||||
位置参数明确指定文件或目录,不从当前机器的工作区推测目标。推荐先预览:
|
||||
|
||||
```sh
|
||||
python3 scripts/migrate-css-sizes.py ../ebox-playground/examples
|
||||
```
|
||||
|
||||
检查拟修改内容和歧义报告后,对同一批明确路径增加 `--write` 应用。工具保留字符串、
|
||||
注释和排版,不执行 Elisp;动态值、已删除或有歧义的结构需要有意修改源码。
|
||||
只对作者数据使用工具:内部已测量结构可能用相同属性名存储已经解析的数字引擎值。
|
||||
该入口替代临时的字面量重写探针。
|
||||
|
||||
迁移工具验证命令:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s scripts -p 'test_migrate_css_sizes.py'
|
||||
```
|
||||
|
||||
@ -35,6 +35,8 @@ The candidate is either fully published or discarded. A failed render, TP write,
|
||||
|
||||
The planner prefers `paint-patch`, then `span-patch`, then `owner-rerender`, and finally `root-rerender` when geometry or identity makes a smaller operation unsafe. The incremental adapter derives tentative owners from live generation facts; the pure patch port then receives only those artifacts and the immutable parent table. These names describe Ebox semantic owner scope; TP alone computes and executes the physical text/property diff. A patch must not silently widen its semantic scope. Reports preserve the Ebox strategy and planned scope while separately recording TP's actual surface operations and revision.
|
||||
|
||||
If span, owner-scoped, formatting-context, or mixed local output declines, the producer rebuilds ordinary candidate state before rendering the complete root. Temporary coordinates, fragments, and paint layers from the declined attempt cannot authorize publication. The report retains the planned strategy and scope; `:projection-kind` is `nil` for this ordinary fallback. A planned mixed strategy therefore does not prove that computation stayed local.
|
||||
|
||||
## Strict retained viewport reflow
|
||||
|
||||
`viewport-reflow` is an explicit retained projection kind for a narrow, proof-driven viewport resize path. The planner may select it only when all of the following hold: the plan is one root-owned geometry `owner-rerender`; the old and candidate roots are the same retained root; no dirty entry changes children; the node-key set, region-id set, and parent table are unchanged; neither the old surface nor the current style environment requires cascade or inline-inheritance recomputation; there is no scroll state; the root has no visible-overflow scope; and the old surface has a non-empty retained node-object table. Width-only, height-only, and both-axis changes use the same proof.
|
||||
|
||||
@ -35,6 +35,8 @@
|
||||
|
||||
规划器优先使用 `paint-patch`,再使用 `span-patch`、`owner-rerender`,最后在几何或 identity 使小操作不安全时使用 `root-rerender`。incremental adapter 从 live generation fact 推导 tentative owner;pure patch port 随后只接收这些 artifact 与不可变 parent table。这些名称描述的是 Ebox 的语义 owner 范围;只有 TP 负责计算并执行物理文本/属性 diff。Patch 不得静默扩大语义范围。报告保留 Ebox strategy 与 planned scope,同时单独记录 TP 的实际 surface operation 和 revision。
|
||||
|
||||
span、owner-scoped、formatting-context 或 mixed 局部输出被拒绝时,producer 先重建普通 candidate state,再渲染完整 root。被拒绝的尝试留下的临时坐标、fragment 和 paint layer 不能授权发布。报告保留 planned strategy 与 scope;普通 fallback 的 `:projection-kind` 为 `nil`。因此,planned mixed strategy 不能证明计算始终局限在局部。
|
||||
|
||||
## 严格 retained viewport reflow
|
||||
|
||||
`viewport-reflow` 是一个明确的 retained projection kind,只用于范围很窄、由 proof 驱动的 viewport resize 路径。只有以下条件全部满足时,规划器才能选择它:plan 是一个 root-owned geometry `owner-rerender`;旧 root 与 candidate root 是同一个 retained root;没有 dirty entry 修改 children;node-key set、region-id set 和 parent table 都不变;旧 surface 和当前 style environment 都不需要 cascade 或 inline-inheritance 重新计算;没有 scroll state;root 没有 visible-overflow scope;旧 surface 存在非空 retained node-object table。仅改变 width、仅改变 height 以及同时改变两个轴使用同一套 proof。
|
||||
|
||||
@ -6,7 +6,11 @@ This reference describes the current public boundary of the standalone Ebox
|
||||
package. It separates the ordinary author path from the evaluated typed API
|
||||
used by framework integrations. Names beginning with `ebox--` are private.
|
||||
|
||||
Ebox requires Emacs 29.1 or newer, ECSS, and TP:
|
||||
Ebox requires Emacs 29.1 or newer, ECSS 0.1.0 or newer, and TP 2.0.0 or newer.
|
||||
EKP 1.0.0 or newer is an optional dependency required for `:wrap-mode kp`;
|
||||
`word`, `char`, and `none` do not load it. Missing or incompatible EKP signals
|
||||
an actionable error instead of silently changing the wrapping algorithm.
|
||||
See [installation](../../README.md#install) for source loading and dependency roles.
|
||||
|
||||
```elisp
|
||||
(require 'ebox)
|
||||
@ -38,9 +42,9 @@ functions; it does not extract a node or construct a source index.
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(column :padding (1 2)
|
||||
'(column :padding ((lh 1) (ch 2))
|
||||
(box :id "status" :color "#166534" "Ready")
|
||||
(row :item-gap 1
|
||||
(row :item-gap (ch 1)
|
||||
(box "Left")
|
||||
(box "Right"))))
|
||||
```
|
||||
@ -70,13 +74,176 @@ another public node or wrapper.
|
||||
| Paint | `:color`, `:background-color`, exact alias `:bgcolor`, border colors, `:visibility` |
|
||||
| Text layout | `:text-align`, `:wrap-mode` (`word`, `char`, `kp`, `none`) |
|
||||
| Overflow | `:overflow` (`visible`, `hidden`, `scroll`) |
|
||||
| Layer placement | `:position`, `:left`, `:top`, `:z-index`, `:layer`, `:anchor`, `:placement` |
|
||||
|
||||
Ordinary horizontal numbers are character columns; a one-element horizontal
|
||||
list is pixels. Vertical numbers are lines. Border widths are the exception:
|
||||
they are always nonnegative integer pixels, so use `(1 solid "#687386")`, not
|
||||
`((1) solid "#687386")`. `(viewport)` and
|
||||
`(viewport-height)` use the current render context. Property validation belongs
|
||||
to Ebox and invalid context/value combinations signal an error.
|
||||
`hidden` enforces the content width after composing children, preserving whole
|
||||
supported text clusters and excluding the container's own padding and border
|
||||
from the clipping area. Clipped-away child properties are removed with their
|
||||
text. Partial display spaces can be shortened; oversized glyphs/images are
|
||||
omitted, not pixel-masked. Vertical clipping uses whole lines. `visible` allows
|
||||
overflow, and `scroll` provides vertical scrolling. No ellipsis or horizontal
|
||||
scrolling is implied.
|
||||
|
||||
Geometry values use explicit `(unit number)` lists: `(px 240)`, `(% 50)`,
|
||||
`(vw 100)`, `(vh 100)`, `(ch 80)`, and `(lh 3)`. Each property accepts only
|
||||
the units in the [shared unit-constraint table](ebox-user-guide.en.md#size-unit-constraints);
|
||||
lengths cannot cross from one geometry axis to the other. CSS strings, bare lengths,
|
||||
singleton pixel lists, and the old viewport/contain keywords are rejected.
|
||||
Zero lengths also require a permitted unit. Border widths use their independent
|
||||
paint-thickness rule and reject
|
||||
percentages: `:border ((px 1) solid "#687386")`. Dimensionless values such as
|
||||
Flex factors and Grid indices remain numbers.
|
||||
|
||||
| Properties | Accepted keywords | Default |
|
||||
| --- | --- | --- |
|
||||
| `width`, `height`, `min-width`, `min-height` | `auto`, `min-content`, `max-content`, `fit-content`, `stretch` | `auto` |
|
||||
| `max-width`, `max-height` | `none`, `min-content`, `max-content`, `fit-content`, `stretch` | `none` |
|
||||
|
||||
The composable size functions are `calc`, `min`, `max`, and `clamp`, for example
|
||||
`(calc (- (vh 100) (lh 1)))` and `(clamp (ch 20) (% 50) (ch 80))`.
|
||||
`fit-content` is only a bare keyword; it never accepts parameters.
|
||||
Property validation belongs to Ebox; all function branches, expanded shorthands,
|
||||
Grid tracks, and Flex bases are checked against their property or parent axis
|
||||
when the tree is built. Invalid context/value combinations signal errors
|
||||
immediately. See the [geometry section of the user
|
||||
guide](ebox-user-guide.en.md#4-use-geometry-and-paint-properties) for percentage
|
||||
references, font units, arithmetic rules, and whole-line vertical display
|
||||
quantization. This is a CSS semantic subset, not a browser layout engine.
|
||||
|
||||
### Retained layer placement
|
||||
|
||||
These non-inherited geometry properties apply to `box`, `row`, `column`,
|
||||
`flex`, and `grid`; Text does not accept them. Existing author tags are unchanged.
|
||||
|
||||
| Property | Accepted value | Default |
|
||||
| --- | --- | --- |
|
||||
| `:position` | `static`, `relative`, `absolute` | `static` |
|
||||
| `:left` | Signed inline length (`px`, `ch`, `vw`, `%`) or size expression | `(px 0)` |
|
||||
| `:top` | Signed block length (`lh`, `vh`, `%`) or size expression | `(lh 0)` |
|
||||
| `:z-index` | Integer, including negative values | `0` |
|
||||
| `:layer` | `local`, `root` | `local` |
|
||||
| `:anchor` | Semantic `:id` as a symbol or string, or `nil` | `nil` |
|
||||
| `:placement` | `bottom-start`, `bottom-end`, `top-start`, `top-end` | `bottom-start` |
|
||||
|
||||
`static` uses ordinary flow. `relative` keeps its flow slot and shifts its paint
|
||||
by `:left` and `:top`. `absolute` contributes no normal-flow size or slot and
|
||||
uses the parent content origin, or the root content canvas for `:layer root`.
|
||||
Offsets use that paint host's font and containing dimensions; `%` uses width
|
||||
for `:left` and height for `:top`. Fractions survive length resolution; the
|
||||
final vertical offset is floored to the host's whole text rows.
|
||||
|
||||
Each local host composes its children as one group. Within a group, smaller
|
||||
`:z-index` values paint first; at equal depth, normal content paints before
|
||||
positioned content, and positioned siblings follow logical document order.
|
||||
A nested child's depth does not escape its local group. A painted Box is
|
||||
opaque across its content, padding, and border, including blank space; its
|
||||
margin is excluded. Paint is clipped to the host canvas.
|
||||
|
||||
`:layer root` changes the paint target while preserving the logical parent,
|
||||
selectors, inheritance, and retained identity. Both `:layer root` and a non-nil
|
||||
`:anchor` require `:position absolute`. Anchors resolve a unique semantic ID in
|
||||
the paint host's logical subtree and use its visible bounds. The panel starts
|
||||
above/below and at the start/end edge as requested, flips vertically when the
|
||||
opposite side fits, then shifts inside the canvas. Missing or invisible anchors
|
||||
produce no panel paint; ambiguous IDs and cyclic placement dependencies signal
|
||||
an error before publication.
|
||||
|
||||
This is an opaque text compositor: horizontal positions are pixels, vertical
|
||||
positions share the host's row grid. Positioned descendants must share their
|
||||
host's effective font line height. Whole supported text clusters and display
|
||||
units survive clipping; partial glyphs/images become neutral space. There is
|
||||
no alpha blending, partial-glyph masking, or child-frame rendering. Layered
|
||||
trees use Elisp when the native representation cannot express their placement.
|
||||
See the [layer guide](ebox-user-guide.en.md#retained-layers) for examples and
|
||||
retained update behavior.
|
||||
|
||||
### Native node capabilities
|
||||
|
||||
`text`, `box`, `row`, `column`, `flex`, and `grid` accept these four explicit
|
||||
node properties. They are separate from CSS declarations and do not participate
|
||||
in the stylesheet cascade.
|
||||
|
||||
| Property | Accepted value | Native surface property |
|
||||
| --- | --- | --- |
|
||||
| `:help-echo` | `nil`, a string, or a function called by Emacs with `(WINDOW OBJECT POSITION)` | `help-echo` |
|
||||
| `:pointer` | `nil`, `text`, `arrow`, `vdrag`, `modeline`, `hand`, `hdrag`, `nhdrag`, or `hourglass` | `pointer` |
|
||||
| `:hover-style` | `nil` or an Ebox paint plist containing only `:color`, `:background-color`, `:text-decoration-line`, `:text-decoration-color`, and `:text-decoration-style` | Compiled `mouse-face` |
|
||||
| `:keymap` | `nil` or a native Emacs keymap whose bindings use ordinary interactive commands | `keymap` |
|
||||
|
||||
Hover colors use color strings; `:text-decoration-color` also accepts
|
||||
`currentColor`. `:text-decoration-line` accepts `none`, `underline`, `overline`,
|
||||
`line-through`, or a nonempty list of distinct decoration lines without `none`.
|
||||
`:text-decoration-style` accepts `solid`, `double`, `dotted`, `dashed`, or `wavy`.
|
||||
Hover styles cannot change font metrics, geometry, spacing, or layout. Ebox compiles
|
||||
the restricted paint plist; a raw face or arbitrary native property plist is
|
||||
not an author input. There is no `:tps`, `:local-map`, or raw `:mouse-face` input.
|
||||
|
||||
Within a rendered line, text and padding covered by the same hover owner
|
||||
(the node declaring `:hover-style`) share one native `mouse-face`. Unspecified
|
||||
hover attributes use that owner's base style. Child text under that hover
|
||||
therefore uses the owner's unspecified attributes even if its normal text color
|
||||
differs. An explicit child hover style creates its own hover region; `nil`
|
||||
blocks the enclosing hover. Physical left/right borders are excluded from
|
||||
hover highlighting, and horizontal border strokes keep their own paint.
|
||||
|
||||
A Box's help, pointer, and keymap surface includes rendered content, padding,
|
||||
and border, excluding its own margin and structural newlines; hover follows
|
||||
the coverage above. Text capabilities apply to its rendered
|
||||
text. At each nested position, an explicit child value takes precedence for
|
||||
that capability. An explicit `nil` blocks the enclosing value; an omitted
|
||||
property leaves enclosing surface coverage in place.
|
||||
|
||||
`ebox-help-create` adapts a zero-argument business function into a native
|
||||
`(WINDOW OBJECT POSITION)` help callback. The business function returns a string
|
||||
or `nil` and runs in the hovered buffer's context. Point and the selected window
|
||||
are unchanged. It is not called until Emacs requests help. For example:
|
||||
|
||||
```elisp
|
||||
(ebox-help-create (lambda () (format "Help for %s" (buffer-name))))
|
||||
```
|
||||
|
||||
Raw native help functions are also supported. Help functions are retained as
|
||||
functions and called by Emacs when help is requested, rather than evaluated
|
||||
while building the DSL. Use backquote and
|
||||
comma to insert a computed function or keymap value. Pointer and help display
|
||||
follow the user's Emacs settings and window system. A pointer shape supplies
|
||||
no command binding by itself.
|
||||
|
||||
`ebox-keymap-create` is the recommended callback-to-keymap helper:
|
||||
|
||||
```elisp
|
||||
(ebox-keymap-create
|
||||
:activate (lambda () (message "Activated"))
|
||||
:bindings (list (cons "?" (lambda () (message "Action help")))))
|
||||
```
|
||||
|
||||
`:activate` accepts a zero-argument function and binds it to `RET`, `[return]`,
|
||||
`SPC`, and `[mouse-1]`. `:bindings` accepts an alist from key-description strings
|
||||
or event vectors to zero-argument functions. Both options are optional; `nil`
|
||||
omits their bindings. Duplicate keys and overlapping key prefixes are errors,
|
||||
including overlaps with the activation keys. The helper returns a native Emacs
|
||||
keymap; callbacks remain literal until their command is invoked. For mouse
|
||||
events, the helper obtains the event window and runs the callback in its buffer.
|
||||
It does not select that window or move point. Keyboard callbacks run in the
|
||||
current buffer, using native dispatch at point.
|
||||
|
||||
Raw native keymaps remain supported. Their mouse commands must obtain the
|
||||
target window from the event and operate in that window's buffer themselves.
|
||||
`ebox-next-interaction` and `ebox-previous-interaction` are optional navigation
|
||||
commands, each accepting an optional integer count (default one). Negative
|
||||
counts reverse direction and zero leaves point unchanged. Each declaring node
|
||||
with a command-bearing keymap contributes one stop at its first accessible
|
||||
rendered character, including inherited coverage; explicit child maps form
|
||||
separate stops. From inside an owner, navigation skips that owner. It respects
|
||||
narrowing and invisibility, ignores ordinary buffer/overlay/global maps, and
|
||||
uses the current committed output after updates or scrolling. A missing target
|
||||
signals `user-error` without moving point; commands do not wrap or bind keys.
|
||||
Menu enable forms and filters are not evaluated, so a declared command can
|
||||
remain a stop when its application dynamically disables a menu item.
|
||||
Navigation moves point only; Ebox provides no separate application state or
|
||||
focus-management system.
|
||||
See the [interaction guide](ebox-user-guide.en.md#native-node-interaction) for
|
||||
a runnable example.
|
||||
|
||||
## 2. Evaluated typed construction
|
||||
|
||||
@ -118,7 +285,7 @@ one value:
|
||||
(root
|
||||
(ebox-box-create
|
||||
:layout (ebox-row-layout-create
|
||||
:item-gap 1 :cross-align 'center)
|
||||
:item-gap '(ch 1) :cross-align 'center)
|
||||
:children (list left right)
|
||||
:source-handle root-handle
|
||||
:owned-facts
|
||||
@ -176,13 +343,22 @@ though the facade inventory below focuses on application entry points.
|
||||
| `ebox-render` | Accept a single-root `CanonicalEboxInput` and return a propertized string without publishing a live buffer. |
|
||||
| `ebox-render-to-buffer` | Accept a single-root `CanonicalEboxInput`, mount a retained TP surface, enable `ebox-buffer-mode`, and return the buffer. Its optional plist accepts only `:observer FUNCTION`. |
|
||||
| `ebox-unmount-buffer` | Release a mounted Ebox/TP surface, observers, retained indexes, and runtime authority from a live buffer. |
|
||||
| `ebox-display-buffer` | Accept a single-root `CanonicalEboxInput`, render through the retained path, delete other windows, and switch to the result. |
|
||||
| `ebox-display-buffer` | `(BUFFER-OR-NAME INPUT &optional ACTION)` renders a single-root `CanonicalEboxInput`, then calls native `display-buffer` with `ACTION` and returns the buffer. Ebox does not delete other windows or select the result. |
|
||||
| `ebox-commit` | Atomically publish a single-root `CanonicalEboxInput` or a one-shot logical candidate returned by `ebox-candidate-begin`. |
|
||||
| `ebox-buffer-set-observer` | Set or remove one function-valued observer on an already mounted buffer. |
|
||||
| `ebox-buffer-update-report` | Return a defensive copy of the last successful update report. |
|
||||
| `ebox-rerender-buffer-with-context` | Apply explicit viewport width and optional height while retaining identity. |
|
||||
| `ebox-viewport-window-width` | Return Ebox's display-safe pixel width for a window. |
|
||||
|
||||
`ebox-display-buffer` respects `display-buffer-alist` and native action
|
||||
precedence. Rendering failure occurs before any display call. Window placement
|
||||
belongs to the selected Emacs display action; Ebox does not roll back a
|
||||
successfully published buffer if a caller-supplied display action fails.
|
||||
|
||||
Each mounted buffer has one viewport layout, shared by all windows showing it.
|
||||
Independent window widths require separate mounted buffers. Explicit viewport
|
||||
updates change that buffer's shared layout.
|
||||
|
||||
`ebox-commit` accepts optional framework publish and rollback callbacks. They
|
||||
join the existing atomic publication; failures restore the previous buffer,
|
||||
surface, runtime state, and successful report.
|
||||
@ -220,7 +396,7 @@ They do not publish by themselves.
|
||||
| --- | --- |
|
||||
| `ebox-region-ids` | Return current low-level region ids in document order. |
|
||||
| `ebox-region-resolve` | Resolve one logical `:id` in a mounted buffer to an opaque surface-scoped handle. |
|
||||
| `ebox-region-update` | Apply supported mutable style or scroll properties through one retained transaction. |
|
||||
| `ebox-region-update` | Apply supported mutable style, scroll, or native node capability properties through one retained transaction. Accept an opaque region handle or a semantic `:id` in the current mounted buffer. |
|
||||
| `ebox-child-range` | Framework integration only: build a nonvisual, addressable child Range descriptor while assembling a typed canonical input. |
|
||||
| `ebox-candidate-begin` | Capture the exact current mounted generation in a one-shot candidate. |
|
||||
| `ebox-candidate-replace` | Replace one candidate node address from a single-root `CanonicalEboxInput`. |
|
||||
@ -235,6 +411,21 @@ Handles and reported positions belong to one live publication generation.
|
||||
Resolve them again after their object is removed. A candidate is sealed by
|
||||
commit and cannot be reused.
|
||||
|
||||
Within a callback for the target mounted buffer, a direct update can use the
|
||||
semantic ID: `(ebox-region-update "action" :help-echo "Updated help")`.
|
||||
Semantic IDs accept strings, non-`nil` symbols, and integers; an integer author
|
||||
`:id` is distinct from Ebox's diagnostic numeric region metadata.
|
||||
Use `ebox-region-resolve` with an explicit buffer when addressing another
|
||||
mounted buffer, then pass that handle to `ebox-region-update`.
|
||||
|
||||
`ebox-region-update` accepts `:help-echo`, `:pointer`, `:hover-style`, and
|
||||
`:keymap` on Text and Box regions, with the same validation as construction.
|
||||
Passing a new value replaces that capability; passing `nil` clears it and
|
||||
blocks an enclosing value. Omitted properties are unchanged. Replace the
|
||||
`:keymap` property when publishing new bindings: Ebox snapshots keymaps, so
|
||||
later caller-side mutations do not update a mounted surface. Clearing it removes that
|
||||
node's keymap; ordinary Emacs buffer and global bindings still apply.
|
||||
|
||||
The author DSL has no Range form. Ordinary authors compose direct Text/Box
|
||||
children and never call `ebox-child-range`; Range addresses exist only for
|
||||
frameworks that already own semantic child ranges and candidate updates.
|
||||
@ -288,6 +479,8 @@ names every entry:
|
||||
`ebox-normal-layout-create`, `ebox-row-layout-create`,
|
||||
`ebox-column-layout-create`, `ebox-flex-layout-create`,
|
||||
`ebox-grid-layout-create`, `ebox-box-create`.
|
||||
- Interaction: `ebox-help-create`, `ebox-keymap-create`,
|
||||
`ebox-next-interaction`, `ebox-previous-interaction`.
|
||||
- Render/publication: `ebox-render`, `ebox-render-to-buffer`,
|
||||
`ebox-unmount-buffer`, `ebox-display-buffer`, `ebox-commit`,
|
||||
`ebox-buffer-set-observer`,
|
||||
@ -317,6 +510,7 @@ names every entry:
|
||||
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
```
|
||||
|
||||
@ -5,7 +5,10 @@
|
||||
本文描述独立 Ebox 包当前的公共边界,并把普通 author 路径与框架集成使用的
|
||||
evaluated typed API 分开。以 `ebox--` 开头的名称都是私有实现。
|
||||
|
||||
Ebox 需要 Emacs 29.1 或更高版本、ECSS 和 TP:
|
||||
Ebox 需要 Emacs 29.1 或更高版本、ECSS 0.1.0 或更高版本,以及 TP 2.0.0 或更高版本。
|
||||
EKP 1.0.0 或更高版本是 `:wrap-mode kp` 所需的可选依赖;`word`、`char`、`none`
|
||||
不加载它。EKP 缺失或不兼容时会给出可操作的错误,不会静默改变换行算法。
|
||||
源码加载方式和各项依赖职责见[安装说明](../../README.zh-CN.md#安装)。
|
||||
|
||||
```elisp
|
||||
(require 'ebox)
|
||||
@ -35,9 +38,9 @@ forest 和拥有其 author facts 的 source generation。普通 author 代码只
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(column :padding (1 2)
|
||||
'(column :padding ((lh 1) (ch 2))
|
||||
(box :id "status" :color "#166534" "Ready")
|
||||
(row :item-gap 1
|
||||
(row :item-gap (ch 1)
|
||||
(box "Left")
|
||||
(box "Right"))))
|
||||
```
|
||||
@ -66,11 +69,140 @@ participation property 就是普通子 Box property,不会创建另一种公
|
||||
| 绘制 | `:color`、`:background-color`、精确 alias `:bgcolor`、border color、`:visibility` |
|
||||
| 文本布局 | `:text-align`、`:wrap-mode`(`word`、`char`、`kp`、`none`) |
|
||||
| Overflow | `:overflow`(`visible`、`hidden`、`scroll`) |
|
||||
| 图层放置 | `:position`、`:left`、`:top`、`:z-index`、`:layer`、`:anchor`、`:placement` |
|
||||
|
||||
普通横向数字表示字符列,单元素横向 list 表示像素;纵向数字表示行数。Border
|
||||
width 是例外,始终使用非负整数像素,因此写 `(1 solid "#687386")`,不写
|
||||
`((1) solid "#687386")`。`(viewport)` 与 `(viewport-height)` 使用当前 render context。property 验证属于
|
||||
Ebox;context/value 组合无效时会直接报错。
|
||||
`hidden` 在组合子节点后约束内容宽度,保留完整的受支持文本簇;容器自身的 padding 与
|
||||
border 不在裁剪区域内。被裁掉的子文本同时移除其属性。固定 display space 可以缩短,
|
||||
放不下的字形/图像则整体省去,不做像素遮罩。纵向按完整行裁剪。`visible` 允许溢出,
|
||||
`scroll` 提供纵向滚动;这些设置不隐含省略号或横向滚动。
|
||||
|
||||
几何值统一使用 `(单位 数值)`:`(px 240)`、`(% 50)`、`(vw 100)`、`(vh 100)`、
|
||||
`(ch 80)`、`(lh 3)`。每个属性只接受[统一单位约束表](ebox-user-guide.zh.md#size-unit-constraints)
|
||||
规定的单位,不能跨几何轴使用。不接受 CSS 字符串、裸长度、单元素像素列表及旧的
|
||||
viewport/contain 关键词;零长度也需要允许的单位。边框宽度采用独立绘制厚度规则,
|
||||
不接受百分比,
|
||||
例如 `:border ((px 1) solid "#687386")`。Flex 因子、Grid 索引等无量纲值仍是数字。
|
||||
|
||||
| 属性 | 接受的关键词 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `width`、`height`、`min-width`、`min-height` | `auto`、`min-content`、`max-content`、`fit-content`、`stretch` | `auto` |
|
||||
| `max-width`、`max-height` | `none`、`min-content`、`max-content`、`fit-content`、`stretch` | `none` |
|
||||
|
||||
尺寸函数支持 `calc`、`min`、`max`、`clamp`,可以组合使用,例如
|
||||
`(calc (- (vh 100) (lh 1)))` 与 `(clamp (ch 20) (% 50) (ch 80))`。
|
||||
`fit-content` 只接受裸关键词,不带参数。property 验证属于 Ebox;构造树时就按
|
||||
属性或父布局方向,递归检查所有函数分支、展开后的简写、Grid 轨道与 Flex basis。
|
||||
context/value 组合无效时会直接报错。百分比参照、字体单位、算术规则及纵向完整行显示量化见
|
||||
[用户指南的几何章节](ebox-user-guide.zh.md#4-使用几何与绘制-property)。这是 CSS
|
||||
语义的子集,不是完整浏览器布局引擎。
|
||||
|
||||
### 保留式图层放置
|
||||
|
||||
以下不继承的几何属性适用于 `box`、`row`、`column`、`flex` 和 `grid`,Text
|
||||
不接受这些属性。现有 author tag 不变。
|
||||
|
||||
| 属性 | 接受的值 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `:position` | `static`、`relative`、`absolute` | `static` |
|
||||
| `:left` | 有符号 inline 长度(`px`、`ch`、`vw`、`%`)或尺寸表达式 | `(px 0)` |
|
||||
| `:top` | 有符号 block 长度(`lh`、`vh`、`%`)或尺寸表达式 | `(lh 0)` |
|
||||
| `:z-index` | 整数,允许负值 | `0` |
|
||||
| `:layer` | `local`、`root` | `local` |
|
||||
| `:anchor` | 以 symbol 或字符串表示的语义 `:id`,或 `nil` | `nil` |
|
||||
| `:placement` | `bottom-start`、`bottom-end`、`top-start`、`top-end` | `bottom-start` |
|
||||
|
||||
`static` 使用普通流。`relative` 保留流中占位,通过 `:left`、`:top` 平移绘制。
|
||||
`absolute` 不贡献普通流尺寸或占位,以父容器内容原点为基准;`:layer root` 则使用
|
||||
根内容画布。偏移使用绘制宿主的字体与包含尺寸;`:left` 的 `%` 参照宽度,`:top`
|
||||
的 `%` 参照高度。长度解析保留小数,最终纵向偏移向下取整到宿主的完整文本行。
|
||||
|
||||
每个局部宿主把子节点合成为一个组。组内较小的 `:z-index` 先绘制;深度相同时,
|
||||
普通内容先于定位内容绘制,定位兄弟按逻辑文档顺序绘制。嵌套子节点的深度不能
|
||||
越过局部分组。Box 的内容、padding 和 border 区域均为不透明绘制,空白也会遮挡;
|
||||
margin 不参与遮挡。绘制裁剪到宿主画布。
|
||||
|
||||
`:layer root` 只改变绘制目标,保留逻辑父节点、selector、继承和 retained identity。
|
||||
`:layer root` 与非 nil 的 `:anchor` 都要求 `:position absolute`。anchor 在绘制
|
||||
宿主的逻辑子树中解析唯一的语义 ID,并使用目标的可见边界。面板按指定的上下及
|
||||
起止边放置,对侧可容纳时纵向翻转,然后平移到画布内。anchor 缺失或不可见时不
|
||||
绘制面板;ID 歧义或循环放置依赖在发布前报错。
|
||||
|
||||
这是不透明文本合成:横向用像素,纵向共享宿主的文本行网格。定位子树必须与宿主
|
||||
使用相同的有效字体行高。裁剪保留完整的受支持文本簇和 display unit;不完整字形或
|
||||
图像以中性空白替代。不提供 alpha 混合、半字形遮罩或 child frame。native 表示
|
||||
无法表达图层放置时使用 Elisp。示例和 retained 更新行为见
|
||||
[图层指南](ebox-user-guide.zh.md#retained-layers)。
|
||||
|
||||
### 原生节点能力
|
||||
|
||||
`text`、`box`、`row`、`column`、`flex` 和 `grid` 都接受下面四个显式节点属性。
|
||||
这些能力与 CSS declaration 分开,不参与样式表 cascade。
|
||||
|
||||
| 属性 | 接受的值 | 原生 surface property |
|
||||
| --- | --- | --- |
|
||||
| `:help-echo` | `nil`、字符串,或由 Emacs 以 `(WINDOW OBJECT POSITION)` 调用的函数 | `help-echo` |
|
||||
| `:pointer` | `nil`、`text`、`arrow`、`vdrag`、`modeline`、`hand`、`hdrag`、`nhdrag` 或 `hourglass` | `pointer` |
|
||||
| `:hover-style` | `nil`,或仅包含 `:color`、`:background-color`、`:text-decoration-line`、`:text-decoration-color`、`:text-decoration-style` 的 Ebox 绘制 plist | 编译后的 `mouse-face` |
|
||||
| `:keymap` | `nil`,或使用普通交互命令作为绑定的原生 Emacs keymap | `keymap` |
|
||||
|
||||
悬停颜色使用颜色字符串,`:text-decoration-color` 还接受 `currentColor`。
|
||||
`:text-decoration-line` 接受 `none`、`underline`、`overline`、`line-through`,或不含
|
||||
`none`、不重复的非空装饰列表;`:text-decoration-style` 接受 `solid`、`double`、
|
||||
`dotted`、`dashed`、`wavy`。悬停样式不能改变字体度量、几何、间距或布局。Ebox 负责
|
||||
编译受限绘制 plist;原始 face 或任意原生 property plist 都不是 author 输入。
|
||||
不提供 `:tps`、`:local-map` 或原始 `:mouse-face` 输入。
|
||||
|
||||
同一渲染行中,由同一个 hover owner(声明 `:hover-style` 的节点)覆盖的文字与
|
||||
padding 共享一个原生 `mouse-face`。未指定的悬停属性采用 owner 的基础样式;
|
||||
因此,即使子文本的正常颜色不同,被父级悬停覆盖时也会采用 owner 未被覆盖的属性。
|
||||
子节点显式声明 hover style 会形成独立悬停区域,`nil` 阻止外层悬停。物理左右边框
|
||||
不参与悬停高亮,水平边框线条保留自己的绘制。
|
||||
|
||||
Box 的帮助、指针与 keymap 范围包含渲染后的内容、padding 和 border,不包含该 Box
|
||||
自身的 margin 及结构性换行符;悬停遵循上述范围。Text 的能力作用于其渲染文本。
|
||||
嵌套位置上,子节点显式声明的每项
|
||||
能力优先于外层值;显式 `nil` 阻止外层值覆盖,省略属性则保留外层交互范围的覆盖。
|
||||
|
||||
`ebox-help-create` 把零参数业务函数适配为原生的 `(WINDOW OBJECT POSITION)`
|
||||
帮助回调。业务函数返回字符串或 `nil`,在悬停 buffer 的上下文中运行,不移动 point
|
||||
或切换选中窗口,直到 Emacs 请求帮助时才调用。例如:
|
||||
|
||||
```elisp
|
||||
(ebox-help-create (lambda () (format "Help for %s" (buffer-name))))
|
||||
```
|
||||
|
||||
仍支持直接传入原生帮助函数。帮助函数保留为函数,由 Emacs 在请求帮助时调用,
|
||||
不在构造 DSL 时求值。
|
||||
计算得到的函数或 keymap 通过反引号和逗号插入。帮助显示和指针外观遵循用户的
|
||||
Emacs 设置及窗口系统;单独设置指针形状不会绑定命令。
|
||||
|
||||
推荐使用 `ebox-keymap-create` 将回调转换为 keymap:
|
||||
|
||||
```elisp
|
||||
(ebox-keymap-create
|
||||
:activate (lambda () (message "Activated"))
|
||||
:bindings (list (cons "?" (lambda () (message "Action help")))))
|
||||
```
|
||||
|
||||
`:activate` 接收零参数函数,将其绑定到 `RET`、`[return]`、`SPC` 和 `[mouse-1]`。
|
||||
`:bindings` 接收 alist,key 是按键描述字符串或事件向量,value 是零参数函数。
|
||||
两项都可省略,`nil` 表示不添加相应绑定。重复按键和重叠前缀会报错,包括与激活键
|
||||
重叠的绑定。辅助函数返回原生 Emacs keymap;回调保留为函数,直到对应命令触发时才调用。
|
||||
鼠标事件会自动从事件取得窗口,并在其 buffer 中运行回调,不选择窗口或移动 point。
|
||||
键盘回调在当前 buffer 中运行,由 point 所在位置进行原生分派。
|
||||
|
||||
仍支持直接传入原生 keymap;其鼠标命令需要自行从事件取得目标窗口,并在该窗口的
|
||||
buffer 中操作。
|
||||
|
||||
`ebox-next-interaction` 和 `ebox-previous-interaction` 是可选导航命令,均接受可选整数
|
||||
count,默认为一;负数反向,零不移动。每个声明了含命令 keymap 的节点在其首个可访问
|
||||
渲染字符处形成一个停靠点,包括继承覆盖;显式子 map 单独形成停靠点。从 owner 内部
|
||||
移动时跳过该 owner。导航尊重 narrowing 与不可见文本,忽略普通 buffer、overlay 和
|
||||
全局 map,在更新或滚动后使用当前已提交输出。目标不存在时抛出 `user-error`,point
|
||||
不动;命令不循环跳转,也不绑定按键。不会求值菜单 enable 表达式或 filter,因此即使
|
||||
应用动态禁用了菜单项,已声明命令仍可能保留停靠点。导航只移动 point;Ebox 不提供
|
||||
独立的应用状态或焦点管理系统。
|
||||
可运行示例见[交互指南](ebox-user-guide.zh.md#native-node-interaction)。
|
||||
|
||||
## 2. Evaluated typed construction
|
||||
|
||||
@ -110,7 +242,7 @@ facts。最后用一个 `CanonicalEboxInput` 同时传递 forest 与封存的 so
|
||||
(root
|
||||
(ebox-box-create
|
||||
:layout (ebox-row-layout-create
|
||||
:item-gap 1 :cross-align 'center)
|
||||
:item-gap '(ch 1) :cross-align 'center)
|
||||
:children (list left right)
|
||||
:source-handle root-handle
|
||||
:owned-facts
|
||||
@ -164,13 +296,20 @@ typed 模块还公开 `ebox-node-kind`、`ebox-text-node-p`、`ebox-box-node-p`
|
||||
| `ebox-render` | 接收 single-root `CanonicalEboxInput`,返回带属性字符串,不发布 live buffer。 |
|
||||
| `ebox-render-to-buffer` | 接收 single-root `CanonicalEboxInput`,挂载 retained TP surface,启用 `ebox-buffer-mode` 并返回 buffer;可选 plist 只接受 `:observer FUNCTION`。 |
|
||||
| `ebox-unmount-buffer` | 从 live buffer 释放 mounted Ebox/TP surface、observer、retained indexes 与 runtime authority。 |
|
||||
| `ebox-display-buffer` | 接收 single-root `CanonicalEboxInput`,走 retained 路径渲染,删除其他 window 后切换到结果。 |
|
||||
| `ebox-display-buffer` | `(BUFFER-OR-NAME INPUT &optional ACTION)` 先渲染 single-root `CanonicalEboxInput`,再以 `ACTION` 调用原生 `display-buffer`,返回 buffer。Ebox 不删除其他窗口,也不选中结果窗口。 |
|
||||
| `ebox-commit` | 原子发布 single-root `CanonicalEboxInput` 或 `ebox-candidate-begin` 返回的 one-shot logical candidate。 |
|
||||
| `ebox-buffer-set-observer` | 给已经 mounted 的 buffer 设置或移除一个 function observer。 |
|
||||
| `ebox-buffer-update-report` | 返回最近一次成功更新报告的防御性副本。 |
|
||||
| `ebox-rerender-buffer-with-context` | 应用显式 viewport width 与可选 height,同时保留 identity。 |
|
||||
| `ebox-viewport-window-width` | 返回 Ebox 对 window 使用的 display-safe 像素宽度。 |
|
||||
|
||||
`ebox-display-buffer` 遵循 `display-buffer-alist` 与原生 action 优先级。渲染失败时
|
||||
不会调用显示函数。窗口放置由选用的 Emacs display action 决定;调用方提供的 display
|
||||
action 报错时,Ebox 不会撤销已经成功发布的 buffer。
|
||||
|
||||
每个已挂载 buffer 只有一套视口布局,所有显示它的窗口共享该布局。不同窗口需要独立
|
||||
宽度时,使用不同的挂载 buffer。显式视口更新修改的是该 buffer 共享的布局。
|
||||
|
||||
`ebox-commit` 接受可选 framework publish/rollback callback。它们加入现有原子发布;
|
||||
失败会恢复旧 buffer、surface、runtime state 与最近一次成功报告。
|
||||
|
||||
@ -203,7 +342,7 @@ observer 为 nil 时,Ebox 不创建 observation context,不取 timestamp/GC
|
||||
| --- | --- |
|
||||
| `ebox-region-ids` | 按文档顺序返回当前底层 region id。 |
|
||||
| `ebox-region-resolve` | 把 mounted buffer 中一个逻辑 `:id` 解析为不透明、surface-scoped 的 handle。 |
|
||||
| `ebox-region-update` | 通过一次 retained transaction 应用支持的可变样式或滚动 property。 |
|
||||
| `ebox-region-update` | 通过一次 retained transaction 应用支持的可变样式、滚动或原生节点能力 property;接受不透明 region handle,或当前已挂载 buffer 中的语义 `:id`。 |
|
||||
| `ebox-child-range` | 仅供框架集成:在组装 typed canonical input 时构造非视觉、可寻址的 child Range descriptor。 |
|
||||
| `ebox-candidate-begin` | 在 one-shot candidate 中捕获当前准确 mounted generation。 |
|
||||
| `ebox-candidate-replace` | 用 single-root `CanonicalEboxInput` 替换一个 candidate node address。 |
|
||||
@ -217,6 +356,19 @@ observer 为 nil 时,Ebox 不创建 observation context,不取 timestamp/GC
|
||||
handle 和位置只属于一个 live publication generation;对象被删除后要重新解析。
|
||||
candidate 被 commit 封存后不能复用。
|
||||
|
||||
回调在目标已挂载 buffer 中运行时,可以直接用语义 ID 更新,例如
|
||||
`(ebox-region-update "action" :help-echo "Updated help")`。
|
||||
语义 ID 接受字符串、非 `nil` 符号和整数;author 声明的整数 `:id` 与 Ebox 用于
|
||||
诊断的数字 region metadata 不同。
|
||||
更新另一个已挂载 buffer 时,使用显式 buffer 调用 `ebox-region-resolve`,再把返回
|
||||
的 handle 传给 `ebox-region-update`。
|
||||
|
||||
`ebox-region-update` 接受 Text 和 Box region 上的 `:help-echo`、`:pointer`、
|
||||
`:hover-style` 和 `:keymap`,验证规则与构造时一致。新值替换对应能力,`nil` 清除
|
||||
对应能力并阻止外层值覆盖,省略的属性保持不变。Ebox 对 keymap 创建快照;调用方
|
||||
之后直接修改原 map 不会更新已挂载的 surface,应显式替换 `:keymap` 来发布新绑定。
|
||||
清除该属性只移除节点 keymap,普通 Emacs buffer 和全局绑定仍然适用。
|
||||
|
||||
author DSL 没有 Range form。普通 author 只组合直接嵌套的 Text/Box child,不调用
|
||||
`ebox-child-range`;Range address 只服务已经拥有 semantic child range 与 candidate
|
||||
update 的框架。
|
||||
@ -265,6 +417,8 @@ native 模块只是加速器,不是正确性依赖。加载包不会构建它
|
||||
- 构造:`ebox-build`、`ebox-text-create`、`ebox-normal-layout-create`、
|
||||
`ebox-row-layout-create`、`ebox-column-layout-create`、
|
||||
`ebox-flex-layout-create`、`ebox-grid-layout-create`、`ebox-box-create`;
|
||||
- 交互:`ebox-help-create`、`ebox-keymap-create`、
|
||||
`ebox-next-interaction`、`ebox-previous-interaction`;
|
||||
- 渲染/发布:`ebox-render`、`ebox-render-to-buffer`、`ebox-unmount-buffer`、
|
||||
`ebox-display-buffer`、`ebox-commit`、`ebox-buffer-set-observer`、`ebox-buffer-update-report`、
|
||||
`ebox-rerender-buffer-with-context`、`ebox-viewport-window-width`、
|
||||
@ -292,6 +446,7 @@ native 模块只是加速器,不是正确性依赖。加载包不会构建它
|
||||
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
```
|
||||
|
||||
@ -37,10 +37,10 @@ child-layout algorithm, not a different kind of visual object.
|
||||
```elisp
|
||||
(defvar ebox-guide-input
|
||||
(ebox-build
|
||||
'(column :padding (1 2)
|
||||
:border (1 solid "#8A93A6")
|
||||
'(column :padding ((lh 1) (ch 2))
|
||||
:border ((px 1) solid "#8A93A6")
|
||||
(text :color "#263244" "Research notes")
|
||||
(row :item-gap 1
|
||||
(row :item-gap (ch 1)
|
||||
(box :id "status" :background-color "#F4F6FB" "Inbox")
|
||||
(box :background-color "#EEF2FF" "Archive")))))
|
||||
```
|
||||
@ -53,6 +53,41 @@ reassemble its internal nodes.
|
||||
Use `box` with geometry but no child when an empty rectangular area is needed.
|
||||
No extra node type is necessary.
|
||||
|
||||
### Prefer defaults and inheritance
|
||||
|
||||
Write only what changes the intended result. Omit redundant default values,
|
||||
place shared text styles on an existing common parent, and keep only child
|
||||
overrides. Reference panels still spell out the property they teach, even
|
||||
when its value is the default.
|
||||
|
||||
Ebox inherits `:color`, `:font-family`, `:font-size`, `:font-weight`,
|
||||
`:font-style`, `:text-align`, `:wrap-mode`, and `:visibility`. Geometry and
|
||||
background colors do not inherit; an unpainted child can show its parent's
|
||||
background. For example:
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(column :width (ch 40) :color "#1F2328" :bgcolor "#FAF7F0"
|
||||
(box "Shared text color")
|
||||
(box :color "#B45309" "Local accent")
|
||||
(box :height (lh 1))))
|
||||
```
|
||||
|
||||
Inheritance does not expand where a property may be authored. For example,
|
||||
`:text-align` is declared on `box`; it cannot be moved onto a `row`, `column`,
|
||||
`flex`, or `grid` merely because its value can inherit.
|
||||
|
||||
In this ordinary Column, the separator fills the available width and explicitly
|
||||
reserves one line with `:height (lh 1)`. No width or repeated background is needed.
|
||||
An otherwise empty Box with `auto` height has zero content height; use
|
||||
`:height (lh 1)` when one blank line is intended. Padding and borders can still
|
||||
add their own outer extent.
|
||||
This is context-dependent: an auto-width child in a Row uses intrinsic width,
|
||||
and Flex/Grid have their own item sizing rules. Do not replace `stretch` with
|
||||
`auto` everywhere, or remove a default-looking value that overrides inherited
|
||||
styles, a stylesheet, or another shorthand. Zero sides can often be omitted
|
||||
with `:padding-inline` or `:padding-block` when no other declaration sets them.
|
||||
|
||||
## 3. Choose the layout that states the intent
|
||||
|
||||
Use `box` for ordinary content, `row` or `column` for direct one-axis
|
||||
@ -66,9 +101,9 @@ ordinary Text or Box nodes.
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(row :item-gap 2 :cross-align center
|
||||
(box :width 12 "Left")
|
||||
(box :width 12 "Right")))
|
||||
'(row :item-gap (ch 2) :cross-align center
|
||||
(box :width (ch 12) "Left")
|
||||
(box :width (ch 12) "Right")))
|
||||
```
|
||||
|
||||
### Flex
|
||||
@ -78,9 +113,9 @@ directly to a child `box` because they describe the parent-child relationship.
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(flex :width (480)
|
||||
'(flex :width (px 480)
|
||||
:flex-flow (row wrap)
|
||||
:gap (1 (12))
|
||||
:gap ((lh 1) (px 12))
|
||||
(box :flex (1 1 auto) "Primary")
|
||||
(box :flex-grow 2 "Secondary")))
|
||||
```
|
||||
@ -93,10 +128,10 @@ child `box`.
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(grid :width (640)
|
||||
:grid-template-columns ((200) (fr 1) (fr 1))
|
||||
:grid-template-rows (1 1)
|
||||
:gap (1 (12))
|
||||
'(grid :width (px 640)
|
||||
:grid-template-columns ((px 200) (fr 1) (fr 1))
|
||||
:grid-template-rows ((lh 1) (lh 1))
|
||||
:gap ((lh 1) (px 12))
|
||||
(box :grid-column (1 :span 3) "Header")
|
||||
(box :grid-column 1 :grid-row 2 "Navigation")
|
||||
(box :grid-column 2 :grid-row 2 "Main")
|
||||
@ -105,19 +140,139 @@ child `box`.
|
||||
|
||||
## 4. Use geometry and paint properties
|
||||
|
||||
Horizontal numbers are character columns; a one-element list such as `(240)`
|
||||
is a pixel width. Vertical numbers are lines. Text accepts only font,
|
||||
foreground/background, and text-decoration properties. Padding, margin,
|
||||
Box geometry uses explicit `(unit number)` values. The same syntax applies to
|
||||
width, height, their minimum/maximum bounds, padding, margins, gaps, borders,
|
||||
Flex basis, and fixed Grid tracks. Ebox implements a CSS sizing subset with an
|
||||
Elisp data representation, not CSS strings such as `"80ch"`.
|
||||
|
||||
| Unit | Meaning |
|
||||
| --- | --- |
|
||||
| `(px 240)` | 240 pixels. |
|
||||
| `(% 50)` | 50% of the property's containing-block reference size. |
|
||||
| `(vw 100)` | 100% of the viewport width. |
|
||||
| `(vh 100)` | 100% of the viewport height. |
|
||||
| `(ch 80)` | 80 times the effective font's advance width for `0`. |
|
||||
| `(lh 3)` | Three effective line heights. |
|
||||
|
||||
`ch` does not count arbitrary characters or CJK glyphs.
|
||||
`1vw` and `1vh` each mean 1% of the corresponding viewport axis. A displayed
|
||||
Ebox viewport is the target window's usable body area, excluding its mode line
|
||||
and header line; a Playground preview therefore uses the preview window.
|
||||
|
||||
<a id="size-unit-constraints"></a>
|
||||
|
||||
### Unit constraints by property
|
||||
|
||||
The following table is the unit contract for all Ebox author entry points.
|
||||
Inline means horizontal and block means vertical in Ebox's supported writing
|
||||
mode. These axis restrictions deliberately narrow CSS's general length model.
|
||||
|
||||
| Value context | Allowed units | Properties |
|
||||
| --- | --- | --- |
|
||||
| Inline geometry | `px`, `ch`, `vw`, `%` | `width`, `min-width`, `max-width`; left/right and inline padding/margin; `column-gap`; Row `item-gap`; Grid column tracks. |
|
||||
| Block geometry | `lh`, `vh`, `%` | `height`, `min-height`, `max-height`; top/bottom and block padding/margin; `row-gap`; Column `item-gap`; Grid row tracks. |
|
||||
| Positioned offsets | Inline units for `left`; block units for `top` | Signed lengths and size expressions; `%` refers to the paint host's corresponding content axis. |
|
||||
| Flex main size | The parent Flex's main-axis units | `flex-basis` and the basis in `(grow shrink basis)`; row directions use inline units, column directions use block units. |
|
||||
| Border paint thickness | `px`, `ch`, `lh`, `vw`, `vh` | `border-width`, each side's border width, and the width component of border shorthands; percentages are forbidden. |
|
||||
|
||||
The same restrictions apply recursively to every branch of `calc`, `min`,
|
||||
`max`, and `clamp`, and to nested Grid track functions. A disallowed unit is
|
||||
an error when `ebox-build` constructs the tree, even if arithmetic would
|
||||
cancel it or another branch would win. For example, `:height (px 24)`,
|
||||
`:width (lh 3)`, and `:height (min (lh 2) (px 24))` are rejected.
|
||||
Border thickness is a separate paint value, so `:border ((px 1) solid "red")`
|
||||
is valid on all four sides.
|
||||
|
||||
Stylesheets and dynamic updates follow the same contract: after computed styles
|
||||
are known and before layout, Ebox rechecks the parent Flex direction and child
|
||||
basis. Invalid updates do not publish to an existing buffer. Numeric `:flex 1`
|
||||
uses an implicit `(% 0)` basis, valid on either main axis; explicit bases still
|
||||
follow the parent direction.
|
||||
|
||||
Shorthands are checked after expansion onto their sides or axes. A one-value
|
||||
`padding`, `margin`, or `gap` must therefore be valid on both axes: use `%`
|
||||
for such a shared length, or spell out the two axes, for example
|
||||
`:padding ((lh 1) (ch 2))` and `:gap ((lh 1) (px 12))`.
|
||||
Use `:padding-inline (px 12)` or `:padding-block (lh 1)` to set only one axis.
|
||||
|
||||
Percent widths refer to containing-block width; percent heights require a
|
||||
definite containing-block height and otherwise follow the property's
|
||||
indefinite-size rule. Percentage padding and margins on every side refer to
|
||||
containing-block width. Border widths do not accept percentages.
|
||||
Negative margins and `auto` margins are outside this subset; the current
|
||||
buffer backend does not implement overlapping margin geometry.
|
||||
|
||||
| Property | Keywords | Default |
|
||||
| --- | --- | --- |
|
||||
| `width`, `height` | `auto`, `min-content`, `max-content`, `fit-content`, `stretch` | `auto` |
|
||||
| `min-width`, `min-height` | `auto`, `min-content`, `max-content`, `fit-content`, `stretch` | `auto` |
|
||||
| `max-width`, `max-height` | `none`, `min-content`, `max-content`, `fit-content`, `stretch` | `none` |
|
||||
|
||||
For a normal block root with an available viewport, `auto` width fills the
|
||||
available space and `auto` height follows content. `min-content` and
|
||||
`max-content` request intrinsic sizes. `fit-content` fits available space
|
||||
between those intrinsic bounds; `stretch` fills available space with the
|
||||
margin box. `none` removes a maximum-size constraint. Automatic minimums
|
||||
depend on layout; an explicit `(px 0)` minimum allows an item to shrink below
|
||||
its automatic content minimum on the inline axis. Use `(lh 0)` for the block
|
||||
minimum. An empty Box with no padding, border, or explicit height has zero
|
||||
height; `(box :height (lh 1))` explicitly reserves one blank line.
|
||||
|
||||
The four size functions compose units without evaluating Lisp during layout:
|
||||
|
||||
```elisp
|
||||
'(column :width (min (% 100) (ch 80))
|
||||
:height (calc (- (vh 100) (lh 1)))
|
||||
(box :width (max (px 120) (% 25)) "Sidebar")
|
||||
(box :width (clamp (ch 20) (% 50) (ch 60)) "Body"))
|
||||
```
|
||||
|
||||
`calc` accepts one arithmetic expression; `+` and `-` combine lengths, `*`
|
||||
multiplies a length by a scalar, and `/` divides it by a nonzero scalar.
|
||||
`min` and `max` choose from one or more permitted lengths. `clamp` takes
|
||||
minimum, preferred, and maximum lengths. Functions may nest. Bare numbers inside math
|
||||
are scalars, never implicit `ch` or `lh` lengths.
|
||||
|
||||
Units and functions remain data until layout, so viewport and containing-block
|
||||
changes are resolved again. Floating-point intermediate results are preserved:
|
||||
`33vw` of an 853-pixel viewport is 281.49 pixels before display quantization.
|
||||
The buffer backend materializes block geometry in whole lines; fractional
|
||||
line targets are quantized at that boundary. Vertical `px` lengths are not
|
||||
part of the author contract. `:overflow hidden` clips excess vertical lines and
|
||||
limits each content line to the measured content width. It retains the fitting
|
||||
prefix without splitting supported text clusters; a glyph or image that cannot
|
||||
fit is omitted as a whole. Fixed display spaces can be shortened to the exact
|
||||
remaining pixel width. Any remaining gap is filled with box content space, so
|
||||
padding and borders retain their positions. This is pixel-width truncation,
|
||||
not partial-glyph/image masking or automatic ellipsis. Omitted child text does
|
||||
not leave its help, hover, pointer, or keymap active in the filler.
|
||||
|
||||
For example, `(box :width (ch 3) :wrap-mode none :overflow hidden "ABCDEFGHIJ")`
|
||||
keeps only the prefix that fits three zero-glyph advances; with `visible`,
|
||||
overflow may extend outside the computed width. Set `hidden` on the container
|
||||
whose content boundary should be enforced. It applies to that container's
|
||||
composed child output as well as its direct text. `scroll` provides vertical
|
||||
scrolling and does not add horizontal scrolling.
|
||||
|
||||
Bare geometry numbers, one-element pixel lists, `viewport`, `viewport-height`,
|
||||
`contain`, and parameterized `fit-content` are rejected. Even zero needs an
|
||||
axis-appropriate unit: `(px 0)` inline or `(lh 0)` block. Grid `(fr n)` remains a track fraction; Grid placement and
|
||||
Flex growth/shrink factors remain dimensionless numbers.
|
||||
|
||||
Text style declarations accept only font, foreground/background, and
|
||||
text-decoration properties. Text also accepts the four native node capabilities
|
||||
described below.
|
||||
Padding, margin,
|
||||
border, size, `:outer`, overflow, visibility, and wrapping policy belong only
|
||||
to Box. Font and color on Box may feed inherited Text facts, but never give
|
||||
Text Box geometry.
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(box :width (420)
|
||||
:padding (1 2)
|
||||
:margin (0 1)
|
||||
:border (1 solid "#8A93A6")
|
||||
'(box :width (px 420)
|
||||
:padding ((lh 1) (ch 2))
|
||||
:margin ((lh 0) (ch 1))
|
||||
:border ((px 1) solid "#8A93A6")
|
||||
:color "#263244"
|
||||
:background-color "#FFFFFF"
|
||||
"A readable panel"))
|
||||
@ -126,6 +281,192 @@ Text Box geometry.
|
||||
Use `:outer inline` or `:outer block` to state how a Box participates in its
|
||||
parent. The child-layout algorithm still comes from the form name.
|
||||
|
||||
<a id="retained-layers"></a>
|
||||
|
||||
### Retained layers
|
||||
|
||||
Use positioning properties on existing Box forms to overlap content. Give the
|
||||
host an explicit height when absolute children need a reserved canvas: they do
|
||||
not increase its automatic flow size.
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(box :width (ch 12) :height (lh 3)
|
||||
"ABCDEFGHIJKL\nabcdefghijkl\n0123456789ab"
|
||||
(box :position absolute :left (ch 2) :top (lh 1)
|
||||
:width (ch 5) :height (lh 1)
|
||||
:background-color "#334155" :color "#FFFFFF"
|
||||
"Panel")))
|
||||
```
|
||||
|
||||
`:position relative` keeps the child's ordinary slot and moves only its paint.
|
||||
`:position absolute` removes the child from normal, Row, Column, Flex, or Grid
|
||||
flow and places it from the parent content origin. Signed `:left` uses inline
|
||||
units; signed `:top` uses block units and is floored to whole host rows at final
|
||||
placement. Both accept size expressions. A local host clips its composed output
|
||||
to its canvas. Nested local groups are automatic: a child's high `:z-index`
|
||||
cannot jump above a sibling of its host. Integer depths paint low to high;
|
||||
normal content precedes positioned content at equal depth, and equal-depth
|
||||
positioned boxes follow document order.
|
||||
|
||||
The upper box covers its content, padding, and border, including blanks, while
|
||||
its margin leaves the lower content visible. Visible text retains its native
|
||||
help, pointer, hover, and keymap properties; covered text contributes none of
|
||||
those interactions. The retained lower node and its semantic ID still exist.
|
||||
Updates to it become visible with their latest content and properties when
|
||||
the covering box moves or becomes hidden.
|
||||
|
||||
Use a root layer to place a panel beyond a clipped local container while
|
||||
keeping its logical parent and inherited styles:
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(box :width (ch 20) :height (lh 4)
|
||||
(box :height (lh 1) :overflow hidden
|
||||
(box :id "trigger" :width (ch 6) "Open")
|
||||
(box :id "panel" :position absolute :layer root
|
||||
:anchor "trigger" :placement bottom-start
|
||||
:width (ch 8) :height (lh 2)
|
||||
"Choice A\nChoice B"))))
|
||||
```
|
||||
|
||||
`:layer root` selects the root content canvas as the paint target. It does not
|
||||
reparent the panel for selectors, inheritance, or region updates. `:anchor`
|
||||
uses a unique semantic ID in the paint host's logical subtree. Both properties
|
||||
require absolute positioning. Placements are `bottom-start`, `bottom-end`,
|
||||
`top-start`, and `top-end`; offsets adjust the requested origin. Panels flip
|
||||
vertically if the opposite side fits, then shift within the host canvas.
|
||||
Missing or invisible anchors do not paint a panel; ambiguous IDs and placement
|
||||
dependency cycles are errors.
|
||||
|
||||
Composition uses horizontal pixels and one shared vertical text-row grid.
|
||||
Positioned subtrees must have the same effective font line height as their
|
||||
host. Whole supported clusters survive cuts; partially covered glyphs/images
|
||||
are replaced with neutral space, and fixed display spaces can be shortened.
|
||||
There is no alpha blending, half-glyph masking, or child frame. Layered trees
|
||||
fall back to Elisp when native reflow cannot represent them.
|
||||
|
||||
Publication remains a TP transaction, including hidden-node updates and
|
||||
rollback. Fixed-footprint local changes can recompose only the affected host;
|
||||
geometry changes and root-layer dependencies may require broader work. No
|
||||
per-update latency or universal local-update guarantee is implied. Run
|
||||
`make layer-tests` for the focused contract. The separate `ebox-playground`
|
||||
package provides `layer-minimal.ebox` and `layer-reference.ebox`; batch tests
|
||||
do not establish GUI visual parity or remote CI success.
|
||||
|
||||
### Wrapping and the optional EKP dependency
|
||||
|
||||
`:wrap-mode word` wraps ordinary words and permits character breaks for CJK;
|
||||
`char` breaks at the common text-cluster boundaries supported by Ebox; `none`
|
||||
keeps explicit newlines without adding soft line breaks. `kp` delegates
|
||||
paragraph layout to the independent [EKP package](https://github.com/Kinneyzhang/emacs-kp)
|
||||
using the Knuth–Plass algorithm. The mode belongs to a Box and is inherited by
|
||||
its text content.
|
||||
|
||||
Install EKP 1.0.0 or newer, or add its source directory to `load-path`, before
|
||||
using `:wrap-mode kp`. It is an optional dependency loaded on demand; ordinary
|
||||
Ebox layouts require only ECSS and TP. Choosing `kp` without a compatible EKP
|
||||
signals an installation or update error; install EKP or explicitly select
|
||||
another mode. Ebox never silently switches to word wrapping. The EKP accelerator and the Ebox Rust
|
||||
reflow module are separate optional modules; enabling one does not install the
|
||||
other.
|
||||
|
||||
```elisp
|
||||
(add-to-list 'load-path "/path/to/ekp")
|
||||
(ebox-render
|
||||
(ebox-build '(box :width (ch 40) :wrap-mode kp :overflow hidden
|
||||
"Knuth–Plass considers the paragraph when choosing line breaks.")))
|
||||
```
|
||||
|
||||
Common combining marks, variation selectors, emoji modifiers, ZWJ sequences,
|
||||
and regional-indicator pairs are kept together by Ebox's text-cluster handling.
|
||||
This is not complete Unicode grapheme segmentation or browser-level
|
||||
bidirectional layout. Font shaping and glyph availability remain dependent on
|
||||
Emacs, the selected fonts, and the window system.
|
||||
|
||||
<a id="native-node-interaction"></a>
|
||||
### Native node interaction
|
||||
|
||||
Text and every Box form accept `:help-echo`, `:pointer`, `:hover-style`, and
|
||||
`:keymap`. These are explicit node capabilities outside the CSS cascade.
|
||||
For example, render a Box with dynamic help, a hand pointer, hover paint, and
|
||||
one callback for both mouse and keyboard activation:
|
||||
|
||||
```elisp
|
||||
(ebox-render-to-buffer
|
||||
"*Ebox Interaction*"
|
||||
(ebox-build
|
||||
`(box :id "action" :padding ((lh 1) (ch 2))
|
||||
:border ((px 1) solid "#5893A3")
|
||||
:help-echo ,(ebox-help-create
|
||||
(lambda ()
|
||||
(format-time-string "Help requested at %H:%M:%S")))
|
||||
:pointer hand
|
||||
:hover-style (:color "#FFFFFF" :background-color "#286477"
|
||||
:text-decoration-line underline)
|
||||
:keymap ,(ebox-keymap-create :activate #'describe-mode)
|
||||
"Click or press RET / SPC here to describe this buffer's mode.")))
|
||||
```
|
||||
|
||||
`:help-echo` accepts a string, a native function with arguments
|
||||
`(WINDOW OBJECT POSITION)`, or `nil`. Prefer `ebox-help-create` to adapt a
|
||||
zero-argument business function returning a string or `nil`; Ebox supplies the
|
||||
hovered buffer's context. Emacs calls the function when requesting help; the DSL
|
||||
does not call it while building the tree. `:pointer` accepts
|
||||
`text`, `arrow`, `vdrag`, `modeline`, `hand`, `hdrag`, `nhdrag`, `hourglass`,
|
||||
or `nil`. Help display and exact pointer appearance depend on the user's Emacs
|
||||
settings and window system.
|
||||
|
||||
`:hover-style` accepts `nil` or an Ebox paint plist containing only `:color`,
|
||||
`:background-color`, `:text-decoration-line`, `:text-decoration-color`, and
|
||||
`:text-decoration-style`. Within a rendered line, one declaring node's text
|
||||
and padding share a native `mouse-face`, with unspecified attributes taken from
|
||||
that node's base style. Differently colored child text covered by this hover
|
||||
uses the same hover base. Physical left/right borders are excluded from hover;
|
||||
horizontal border strokes remain intact. Font metrics and geometry do not change.
|
||||
The [API reference](ebox-api-reference.en.md#native-node-capabilities)
|
||||
defines the accepted values. Ebox compiles this plist to native `mouse-face`;
|
||||
there is no arbitrary native-property passthrough.
|
||||
|
||||
Box help, pointer, and keymap cover its rendered content, padding, and border,
|
||||
excluding that Box's margin and structural newlines; hover follows the rule
|
||||
above. A nested Text or Box can replace
|
||||
each capability with an explicit value. Explicit `nil` blocks an enclosing
|
||||
value; an omitted property leaves the enclosing surface coverage in place.
|
||||
|
||||
`:keymap` accepts a native Emacs keymap or `nil`. Prefer `ebox-keymap-create`:
|
||||
its `:activate` callback takes no arguments and handles `RET`, `[return]`, `SPC`,
|
||||
and `[mouse-1]`. Use `:bindings` for an alist of key-description strings or event
|
||||
vectors paired with zero-argument callbacks. The helper uses the event window's
|
||||
buffer for mouse callbacks, so a callback can directly update a semantic ID
|
||||
with `(ebox-region-update "action" :help-echo "Activated")`.
|
||||
|
||||
Move point into the surface to use keyboard bindings. Optional commands
|
||||
`ebox-next-interaction` and `ebox-previous-interaction` move between declaring
|
||||
nodes that currently render a usable keymap. Each owner contributes one stop,
|
||||
even if its map covers padding or several lines; explicit child maps are
|
||||
separate stops. Empty maps, explicit `nil`, and invisible or clipped-away
|
||||
content do not add stops. Navigation follows the current committed buffer, so
|
||||
updates do not require a separate refresh. At a boundary it reports a user error
|
||||
without wrapping or moving point. These commands add no bindings automatically.
|
||||
For example, opt in for buffers using `ebox-buffer-mode`:
|
||||
|
||||
```elisp
|
||||
(keymap-set ebox-scroll-map "TAB" #'ebox-next-interaction)
|
||||
(keymap-set ebox-scroll-map "<backtab>" #'ebox-previous-interaction)
|
||||
```
|
||||
|
||||
Raw native keymaps are also accepted; their mouse commands must handle the
|
||||
event's target buffer themselves. Ebox uses native command dispatch and does
|
||||
not create an application state or focus-management system. A hand pointer
|
||||
alone does not bind a click command.
|
||||
|
||||
The Playground's [interaction lab](../../../ebox-playground/README.md#native-interaction-lab)
|
||||
keeps its business functions and state in `interaction-reference.el`, with its
|
||||
layout in `interaction-reference.ebox`. It demonstrates callbacks, nested
|
||||
overrides, and replacing or removing all four capabilities through public
|
||||
region updates.
|
||||
|
||||
## 5. Render and publish
|
||||
|
||||
`ebox-render` returns propertized text without changing a live buffer.
|
||||
@ -138,13 +479,28 @@ Both functions, and `ebox-display-buffer`, accept the opaque value returned by
|
||||
(ebox-render-to-buffer "*Ebox Guide*" ebox-guide-input)
|
||||
```
|
||||
|
||||
To display the result as well, use `ebox-display-buffer`. It finishes rendering
|
||||
before asking Emacs to place the buffer, respects `display-buffer-alist`, and
|
||||
accepts the same optional action argument as native `display-buffer`:
|
||||
|
||||
```elisp
|
||||
(ebox-display-buffer "*Ebox Guide*" ebox-guide-input
|
||||
'(display-buffer-pop-up-window))
|
||||
```
|
||||
|
||||
It returns the buffer and does not select the result or delete other windows.
|
||||
A rendering error leaves the window layout untouched. A custom display action
|
||||
can still have its own window effects; if it fails after publication, the
|
||||
successfully rendered buffer remains available. Use `ebox-render-to-buffer`
|
||||
when the caller owns display placement entirely.
|
||||
|
||||
Build a fresh canonical input and use `ebox-commit` for an atomic update:
|
||||
|
||||
```elisp
|
||||
(ebox-commit
|
||||
"*Ebox Guide*"
|
||||
(ebox-build
|
||||
'(column :padding (1 2)
|
||||
'(column :padding ((lh 1) (ch 2))
|
||||
(text "Updated notes")
|
||||
(box :key body "The new input is caller-owned."))))
|
||||
```
|
||||
@ -156,13 +512,40 @@ the last successful update report.
|
||||
## 6. Query and update a mounted surface
|
||||
|
||||
`:id`, `:class`, and `:key` are author metadata. Selectors use ECSS semantics.
|
||||
Resolve an `:id` to an opaque, surface-scoped handle before a direct update:
|
||||
In the target mounted buffer, pass the semantic `:id` directly:
|
||||
|
||||
```elisp
|
||||
(with-current-buffer "*Ebox Guide*"
|
||||
(ebox-region-update "status" :color "#166534"))
|
||||
```
|
||||
|
||||
For an explicit buffer address, resolve an `:id` to an opaque, surface-scoped
|
||||
handle:
|
||||
|
||||
```elisp
|
||||
(let ((handle (ebox-region-resolve "*Ebox Guide*" "status")))
|
||||
(ebox-region-update handle :color "#166534"))
|
||||
```
|
||||
|
||||
The same API replaces native capabilities on Text or Box regions. For the
|
||||
interaction example above:
|
||||
|
||||
```elisp
|
||||
(with-current-buffer "*Ebox Interaction*"
|
||||
(ebox-region-update "action" :help-echo "Updated help" :pointer 'arrow
|
||||
:hover-style '(:background-color "#F6D6AB"))
|
||||
(ebox-region-update "action" :help-echo nil :pointer nil
|
||||
:hover-style nil :keymap nil))
|
||||
```
|
||||
|
||||
Update arguments are evaluated Elisp, so literal symbols and plists need
|
||||
quotes, unlike properties inside already quoted DSL data. Omitted update
|
||||
properties stay unchanged. Explicit `nil` clears that capability and blocks
|
||||
an enclosing value.
|
||||
Ebox snapshots keymaps; replace `:keymap` to publish new bindings instead of
|
||||
mutating the original map. Clearing a node keymap leaves ordinary buffer and
|
||||
global bindings available.
|
||||
|
||||
`ebox-selector-query-buffer` returns document-ordered matches from a mounted
|
||||
buffer. `ebox-selector-update-buffer` applies one style update to all editable
|
||||
matches. Numeric region ids are diagnostic render metadata, not stable update
|
||||
@ -170,18 +553,25 @@ handles.
|
||||
|
||||
## 7. Resize and scroll
|
||||
|
||||
A finite height plus `:overflow scroll` creates a scroll window. Ebox buffer
|
||||
mode installs keyboard and wheel commands that route through the innermost
|
||||
scroll owner before falling back to ordinary Emacs scrolling.
|
||||
When content exceeds a finite height, `:overflow scroll` creates an internal
|
||||
scroll window. Keyboard scrolling targets point; wheel and trackpad scrolling
|
||||
target the mouse position carried by the event. Remaining distance passes
|
||||
through enclosing scroll owners from inner to outer, then to ordinary Emacs
|
||||
scrolling. Events over another column, outside a box, or on the mode line do
|
||||
not select an unrelated scroll box elsewhere on the page.
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(box :id log :width (420) :height 8 :overflow scroll
|
||||
'(box :id log :width (px 420) :height (lh 8) :overflow scroll
|
||||
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9"))
|
||||
```
|
||||
|
||||
Visible mounted buffers follow their display window. Integrations may apply an
|
||||
explicit viewport with:
|
||||
Visible mounted buffers follow one chosen display window. Each mounted buffer
|
||||
owns one viewport layout: showing the same buffer in two windows with different
|
||||
widths does not provide two independent layouts. Mount the same canonical input
|
||||
in separate buffers when each view needs its own width. Integrations may apply
|
||||
an explicit viewport, measured in pixels horizontally and lines vertically,
|
||||
with:
|
||||
|
||||
```elisp
|
||||
(ebox-rerender-buffer-with-context
|
||||
@ -190,18 +580,71 @@ explicit viewport with:
|
||||
|
||||
## 8. Standalone `.ebox` files
|
||||
|
||||
A `.ebox` file contains one unquoted structural form:
|
||||
A `.ebox` file contains one ordinary Elisp expression whose value is the layout
|
||||
data passed to `ebox-build`. Quote the whole list for a static layout:
|
||||
|
||||
```elisp
|
||||
(column :padding '(1 2)
|
||||
(text :color "#263244" "Title")
|
||||
(box :width '(240) "Body"))
|
||||
'(column :padding ((lh 1) (ch 2))
|
||||
:cross-align center
|
||||
(text :color "#263244" "Title")
|
||||
(box :width (px 240) "Body"))
|
||||
```
|
||||
|
||||
The sibling `ebox-playground` evaluates property expressions before calling
|
||||
`ebox-build`. Quote list and symbol constants in a `.ebox` file, as shown
|
||||
above. When calling `ebox-build` directly, pass inert data as in the earlier
|
||||
examples.
|
||||
The sibling `ebox-playground` reads exactly one expression, evaluates it with
|
||||
lexical bindings, and passes the resulting data to `ebox-build`. It does not
|
||||
evaluate individual properties or children. The expression is exactly what you
|
||||
would write as the argument to `(ebox-build ...)` in an `.el` file; omit that
|
||||
outer call in `.ebox`.
|
||||
|
||||
Put necessary business functions and variables in a same-basename `.el` file
|
||||
beside the layout. For `notes.ebox`, the Playground loads `notes.el` when it
|
||||
exists, before evaluating the layout on every preview. This applies to
|
||||
`C-c C-c`, `ebox-playground-open-file`, and file render entry points. It loads
|
||||
the exact `.el` source, so saving changes is enough for the next preview;
|
||||
there is no `require` cache or preference for a stale `.elc`. If the companion
|
||||
is absent, the standalone `.ebox` still works. Companion load errors stop the
|
||||
render before the layout is evaluated.
|
||||
|
||||
For example, `notes.el` owns the data and action:
|
||||
|
||||
```elisp
|
||||
;;; notes.el --- Notes example behavior -*- lexical-binding: t; -*-
|
||||
(defvar notes-body "A long article.")
|
||||
|
||||
(defun notes-help ()
|
||||
"Return business help for the article."
|
||||
(format "Article: %s" notes-body))
|
||||
|
||||
(defun notes-activate ()
|
||||
"Mark the article in the current Ebox buffer."
|
||||
(ebox-region-update "article" :color "#166534"))
|
||||
```
|
||||
|
||||
`notes.ebox` stays focused on the layout:
|
||||
|
||||
```elisp
|
||||
`(column :padding ((lh 1) (ch 2))
|
||||
(box :id "article" :width (px 480)
|
||||
:help-echo ,(ebox-help-create #'notes-help)
|
||||
:pointer hand
|
||||
:keymap ,(ebox-keymap-create :activate #'notes-activate)
|
||||
,notes-body))
|
||||
```
|
||||
|
||||
Backquote keeps layout data literal, `,` inserts a value, and `,@` inserts a
|
||||
list of children. Avoid helpers whose only purpose is hiding repeated DSL
|
||||
property lists. Each preview loads the companion and then evaluates the layout
|
||||
once; ordinary Elisp variable definitions determine whether application state
|
||||
is initialized, retained, or reset.
|
||||
|
||||
Migration from the old property-evaluation format is explicit: add a quote to
|
||||
the whole static layout and remove the quotes around its list and symbol
|
||||
properties. For dynamic layouts, use backquote and commas at the values to
|
||||
evaluate. For example, old `:padding '(1 2)` becomes
|
||||
`:padding ((lh 1) (ch 2))` inside a quoted layout, and a computed pixel width
|
||||
becomes `:width (px ,(+ 200 40))` inside a backquoted layout. Bare structural forms
|
||||
are no longer interpreted as DSL automatically; there is no legacy-format
|
||||
auto-detection.
|
||||
|
||||
## 9. Typed integration API
|
||||
|
||||
@ -243,7 +686,7 @@ the forest and source index together as one `CanonicalEboxInput`.
|
||||
(root
|
||||
(ebox-box-create
|
||||
:layout (ebox-row-layout-create
|
||||
:item-gap 1 :cross-align 'center)
|
||||
:item-gap '(ch 1) :cross-align 'center)
|
||||
:children (list left right)
|
||||
:source-handle root-handle
|
||||
:owned-facts
|
||||
@ -274,6 +717,7 @@ From the repository root:
|
||||
|
||||
```sh
|
||||
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
```
|
||||
|
||||
@ -33,10 +33,10 @@ Ebox 文档由 Text 和 Box 节点组成。author 语法只有七个入口:
|
||||
```elisp
|
||||
(defvar ebox-guide-input
|
||||
(ebox-build
|
||||
'(column :padding (1 2)
|
||||
:border (1 solid "#8A93A6")
|
||||
'(column :padding ((lh 1) (ch 2))
|
||||
:border ((px 1) solid "#8A93A6")
|
||||
(text :color "#263244" "Research notes")
|
||||
(row :item-gap 1
|
||||
(row :item-gap (ch 1)
|
||||
(box :id "status" :background-color "#F4F6FB" "Inbox")
|
||||
(box :background-color "#EEF2FF" "Archive")))))
|
||||
```
|
||||
@ -47,6 +47,33 @@ generation 保持为一个整体。普通 author 代码只需把这个值原样
|
||||
|
||||
需要空矩形区域时,使用只有几何 property、没有子节点的 `box`,不需要额外节点类型。
|
||||
|
||||
### 优先利用默认值和继承
|
||||
|
||||
默认只写改变预期效果的属性:省略冗余默认值,把共同的文字样式放到已有的共同父节点,
|
||||
子节点只写差异。参考示例正在讲解的属性仍然显式写出,即使它恰好采用默认值。
|
||||
|
||||
eBox 自动继承 `:color`、`:font-family`、`:font-size`、`:font-weight`、
|
||||
`:font-style`、`:text-align`、`:wrap-mode` 和 `:visibility`。几何属性和背景色不继承;
|
||||
子节点未绘制背景时,可以透出父节点的背景。例如:
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(column :width (ch 40) :color "#1F2328" :bgcolor "#FAF7F0"
|
||||
(box "Shared text color")
|
||||
(box :color "#B45309" "Local accent")
|
||||
(box :height (lh 1))))
|
||||
```
|
||||
|
||||
继承不会扩大属性的声明范围。例如 `:text-align` 只能声明在 `box` 上,不能因为它可以继承,
|
||||
就把它移到 `row`、`column`、`flex` 或 `grid` 上。
|
||||
|
||||
在这个普通 Column 中,分隔框会填满可用宽度,并通过 `:height (lh 1)` 明确保留一行,
|
||||
无需重复设置宽度和背景。空 Box 的 `auto` 内容高度为零;需要一行留白时显式设置
|
||||
`:height (lh 1)`。padding 和 border 仍可增加各自的外部占用。
|
||||
这取决于布局上下文:Row 中 auto 宽度的子项按固有宽度排布,Flex/Grid 也有各自的子项尺寸规则。
|
||||
不要把所有 `stretch` 都替换成 `auto`,也不要删除正在覆盖继承样式、样式表或其他简写的值。
|
||||
没有其他声明设置对应边距时,可以用 `:padding-inline` 或 `:padding-block` 省去零值轴。
|
||||
|
||||
## 3. 用布局名称直接表达意图
|
||||
|
||||
普通内容用 `box`;直接的一维组合用 `row` 或 `column`;需要分配剩余空间或换行时
|
||||
@ -59,9 +86,9 @@ generation 保持为一个整体。普通 author 代码只需把这个值原样
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(row :item-gap 2 :cross-align center
|
||||
(box :width 12 "Left")
|
||||
(box :width 12 "Right")))
|
||||
'(row :item-gap (ch 2) :cross-align center
|
||||
(box :width (ch 12) "Left")
|
||||
(box :width (ch 12) "Right")))
|
||||
```
|
||||
|
||||
### Flex
|
||||
@ -71,9 +98,9 @@ Flex 容器 property 属于 `flex`。participation property 直接属于子 `box
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(flex :width (480)
|
||||
'(flex :width (px 480)
|
||||
:flex-flow (row wrap)
|
||||
:gap (1 (12))
|
||||
:gap ((lh 1) (px 12))
|
||||
(box :flex (1 1 auto) "Primary")
|
||||
(box :flex-grow 2 "Secondary")))
|
||||
```
|
||||
@ -85,10 +112,10 @@ placement property 同样直接属于子 `box`。
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(grid :width (640)
|
||||
:grid-template-columns ((200) (fr 1) (fr 1))
|
||||
:grid-template-rows (1 1)
|
||||
:gap (1 (12))
|
||||
'(grid :width (px 640)
|
||||
:grid-template-columns ((px 200) (fr 1) (fr 1))
|
||||
:grid-template-rows ((lh 1) (lh 1))
|
||||
:gap ((lh 1) (px 12))
|
||||
(box :grid-column (1 :span 3) "Header")
|
||||
(box :grid-column 1 :grid-row 2 "Navigation")
|
||||
(box :grid-column 2 :grid-row 2 "Main")
|
||||
@ -97,17 +124,117 @@ placement property 同样直接属于子 `box`。
|
||||
|
||||
## 4. 使用几何与绘制 property
|
||||
|
||||
普通横向数字表示字符列,`(240)` 这样的单元素 list 表示像素宽度;纵向数字表示
|
||||
行数。Text 只接受字体、前景/背景和文本装饰 property;padding、margin、border、
|
||||
Box 几何统一使用 `(单位 数值)`。宽高及其最小/最大值、内外边距、gap、边框宽度、
|
||||
Flex basis 和 Grid 固定轨道都使用这套格式。Ebox 实现 CSS 尺寸语义的一个子集,
|
||||
输入是 Elisp 数据,不接受 `"80ch"` 这样的 CSS 字符串。
|
||||
|
||||
| 单位 | 含义 |
|
||||
| --- | --- |
|
||||
| `(px 240)` | 240 像素。 |
|
||||
| `(% 50)` | 该属性所参照的包含块尺寸的 50%。 |
|
||||
| `(vw 100)` | 视口宽度的 100%。 |
|
||||
| `(vh 100)` | 视口高度的 100%。 |
|
||||
| `(ch 80)` | 当前有效字体中字符 `0` 的排版前进宽度的 80 倍。 |
|
||||
| `(lh 3)` | 三个有效行高。 |
|
||||
|
||||
`ch` 不表示任意字符或汉字的个数。`1vw`、`1vh` 分别表示视口对应轴的 1%。
|
||||
显示中的 Ebox 视口是目标窗口的
|
||||
可用正文区域,不包含 mode-line 和 header-line;因此 Playground 使用预览窗口。
|
||||
|
||||
<a id="size-unit-constraints"></a>
|
||||
|
||||
### 按属性限制单位
|
||||
|
||||
下表是所有 Ebox 作者入口共同遵循的单位契约。Ebox 当前支持的书写模式中,inline
|
||||
表示横向,block 表示纵向。这是针对 Emacs 收窄后的规则,比 CSS 的通用长度模型严格。
|
||||
|
||||
| 值的用途 | 允许单位 | 对应属性 |
|
||||
| --- | --- | --- |
|
||||
| Inline 几何 | `px`、`ch`、`vw`、`%` | `width`、`min-width`、`max-width`;左右和 inline padding/margin;`column-gap`;Row 的 `item-gap`;Grid 列轨道。 |
|
||||
| Block 几何 | `lh`、`vh`、`%` | `height`、`min-height`、`max-height`;上下和 block padding/margin;`row-gap`;Column 的 `item-gap`;Grid 行轨道。 |
|
||||
| 定位偏移 | `left` 使用 inline 单位;`top` 使用 block 单位 | 有符号长度及尺寸表达式;`%` 参照绘制宿主对应内容轴。 |
|
||||
| Flex 主轴尺寸 | 所在父 Flex 主轴允许的单位 | `flex-basis` 及 `(grow shrink basis)` 中的 basis;row 方向用 inline 单位,column 方向用 block 单位。 |
|
||||
| 边框绘制厚度 | `px`、`ch`、`lh`、`vw`、`vh` | `border-width`、各侧边框宽度、border 简写中的宽度;不接受百分比。 |
|
||||
|
||||
`calc`、`min`、`max`、`clamp` 的所有分支以及嵌套的 Grid 轨道函数,都递归遵循
|
||||
对应的单位限制。出现不允许的单位时,`ebox-build` 初次构造树就报错;即使计算会
|
||||
抵消它,或最终会选中其他分支,也不例外。例如 `:height (px 24)`、`:width (lh 3)`
|
||||
和 `:height (min (lh 2) (px 24))` 都不接受。边框厚度是独立的绘制值,因此
|
||||
`:border ((px 1) solid "red")` 可以同时设置四个方向的边框。
|
||||
|
||||
样式表和动态更新也遵循同一契约:计算样式确定后、布局开始前,重新检查 Flex
|
||||
父容器方向与子项 basis。非法更新不会发布到已有 buffer。数字 `:flex 1` 的隐含
|
||||
basis 为 `(% 0)`,因此可用于两个主轴;显式 basis 仍按父方向验证。
|
||||
|
||||
简写展开到各个方向后逐项验证。单值 `padding`、`margin`、`gap` 会同时用于两轴,
|
||||
因此这种共享长度只能使用 `%`;通常直接分开两轴,例如
|
||||
`:padding ((lh 1) (ch 2))`、`:gap ((lh 1) (px 12))`。
|
||||
只设置一轴时,可以写 `:padding-inline (px 12)` 或 `:padding-block (lh 1)`。
|
||||
|
||||
百分比宽度参照包含块宽度;百分比高度需要确定的包含块高度,否则按该属性
|
||||
的尺寸不确定规则处理。四个方向的百分比 padding、margin 都参照包含块宽度。
|
||||
边框宽度不接受百分比。
|
||||
此子集暂不支持负 margin 和 `auto` margin;当前 buffer 后端没有实现外边距重叠
|
||||
所需的几何能力。
|
||||
|
||||
| 属性 | 关键词 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `width`、`height` | `auto`、`min-content`、`max-content`、`fit-content`、`stretch` | `auto` |
|
||||
| `min-width`、`min-height` | `auto`、`min-content`、`max-content`、`fit-content`、`stretch` | `auto` |
|
||||
| `max-width`、`max-height` | `none`、`min-content`、`max-content`、`fit-content`、`stretch` | `none` |
|
||||
|
||||
普通块级根容器有可用视口时,`auto` 宽度填满可用空间,`auto` 高度由内容决定。
|
||||
`min-content`、`max-content` 请求内容固有尺寸;`fit-content` 在这两个固有尺寸之间
|
||||
适应可用空间;`stretch` 让 margin box 填满可用空间。`none` 表示不限制最大尺寸。
|
||||
自动最小尺寸取决于布局;显式设置横向最小值 `(px 0)` 或纵向最小值 `(lh 0)`,
|
||||
可允许 item 缩小到其自动内容最小尺寸以下。没有 padding、border 和显式高度的空
|
||||
Box 高度为零;`(box :height (lh 1))` 明确保留一行空白。
|
||||
|
||||
四种尺寸函数可以组合单位,不会在布局阶段执行任意 Lisp:
|
||||
|
||||
```elisp
|
||||
'(column :width (min (% 100) (ch 80))
|
||||
:height (calc (- (vh 100) (lh 1)))
|
||||
(box :width (max (px 120) (% 25)) "侧栏")
|
||||
(box :width (clamp (ch 20) (% 50) (ch 60)) "正文"))
|
||||
```
|
||||
|
||||
`calc` 接受一个算术表达式;`+`、`-` 组合长度,`*` 将长度乘以标量,`/` 将长度
|
||||
除以非零标量。`min`、`max` 从一个或多个允许的长度中选取最小值、最大值;`clamp` 的三个
|
||||
参数依次为最小值、首选值、最大值。函数可以嵌套。算术表达式内的裸数字只是标量,
|
||||
不会隐式变成 `ch` 或 `lh`。
|
||||
|
||||
单位和函数一直保留为数据,到布局阶段才解析,因此视口或包含块变化后会重新计算。
|
||||
中间结果保留小数,例如 853 像素视口的 `33vw` 在显示量化前是 281.49 像素。
|
||||
当前 buffer 后端按完整行生成 block 几何,小数行目标在这个边界量化。作者接口不
|
||||
接受纵向 `px` 长度。
|
||||
`:overflow hidden` 裁掉超出的纵向行,并将每行内容限制在测量后的内容宽度内。
|
||||
保留放得下的前缀,不拆开受支持的文本簇;放不下的字形或图像整体省去。固定 display
|
||||
space 可以缩短到精确的剩余像素宽度,剩余空隙用盒子内容空白补齐,使 padding 和边框
|
||||
保持位置。这是按像素宽度截断,不是显示半个字形/图像,也不自动添加省略号。
|
||||
被省去的子文本不会在填充空白上留下自己的帮助、hover、指针或 keymap。
|
||||
|
||||
例如 `(box :width (ch 3) :wrap-mode none :overflow hidden "ABCDEFGHIJ")` 只保留
|
||||
三个 `0` 字形前进宽度内放得下的前缀;使用 `visible` 时,溢出仍可超出计算宽度。
|
||||
在哪个容器上设置 `hidden`,就约束哪个容器的内容边界,直接文本和组合后的子节点输出
|
||||
都受约束。`scroll` 提供纵向滚动,不增加横向滚动。
|
||||
|
||||
不再接受几何裸数字、单元素像素列表、`viewport`、`viewport-height`、`contain`,
|
||||
以及带参数的 `fit-content`。零长度也需要对应轴允许的单位,例如横向 `(px 0)`、
|
||||
纵向 `(lh 0)`。Grid 的 `(fr n)` 仍表示轨道
|
||||
份额;Grid 放置索引、Flex grow/shrink 因子仍是无量纲数字。
|
||||
|
||||
Text 的样式声明只接受字体、前景/背景和文本装饰 property;此外,Text 还接受下文
|
||||
介绍的四种原生节点能力。padding、margin、border、
|
||||
尺寸、`:outer`、overflow、可见性和换行策略只属于 Box。Box 上的字体与颜色可以
|
||||
作为其 Text 子树的继承来源,但不会让 Text 获得 Box 几何。
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(box :width (420)
|
||||
:padding (1 2)
|
||||
:margin (0 1)
|
||||
:border (1 solid "#8A93A6")
|
||||
'(box :width (px 420)
|
||||
:padding ((lh 1) (ch 2))
|
||||
:margin ((lh 0) (ch 1))
|
||||
:border ((px 1) solid "#8A93A6")
|
||||
:color "#263244"
|
||||
:background-color "#FFFFFF"
|
||||
"A readable panel"))
|
||||
@ -116,6 +243,158 @@ placement property 同样直接属于子 `box`。
|
||||
用 `:outer inline` 或 `:outer block` 表达 Box 如何参与父布局。子布局算法仍由 form
|
||||
名称决定。
|
||||
|
||||
<a id="retained-layers"></a>
|
||||
|
||||
### 保留式图层
|
||||
|
||||
在现有 Box form 上设置定位属性即可重叠内容。绝对定位子节点不增加普通流的自动
|
||||
尺寸,需要预留画布时,为宿主声明明确的高度。
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(box :width (ch 12) :height (lh 3)
|
||||
"ABCDEFGHIJKL\nabcdefghijkl\n0123456789ab"
|
||||
(box :position absolute :left (ch 2) :top (lh 1)
|
||||
:width (ch 5) :height (lh 1)
|
||||
:background-color "#334155" :color "#FFFFFF"
|
||||
"Panel")))
|
||||
```
|
||||
|
||||
`:position relative` 保留普通流中的占位,只移动绘制。`:position absolute`
|
||||
从 normal、Row、Column、Flex 或 Grid 流中移除子节点,并从父内容原点放置。
|
||||
有符号 `:left` 使用 inline 单位;有符号 `:top` 使用 block 单位,最终放置时
|
||||
向下取整到宿主的完整行。两者都接受尺寸表达式。局部宿主把合成输出裁剪到自身
|
||||
画布。嵌套局部分组自动建立:子节点再高的 `:z-index` 也无法越过宿主的兄弟。
|
||||
整数深度从低到高绘制;同深度的普通内容先于定位内容,定位节点按文档顺序绘制。
|
||||
|
||||
上层 Box 的内容、padding 和 border 都遮挡下层,空白也不例外;margin 则保留
|
||||
下层可见性。可见文本保留原生 help、pointer、hover 和 keymap 属性,被覆盖的
|
||||
文本不贡献这些交互。下层 retained 节点及语义 ID 仍然存在;遮挡期间更新后,
|
||||
上层移动或隐藏时会显示下层最新的内容与属性。
|
||||
|
||||
使用根图层可把面板绘制到局部裁剪容器之外,同时保留逻辑父节点和继承样式:
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(box :width (ch 20) :height (lh 4)
|
||||
(box :height (lh 1) :overflow hidden
|
||||
(box :id "trigger" :width (ch 6) "Open")
|
||||
(box :id "panel" :position absolute :layer root
|
||||
:anchor "trigger" :placement bottom-start
|
||||
:width (ch 8) :height (lh 2)
|
||||
"Choice A\nChoice B"))))
|
||||
```
|
||||
|
||||
`:layer root` 选择根内容画布作为绘制目标,不改变 selector、继承或 region 更新
|
||||
所依据的父子关系。`:anchor` 使用绘制宿主逻辑子树中的唯一语义 ID;两者都要求
|
||||
绝对定位。placement 支持 `bottom-start`、`bottom-end`、`top-start`、`top-end`,
|
||||
偏移调整请求的原点。对侧能容纳时,面板纵向翻转,再平移到宿主画布内。anchor
|
||||
缺失或不可见时不绘制面板;ID 歧义及放置依赖循环会报错。
|
||||
|
||||
合成横向使用像素,纵向共享一套文本行网格。定位子树的有效字体行高必须与宿主
|
||||
相同。裁剪保留完整的受支持文本簇,不完整字形或图像以中性空白替代,固定 display
|
||||
space 可以缩短。不提供 alpha 混合、半字形遮罩或 child frame。native reflow
|
||||
无法表示图层时回退到 Elisp。
|
||||
|
||||
包括隐藏节点更新和 rollback 在内的发布仍通过 TP transaction。固定外部尺寸的
|
||||
局部修改可以只重新合成受影响的宿主;几何变化或根图层依赖可能需要扩大范围。
|
||||
不保证每次更新的耗时或所有更新都保持局部。用 `make layer-tests` 运行聚焦契约。
|
||||
独立的 `ebox-playground` 包提供 `layer-minimal.ebox`、`layer-reference.ebox`;
|
||||
batch 测试不证明 GUI 视觉一致性或远程 CI 成功。
|
||||
|
||||
### 换行与可选 EKP 依赖
|
||||
|
||||
`:wrap-mode word` 按普通单词换行,并允许 CJK 字符换行;`char` 按 Ebox 支持的常见
|
||||
文本簇边界换行;`none` 保留显式换行符,不添加软换行。`kp` 将段落交给独立的
|
||||
[EKP 包](https://github.com/Kinneyzhang/emacs-kp),使用 Knuth–Plass 算法排版。
|
||||
换行模式属于 Box,并由其中的文本继承。
|
||||
|
||||
使用 `:wrap-mode kp` 前,安装 EKP 1.0.0 或更高版本,或将其源码目录加入 `load-path`。
|
||||
它是按需加载的可选依赖,普通 Ebox 布局只需要 ECSS 和 TP。选择 `kp` 而没有兼容的
|
||||
EKP 时,会提示安装或更新;请安装 EKP,或显式选择其他换行模式,不会静默改用单词换行。
|
||||
EKP 加速器与 Ebox Rust reflow 模块是两个独立的可选模块,启用一个不会安装另一个。
|
||||
|
||||
```elisp
|
||||
(add-to-list 'load-path "/path/to/ekp")
|
||||
(ebox-render
|
||||
(ebox-build '(box :width (ch 40) :wrap-mode kp :overflow hidden
|
||||
"Knuth–Plass considers the paragraph when choosing line breaks.")))
|
||||
```
|
||||
|
||||
Ebox 的文本簇处理会保留常见组合音标、变体选择符、Emoji 修饰符、ZWJ 序列和区域指示符
|
||||
配对,但不实现完整 Unicode 字素分段或浏览器级双向布局。字形塑造和字形是否可用,
|
||||
仍取决于 Emacs、所选字体和窗口系统。
|
||||
|
||||
<a id="native-node-interaction"></a>
|
||||
### 原生节点交互
|
||||
|
||||
Text 与所有 Box form 都接受 `:help-echo`、`:pointer`、`:hover-style` 和
|
||||
`:keymap`。它们是 CSS cascade 之外的显式节点能力。例如,下面的 Box 组合了
|
||||
动态帮助、手形指针、悬停绘制,以及供鼠标和键盘激活共用的回调:
|
||||
|
||||
```elisp
|
||||
(ebox-render-to-buffer
|
||||
"*Ebox Interaction*"
|
||||
(ebox-build
|
||||
`(box :id "action" :padding ((lh 1) (ch 2))
|
||||
:border ((px 1) solid "#5893A3")
|
||||
:help-echo ,(ebox-help-create
|
||||
(lambda ()
|
||||
(format-time-string "Help requested at %H:%M:%S")))
|
||||
:pointer hand
|
||||
:hover-style (:color "#FFFFFF" :background-color "#286477"
|
||||
:text-decoration-line underline)
|
||||
:keymap ,(ebox-keymap-create :activate #'describe-mode)
|
||||
"Click or press RET / SPC here to describe this buffer's mode.")))
|
||||
```
|
||||
|
||||
`:help-echo` 接受字符串、参数为 `(WINDOW OBJECT POSITION)` 的原生函数或 `nil`。
|
||||
推荐通过 `ebox-help-create` 适配返回字符串或 `nil` 的零参数业务函数,由 Ebox 提供
|
||||
悬停 buffer 的上下文。Emacs 在请求帮助时调用函数,DSL 构造树时不会调用它。
|
||||
`:pointer` 接受 `text`、
|
||||
`arrow`、`vdrag`、`modeline`、`hand`、`hdrag`、`nhdrag`、`hourglass` 或 `nil`。
|
||||
帮助的显示方式和指针的具体外观由用户的 Emacs 设置及窗口系统决定。
|
||||
|
||||
`:hover-style` 接受 `nil`,或仅包含 `:color`、`:background-color`、
|
||||
`:text-decoration-line`、`:text-decoration-color`、`:text-decoration-style` 的
|
||||
Ebox 绘制 plist。同一渲染行中,一个声明节点覆盖的文字与 padding 共享原生
|
||||
`mouse-face`,未指定属性采用该节点的基础样式;被该悬停覆盖的不同色子文本也使用
|
||||
同一悬停基础样式。物理左右边框不参与悬停,水平边框线条保持完整;字体度量与几何
|
||||
不改变。允许的值见
|
||||
[API 参考](ebox-api-reference.zh.md#原生节点能力)。Ebox 将这个 plist 编译为原生
|
||||
`mouse-face`,不提供任意原生 property 透传。
|
||||
|
||||
Box 的帮助、指针和 keymap 覆盖渲染内容、padding 和 border,不包含该 Box 自身的
|
||||
margin 及结构性换行符;悬停遵循上面的规则。嵌套的 Text 或 Box 可通过显式值替换
|
||||
每项能力;显式 `nil` 阻止外层值覆盖,
|
||||
省略属性则保留外层交互范围的覆盖。
|
||||
|
||||
`:keymap` 接受原生 Emacs keymap 或 `nil`。推荐使用 `ebox-keymap-create`:
|
||||
`:activate` 的回调不带参数,处理 `RET`、`[return]`、`SPC` 和 `[mouse-1]`。
|
||||
`:bindings` 接受 alist,按键描述字符串或事件向量对应零参数回调。
|
||||
辅助函数让鼠标回调在事件窗口的 buffer 中运行,因此回调可以直接按语义 ID 更新:
|
||||
`(ebox-region-update "action" :help-echo "Activated")`。
|
||||
|
||||
使用键盘绑定时,先把 point 移到交互范围内。可选命令 `ebox-next-interaction` 与
|
||||
`ebox-previous-interaction` 在当前渲染了可用 keymap 的声明节点之间移动。每个 owner
|
||||
只有一个停靠点,即使其 map 覆盖 padding 或多行;显式子 map 单独形成停靠点。
|
||||
空 map、显式 `nil`、不可见或被裁掉的内容不产生停靠点。导航读取当前已提交 buffer,
|
||||
更新后不需要额外刷新。到达边界时报用户错误,不循环跳转,也不移动 point。
|
||||
这些命令不会自动添加按键绑定。例如,为启用了 `ebox-buffer-mode` 的 buffer 选择启用:
|
||||
|
||||
```elisp
|
||||
(keymap-set ebox-scroll-map "TAB" #'ebox-next-interaction)
|
||||
(keymap-set ebox-scroll-map "<backtab>" #'ebox-previous-interaction)
|
||||
```
|
||||
|
||||
仍可直接传入原生 keymap,其鼠标命令需要自行处理事件的目标 buffer。Ebox 使用原生
|
||||
命令分派,不创建应用状态或焦点管理系统。单独设置手形指针不会绑定点击命令。
|
||||
|
||||
Playground 的[交互实验室](../../../ebox-playground/README.zh-CN.md#native-interaction-lab)
|
||||
把业务函数与状态放在 `interaction-reference.el`,把布局放在
|
||||
`interaction-reference.ebox`,展示回调、嵌套覆盖,以及通过公共 region 更新替换或
|
||||
清除四种能力。
|
||||
|
||||
## 5. 渲染与发布
|
||||
|
||||
`ebox-render` 返回带属性文本,不修改 live buffer;`ebox-render-to-buffer` 挂载
|
||||
@ -127,13 +406,25 @@ retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收
|
||||
(ebox-render-to-buffer "*Ebox Guide*" ebox-guide-input)
|
||||
```
|
||||
|
||||
需要同时显示结果时,使用 `ebox-display-buffer`。它先完成渲染,再让 Emacs 放置
|
||||
buffer,遵循 `display-buffer-alist`,并接受与原生 `display-buffer` 相同的可选 action:
|
||||
|
||||
```elisp
|
||||
(ebox-display-buffer "*Ebox Guide*" ebox-guide-input
|
||||
'(display-buffer-pop-up-window))
|
||||
```
|
||||
|
||||
它返回 buffer,不选中结果窗口,也不删除其他窗口。渲染报错不会改变窗口布局。
|
||||
自定义 display action 仍可能产生自己的窗口效果;如果 action 在发布后报错,
|
||||
已经成功渲染的 buffer 仍然保留。调用方完全管理显示位置时,使用 `ebox-render-to-buffer`。
|
||||
|
||||
重新构建 canonical input 后,用 `ebox-commit` 原子更新:
|
||||
|
||||
```elisp
|
||||
(ebox-commit
|
||||
"*Ebox Guide*"
|
||||
(ebox-build
|
||||
'(column :padding (1 2)
|
||||
'(column :padding ((lh 1) (ch 2))
|
||||
(text "Updated notes")
|
||||
(box :key body "The new input is caller-owned."))))
|
||||
```
|
||||
@ -143,30 +434,57 @@ retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收
|
||||
|
||||
## 6. 查询和更新 mounted surface
|
||||
|
||||
`:id`、`:class` 与 `:key` 是 author metadata。Selector 使用 ECSS 语义。直接更新
|
||||
前,把 `:id` 解析为不透明、surface-scoped 的 handle:
|
||||
`:id`、`:class` 与 `:key` 是 author metadata。Selector 使用 ECSS 语义。
|
||||
在目标已挂载 buffer 中,可以直接传入语义 `:id`:
|
||||
|
||||
```elisp
|
||||
(with-current-buffer "*Ebox Guide*"
|
||||
(ebox-region-update "status" :color "#166534"))
|
||||
```
|
||||
|
||||
需要显式指定 buffer 时,把 `:id` 解析为不透明、surface-scoped 的 handle:
|
||||
|
||||
```elisp
|
||||
(let ((handle (ebox-region-resolve "*Ebox Guide*" "status")))
|
||||
(ebox-region-update handle :color "#166534"))
|
||||
```
|
||||
|
||||
同一 API 可以替换 Text 或 Box region 的原生能力。对上面的交互示例:
|
||||
|
||||
```elisp
|
||||
(with-current-buffer "*Ebox Interaction*"
|
||||
(ebox-region-update "action" :help-echo "Updated help" :pointer 'arrow
|
||||
:hover-style '(:background-color "#F6D6AB"))
|
||||
(ebox-region-update "action" :help-echo nil :pointer nil
|
||||
:hover-style nil :keymap nil))
|
||||
```
|
||||
|
||||
更新参数是正常求值的 Elisp,因此符号和字面量 plist 需要 quote;它们与已经被 quote
|
||||
的 DSL 数据不同。省略的更新属性保持不变,显式 `nil` 清除对应能力并阻止外层值覆盖。
|
||||
Ebox 对 keymap 创建快照;发布新绑定时应替换 `:keymap`,而不是修改原 map。
|
||||
清除节点 keymap 后,普通 buffer 和全局绑定仍然可用。
|
||||
|
||||
`ebox-selector-query-buffer` 按文档顺序返回 mounted buffer 中的匹配;
|
||||
`ebox-selector-update-buffer` 对所有可编辑匹配应用一次样式更新。数字 region id
|
||||
只是诊断用渲染 metadata,不是稳定更新 handle。
|
||||
|
||||
## 7. Resize 与滚动
|
||||
|
||||
有限高度加 `:overflow scroll` 会创建 scroll window。Ebox buffer mode 安装键盘和
|
||||
滚轮命令:先交给最内层 scroll owner,无法继续消费时才回退到普通 Emacs 滚动。
|
||||
内容超过有限高度时,`:overflow scroll` 会创建内部 scroll window。键盘滚动以
|
||||
文本光标(point)为目标;鼠标和触摸板滚动以事件中的鼠标位置为目标。剩余滚动距离
|
||||
沿包含该位置的 scroll owner 从内向外传递,最后交给普通 Emacs 滚动。鼠标位于
|
||||
其他栏、盒子外或 mode line 时,不会选取页面中无关的滚动盒子。
|
||||
|
||||
```elisp
|
||||
(ebox-build
|
||||
'(box :id log :width (420) :height 8 :overflow scroll
|
||||
'(box :id log :width (px 420) :height (lh 8) :overflow scroll
|
||||
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9"))
|
||||
```
|
||||
|
||||
可见 mounted buffer 会跟随其 display window。集成层也可以显式应用 viewport:
|
||||
可见 mounted buffer 会跟随选定的一个显示窗口。每个已挂载 buffer 只有一套视口布局:
|
||||
将同一个 buffer 同时放到不同宽度的窗口中,不会产生两套独立布局。每个视图需要
|
||||
各自宽度时,把同一份 canonical input 分别挂载到不同 buffer。集成层也可以显式应用
|
||||
viewport,横向以像素、纵向以行为单位:
|
||||
|
||||
```elisp
|
||||
(ebox-rerender-buffer-with-context
|
||||
@ -175,17 +493,62 @@ retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收
|
||||
|
||||
## 8. 独立 `.ebox` 文件
|
||||
|
||||
`.ebox` 文件包含一个不加 quote 的结构 form:
|
||||
`.ebox` 文件包含一个普通 Elisp 表达式,其求值结果就是交给 `ebox-build` 的布局
|
||||
数据。静态布局对整个列表加 quote:
|
||||
|
||||
```elisp
|
||||
(column :padding '(1 2)
|
||||
(text :color "#263244" "Title")
|
||||
(box :width '(240) "Body"))
|
||||
'(column :padding ((lh 1) (ch 2))
|
||||
:cross-align center
|
||||
(text :color "#263244" "Title")
|
||||
(box :width (px 240) "Body"))
|
||||
```
|
||||
|
||||
同级 `ebox-playground` 会先求值 property 表达式,再调用 `ebox-build`。因此 `.ebox`
|
||||
文件中的 list 与 symbol 常量要加 quote,如上所示。直接调用 `ebox-build` 时,则像
|
||||
前面的例子一样传入 inert data。
|
||||
同级 `ebox-playground` 读取唯一一个表达式,以词法绑定求值,再将得到的数据传给
|
||||
`ebox-build`,不再单独求值属性或子节点。这与 `.el` 中 `(ebox-build ...)` 的参数
|
||||
表达式完全一致;在 `.ebox` 中只需省略外层的 `ebox-build` 调用。
|
||||
|
||||
必要的业务函数与变量放在布局旁边的同名 `.el` 文件中。预览 `notes.ebox` 时,如果
|
||||
存在 `notes.el`,Playground 会在每次求值布局前加载它;`C-c C-c`、
|
||||
`ebox-playground-open-file` 和文件渲染入口都遵循这个顺序。加载的是指定的 `.el`
|
||||
源码,因此保存修改后再次预览即可生效,不使用 `require` 缓存,也不优先选择旧的
|
||||
`.elc`。没有配套文件时,独立 `.ebox` 仍可使用;配套文件加载出错时,渲染停止,
|
||||
不会继续求值布局。
|
||||
|
||||
例如,由 `notes.el` 定义数据与操作:
|
||||
|
||||
```elisp
|
||||
;;; notes.el --- Notes example behavior -*- lexical-binding: t; -*-
|
||||
(defvar notes-body "A long article.")
|
||||
|
||||
(defun notes-help ()
|
||||
"Return business help for the article."
|
||||
(format "Article: %s" notes-body))
|
||||
|
||||
(defun notes-activate ()
|
||||
"Mark the article in the current Ebox buffer."
|
||||
(ebox-region-update "article" :color "#166534"))
|
||||
```
|
||||
|
||||
`notes.ebox` 专注表达布局:
|
||||
|
||||
```elisp
|
||||
`(column :padding ((lh 1) (ch 2))
|
||||
(box :id "article" :width (px 480)
|
||||
:help-echo ,(ebox-help-create #'notes-help)
|
||||
:pointer hand
|
||||
:keymap ,(ebox-keymap-create :activate #'notes-activate)
|
||||
,notes-body))
|
||||
```
|
||||
|
||||
反引号保留布局数据,`,` 插入值,`,@` 展开子节点列表。不要仅为了隐藏重复 DSL
|
||||
属性列表而添加辅助函数。每次预览先加载配套文件,再求值一次布局;普通 Elisp
|
||||
变量定义决定应用状态是初始化、保留还是重置。
|
||||
|
||||
旧的属性求值格式需要显式迁移:静态布局在最外层加 quote,去掉内部 list 和 symbol
|
||||
属性的 quote;动态布局使用反引号,并在需要求值的位置加逗号。例如,旧的
|
||||
`:padding '(1 2)` 改成引用列表内的 `:padding ((lh 1) (ch 2))`;计算像素宽度时,
|
||||
在反引号列表内写 `:width (px ,(+ 200 40))`。
|
||||
不再自动将裸结构 form 解释为 DSL,也不自动识别旧格式。
|
||||
|
||||
## 9. Typed 集成 API
|
||||
|
||||
@ -226,7 +589,7 @@ declarations 投影出的 node-owned facts。最后封存 builder,把 forest
|
||||
(root
|
||||
(ebox-box-create
|
||||
:layout (ebox-row-layout-create
|
||||
:item-gap 1 :cross-align 'center)
|
||||
:item-gap '(ch 1) :cross-align 'center)
|
||||
:children (list left right)
|
||||
:source-handle root-handle
|
||||
:owned-facts
|
||||
@ -256,6 +619,7 @@ Rust 模块是可选加速器,Ebox 加载时不会构建它:
|
||||
|
||||
```sh
|
||||
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
|
||||
```
|
||||
|
||||
@ -9,10 +9,12 @@
|
||||
(define-error 'ebox-surface-capability-error
|
||||
"Ebox surface capability violation")
|
||||
|
||||
(declare-function ebox-get "ebox" (box property))
|
||||
|
||||
(defun ebox-buffer-validate-border-capability (style)
|
||||
"Return STYLE when its border can be represented exactly by Emacs text."
|
||||
(dolist (side '(top bottom) style)
|
||||
(let ((width (or (plist-get
|
||||
(let ((width (or (ebox-get
|
||||
style (intern (format ":border-%s-pixel" side)))
|
||||
0)))
|
||||
(when (> width 1)
|
||||
@ -162,7 +164,9 @@ Set this to nil to leave `gc-cons-percentage' unchanged.")
|
||||
:gc-cons-percentage percentage))
|
||||
|
||||
(defun ebox--deferred-render-gc-restore ()
|
||||
"Restore GC settings saved by `ebox--with-deferred-render-gc'."
|
||||
"Restore caller GC policy saved by `ebox--with-deferred-render-gc'.
|
||||
The render burst owns temporary settings, not collection of the entire Emacs
|
||||
heap. Normal allocation pressure decides when to collect after restoration."
|
||||
(let ((state ebox--deferred-render-gc-state))
|
||||
(cl-incf ebox--deferred-render-gc-generation)
|
||||
(setq ebox--deferred-render-gc-state nil)
|
||||
@ -172,8 +176,7 @@ Set this to nil to leave `gc-cons-percentage' unchanged.")
|
||||
(setq gc-cons-threshold
|
||||
(plist-get state :gc-cons-threshold))
|
||||
(setq gc-cons-percentage
|
||||
(plist-get state :gc-cons-percentage))
|
||||
(garbage-collect))))
|
||||
(plist-get state :gc-cons-percentage)))))
|
||||
|
||||
(defun ebox--deferred-render-gc-enter ()
|
||||
"Raise GC settings for an interactive render transaction."
|
||||
@ -408,30 +411,56 @@ foreground color."
|
||||
'(:inherit default)
|
||||
(list :foreground foreground))))
|
||||
|
||||
(defun ebox-buffer--color-face (color role)
|
||||
"Return COLOR's canonical face contribution for paint ROLE.
|
||||
Paint slots retain their named-face address. ROLE is `foreground',
|
||||
`background', `overline', or `underline'; nil horizontal border colors
|
||||
retain the ordinary default-color border semantics."
|
||||
(if (tp-paint-slot-p color)
|
||||
(tp-paint-slot-face color)
|
||||
(pcase role
|
||||
('foreground
|
||||
(if (eq color 'ebox/default-foreground)
|
||||
(ebox-buffer--default-foreground-face)
|
||||
(list :foreground color)))
|
||||
('background (list :background color))
|
||||
('overline (list :overline (or color t)))
|
||||
('underline
|
||||
(list :underline (append '(:position t) (and color (list :color color)))))
|
||||
(_ (error "Unknown Ebox color paint role: %S" role)))))
|
||||
|
||||
(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 (ebox-buffer--font-face style)))
|
||||
(let ((face (ebox-buffer--font-face style))
|
||||
named-faces)
|
||||
(when-let* ((decoration (ebox-buffer--text-decoration-face style)))
|
||||
(setq face (append face decoration)))
|
||||
(when-let* ((foreground (ebox-buffer--paint-color style role)))
|
||||
(setq face
|
||||
(cond
|
||||
((tp-paint-slot-p foreground)
|
||||
(let ((slot-face (tp-paint-slot-face foreground)))
|
||||
(if face (list slot-face face) slot-face)))
|
||||
((eq foreground 'ebox/default-foreground)
|
||||
(append (ebox-buffer--default-foreground-face) face))
|
||||
(t (plist-put face :foreground foreground)))))
|
||||
(let ((contribution (ebox-buffer--color-face foreground 'foreground)))
|
||||
(if (symbolp contribution)
|
||||
(push contribution named-faces)
|
||||
(setq face
|
||||
(if (eq foreground 'ebox/default-foreground)
|
||||
(append contribution face)
|
||||
(plist-put face :foreground
|
||||
(plist-get contribution :foreground)))))))
|
||||
(when-let* ((background (ebox-buffer--paint-background-color style role)))
|
||||
(setq face
|
||||
(if (tp-paint-slot-p background)
|
||||
(let ((slot-face (tp-paint-slot-face background)))
|
||||
(if face (list slot-face face) slot-face))
|
||||
(plist-put face :background background))))
|
||||
(when face
|
||||
(list 'face face))))
|
||||
(let ((contribution (ebox-buffer--color-face background 'background)))
|
||||
(if (symbolp contribution)
|
||||
(push contribution named-faces)
|
||||
(setq face (plist-put face :background
|
||||
(plist-get contribution :background))))))
|
||||
;; Keep the anonymous plist writable until every color has been mapped.
|
||||
;; Named faces compose only at the end, as a flat precedence-ordered list.
|
||||
(when (or named-faces face)
|
||||
(list 'face
|
||||
(cond
|
||||
((null named-faces) face)
|
||||
(face (append named-faces (list face)))
|
||||
((cdr named-faces) named-faces)
|
||||
(t (car named-faces)))))))
|
||||
|
||||
(defun ebox--propertize-typography (string style)
|
||||
"Apply STYLE's typography to one copy of STRING."
|
||||
@ -479,9 +508,7 @@ buffer-facing paint properties. Layout-only properties never pass through."
|
||||
(length (length string)))
|
||||
(ebox--add-render-face!
|
||||
string 0 length
|
||||
(if (tp-paint-slot-p bgcolor)
|
||||
(tp-paint-slot-face bgcolor)
|
||||
`(:background ,bgcolor))
|
||||
(ebox-buffer--color-face bgcolor 'background)
|
||||
t)
|
||||
(ebox--register-render-owned-face-values source string)))
|
||||
|
||||
@ -492,11 +519,7 @@ buffer-facing paint properties. Layout-only properties never pass through."
|
||||
(length (length string)))
|
||||
(ebox--add-render-face!
|
||||
string 0 length
|
||||
(cond
|
||||
((tp-paint-slot-p color) (tp-paint-slot-face color))
|
||||
((eq color 'ebox/default-foreground)
|
||||
(ebox-buffer--default-foreground-face))
|
||||
(t `(:foreground ,color)))
|
||||
(ebox-buffer--color-face color 'foreground)
|
||||
t)
|
||||
(ebox--register-render-owned-face-values source string)))
|
||||
|
||||
@ -508,18 +531,12 @@ buffer-facing paint properties. Layout-only properties never pass through."
|
||||
(when color
|
||||
(ebox--add-render-face!
|
||||
string 0 length
|
||||
(cond
|
||||
((tp-paint-slot-p color) (tp-paint-slot-face color))
|
||||
((eq color 'ebox/default-foreground)
|
||||
(ebox-buffer--default-foreground-face))
|
||||
(t `(:foreground ,color)))
|
||||
(ebox-buffer--color-face color 'foreground)
|
||||
t))
|
||||
(when bgcolor
|
||||
(ebox--add-render-face!
|
||||
string 0 length
|
||||
(if (tp-paint-slot-p bgcolor)
|
||||
(tp-paint-slot-face bgcolor)
|
||||
`(:background ,bgcolor))
|
||||
(ebox-buffer--color-face bgcolor 'background)
|
||||
t))
|
||||
(ebox--register-render-owned-face-values source string)))
|
||||
|
||||
@ -529,9 +546,7 @@ buffer-facing paint properties. Layout-only properties never pass through."
|
||||
(string (copy-sequence source)))
|
||||
(ebox--add-render-face!
|
||||
string 0 (length string)
|
||||
(if (tp-paint-slot-p color)
|
||||
(tp-paint-slot-face color)
|
||||
`(:overline ,(or color t)))
|
||||
(ebox-buffer--color-face color 'overline)
|
||||
t)
|
||||
(ebox--register-render-owned-face-values source string)))
|
||||
|
||||
@ -541,9 +556,7 @@ buffer-facing paint properties. Layout-only properties never pass through."
|
||||
(string (copy-sequence source)))
|
||||
(ebox--add-render-face!
|
||||
string 0 (length string)
|
||||
(if (tp-paint-slot-p color)
|
||||
(tp-paint-slot-face color)
|
||||
`(:underline (:position t ,@(when color `(:color ,color)))))
|
||||
(ebox-buffer--color-face color 'underline)
|
||||
t)
|
||||
(ebox--register-render-owned-face-values source string)))
|
||||
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
(require 'ebox-child-range)
|
||||
(require 'ebox-tree)
|
||||
|
||||
(declare-function ebox--flex-validate-item-bases "ebox-flex" (props children))
|
||||
|
||||
(defun ebox-canonical--validate-plist (plist context)
|
||||
"Validate PLIST shape for error CONTEXT and return PLIST."
|
||||
(unless (proper-list-p plist)
|
||||
@ -63,9 +65,6 @@
|
||||
(make-hash-table :test #'eq :weakness 'key)
|
||||
"Declaration-identity keyed validated canonical node facts.")
|
||||
|
||||
(defvar ebox-canonical--source-builder nil
|
||||
"Transaction-local source builder available during typed tree assembly.")
|
||||
|
||||
(cl-defstruct (ebox-canonical-facts
|
||||
(:constructor ebox-canonical--make-facts))
|
||||
"Already computed node-owned projection of one source declaration set."
|
||||
@ -324,10 +323,11 @@ and author TAG."
|
||||
(eq (plist-get property :group) 'paint)))))
|
||||
nil))))
|
||||
|
||||
(defun ebox-canonical-facts-from-declarations (tag declarations)
|
||||
(defun ebox-canonical-facts-from-declarations (tag declarations &optional interactions)
|
||||
"Return immutable node-owned facts for TAG from DECLARATIONS.
|
||||
Source declarations stay behind the source handle; the returned value contains
|
||||
only the current canonical-node projection needed before G4c separates paint."
|
||||
only the current canonical-node projection needed before G4c separates paint.
|
||||
INTERACTIONS is a plist of fixed node capabilities, separate from CSS."
|
||||
(let ((predicate
|
||||
(if (eq tag 'text)
|
||||
#'ebox-canonical--text-engine-property-p
|
||||
@ -338,7 +338,12 @@ only the current canonical-node projection needed before G4c separates paint."
|
||||
predicate
|
||||
(if (eq tag 'text) "Ebox Text" "Ebox Box") tag)))
|
||||
(ebox-canonical--make-facts
|
||||
:engine-properties engine
|
||||
:engine-properties
|
||||
(append engine
|
||||
(when interactions
|
||||
(list :surface-properties
|
||||
(ebox-interaction-surface-properties
|
||||
(ebox-interaction-normalize interactions)))))
|
||||
:style-required-p style-required-p
|
||||
:specified-property-ids
|
||||
(cl-loop for (id _value) on canonical by #'cddr collect id)))))
|
||||
@ -397,8 +402,8 @@ only the current canonical-node projection needed before G4c separates paint."
|
||||
(setq offset after))))
|
||||
(nreverse anchors)))
|
||||
|
||||
(defun ebox-canonical--normalize-children (children)
|
||||
"Return material children and optional Range sequence for CHILDREN."
|
||||
(defun ebox-canonical--normalize-children (children source-builder)
|
||||
"Normalize CHILDREN, using explicit SOURCE-BUILDER for Range ownership."
|
||||
(let (material segments range-p)
|
||||
(dolist (entry children)
|
||||
(if (ebox-child-range--descriptor-p entry)
|
||||
@ -421,19 +426,27 @@ only the current canonical-node projection needed before G4c separates paint."
|
||||
(push entry material)))
|
||||
(setq material (nreverse material)
|
||||
segments (nreverse segments))
|
||||
(when (and range-p (not (ebox-source-builder-p
|
||||
ebox-canonical--source-builder)))
|
||||
(error "Ebox Range children require one canonical source builder"))
|
||||
(let ((source-builder (and range-p ebox-canonical--source-builder)))
|
||||
(when source-builder
|
||||
(ebox-source--builder-assert-open source-builder)
|
||||
(dolist (child material)
|
||||
(unless (ebox-source--builder-record
|
||||
source-builder (ebox-node-source-handle child))
|
||||
(error "Ebox child is not owned by :source-builder"))))
|
||||
(when range-p
|
||||
(unless (ebox-source-builder-p source-builder)
|
||||
(error "Ebox Range children require an explicit :source-builder"))
|
||||
(ebox-source--builder-assert-open source-builder))
|
||||
(let ((source-builder (and range-p source-builder)))
|
||||
(list material
|
||||
(and range-p
|
||||
(ebox-child-range--build
|
||||
segments
|
||||
(lambda (node)
|
||||
(when-let* ((record
|
||||
(ebox-source--builder-record
|
||||
source-builder
|
||||
(ebox-node-source-handle node))))
|
||||
(let ((record
|
||||
(ebox-source--builder-record
|
||||
source-builder (ebox-node-source-handle node))))
|
||||
(unless record
|
||||
(error "Ebox Range child is not owned by :source-builder"))
|
||||
(ebox-source-record-key record)))
|
||||
nil t))))))
|
||||
|
||||
@ -482,14 +495,17 @@ computed Text measurement facts."
|
||||
"Create a canonical BoxNode from evaluated PLIST.
|
||||
|
||||
`:layout' must occur exactly once as an `ebox-layout-config'.
|
||||
`:children' is a list of canonical nodes. Every Layout accepts zero or more
|
||||
children according to its own formatting algorithm. `:source-handle' is the
|
||||
only author source input."
|
||||
`:children' is a list of canonical nodes or transparent Range descriptors.
|
||||
Range children require an open `:source-builder' owning their source handles.
|
||||
Every Layout accepts zero or more children according to its own formatting
|
||||
algorithm. `:source-handle' remains the node's only author source input."
|
||||
(ebox-canonical--validate-plist plist "ebox-box-create")
|
||||
(let* ((layout (ebox-canonical--required-field
|
||||
plist :layout "Ebox Box"))
|
||||
(children-field (ebox-canonical--optional-field
|
||||
plist :children "Ebox Box"))
|
||||
(builder-field (ebox-canonical--optional-field
|
||||
plist :source-builder "Ebox Box"))
|
||||
(outer-field (ebox-canonical--optional-field
|
||||
plist :outer "Ebox Box"))
|
||||
(source-handle (ebox-canonical--source-handle plist "Ebox Box"))
|
||||
@ -499,7 +515,7 @@ only author source input."
|
||||
child-sequence
|
||||
(outer (if outer-field (cdr outer-field) 'block))
|
||||
(props (ebox-canonical--without-keys
|
||||
plist (append '(:layout :children :outer :owned-facts)
|
||||
plist (append '(:layout :children :outer :owned-facts :source-builder)
|
||||
ebox-canonical--source-fields))))
|
||||
(unless (ebox-layout-config-p layout)
|
||||
(error "Ebox Box :layout must be a typed layout config: %S"
|
||||
@ -509,14 +525,19 @@ only author source input."
|
||||
(error "Ebox Box :outer must be inline or block: %S" outer))
|
||||
(unless (proper-list-p children)
|
||||
(error "Ebox Box :children must be a proper list: %S" children))
|
||||
(when builder-field
|
||||
(unless (ebox-source--builder-record (cdr builder-field) source-handle)
|
||||
(error "Ebox Box is not owned by :source-builder")))
|
||||
(pcase-let ((`(,material-children ,normalized-sequence)
|
||||
(ebox-canonical--normalize-children children)))
|
||||
(ebox-canonical--normalize-children children (cdr builder-field))))
|
||||
(setq children material-children)
|
||||
(setq child-sequence normalized-sequence))
|
||||
(unless (memq (ebox-layout-config-kind layout)
|
||||
'(normal row column flex grid))
|
||||
(error "Ebox Box Layout is not implemented: %S"
|
||||
(ebox-layout-config-kind layout)))
|
||||
(when (eq (ebox-layout-config-kind layout) 'flex)
|
||||
(ebox--flex-validate-item-bases (ebox-layout-config-props layout) children))
|
||||
(when props
|
||||
(error "Ebox Box accepts only normalized owned facts"))
|
||||
(when (not (ebox-canonical-facts-p facts))
|
||||
|
||||
@ -280,11 +280,14 @@ each segment so structural copies never recover keys from source metadata."
|
||||
sequence (list (cons ref items)) key-function))
|
||||
|
||||
(defun ebox-child-range--replace-many
|
||||
(sequence replacements key-function &optional reuse-maps)
|
||||
(sequence replacements key-function &optional reuse-maps
|
||||
retain-item-identities-p)
|
||||
"Replace RANGE REPLACEMENTS in SEQUENCE with one two-phase transaction.
|
||||
REUSE-MAPS is an alist from Range ref to proven `(NEW . OLD)' item indexes;
|
||||
those NEW items already name canonical published objects and are not copied.
|
||||
KEY-FUNCTION returns the source-owned key for every replacement item."
|
||||
KEY-FUNCTION returns the source-owned key for every replacement item.
|
||||
When RETAIN-ITEM-IDENTITIES-P is non-nil, non-reused items are already owned by
|
||||
the candidate transaction and are retained without another node copy."
|
||||
(let ((metrics (ebox-child-range--make-metrics))
|
||||
(missing (make-symbol "missing-range-ref"))
|
||||
(seen (make-hash-table :test #'equal)) records)
|
||||
@ -310,7 +313,8 @@ KEY-FUNCTION returns the source-owned key for every replacement item."
|
||||
collect
|
||||
(if (assq offset reuse-map)
|
||||
item
|
||||
(ebox-child-range--item-copy item metrics))))
|
||||
(ebox-child-range--item-copy
|
||||
item metrics retain-item-identities-p))))
|
||||
(vconcat
|
||||
(mapcar (lambda (item)
|
||||
(and key-function (funcall key-function item)))
|
||||
|
||||
216
ebox-composite.el
Normal file
216
ebox-composite.el
Normal file
@ -0,0 +1,216 @@
|
||||
;;; ebox-composite.el --- Pixel interval row composition -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Compose opaque, already rendered rows without changing their source strings.
|
||||
;; Resolve coverage first so every visible interval is cut from its original
|
||||
;; source, independent of the number or ordering of hidden lower placements.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'ebox-measure)
|
||||
|
||||
(declare-function ebox--grapheme-cluster-end "ebox-layout" (string start))
|
||||
|
||||
(defun ebox-composite--line-p (line)
|
||||
"Return non-nil if LINE is a single-line string or nil."
|
||||
(or (null line)
|
||||
(and (stringp line) (not (string-match-p "\n" line)))))
|
||||
|
||||
(defun ebox-composite--replacement-p (display)
|
||||
"Return non-nil when DISPLAY replaces an entire EQ property run."
|
||||
(or (stringp display) (memq (car-safe display) '(space image))))
|
||||
|
||||
(defun ebox-composite--unit-end (line position)
|
||||
"Return the complete display unit end in LINE at POSITION."
|
||||
(let ((end (ebox--grapheme-cluster-end line position)))
|
||||
(if (ebox-composite--replacement-p
|
||||
(get-text-property position 'display line))
|
||||
(max end (next-single-property-change
|
||||
position 'display line (length line)))
|
||||
end)))
|
||||
|
||||
(defun ebox-composite--space (line start end width)
|
||||
"Copy LINE's fixed space at START through END using WIDTH pixels.
|
||||
Retain the source properties and nested display values by identity."
|
||||
(let* ((result (substring line start end))
|
||||
(display (get-text-property start 'display line))
|
||||
(properties (copy-sequence (cdr display))))
|
||||
(put-text-property
|
||||
0 (length result) 'display
|
||||
(cons 'space (plist-put properties :width (list width))) result)
|
||||
result))
|
||||
|
||||
(defun ebox-composite--join (parts)
|
||||
"Join PARTS while retaining their separately measured pixel extents.
|
||||
A neutral zero-pixel display boundary prevents adjacent EQ replacement runs
|
||||
from collapsing or independently shaped source fragments from reshaping."
|
||||
(let ((result "") (width 0))
|
||||
(dolist (part parts result)
|
||||
(unless (equal part "")
|
||||
(let* ((next-width (+ width (ebox--string-pixel-width part)))
|
||||
(joined (concat result part)))
|
||||
(setq result
|
||||
(if (= (ebox--string-pixel-width joined) next-width)
|
||||
joined
|
||||
(concat result
|
||||
(propertize " " 'display '(space :width (0)))
|
||||
part))
|
||||
width next-width))))))
|
||||
|
||||
(defun ebox-composite--fit (line start end left right)
|
||||
"Fit LINE from START through END in its original [LEFT, RIGHT) pixels.
|
||||
Removing a left shaping context can change a retained unit's width. Resolve
|
||||
that unit within its original pixel interval, then retry the remaining whole
|
||||
run. A newly wider glyph becomes neutral fill rather than shifting the next
|
||||
source glyph or borrowing its pixels."
|
||||
(let ((position start) (pixel left) parts)
|
||||
(while (< position end)
|
||||
(let* ((remaining (substring line position end))
|
||||
(width (ebox--string-pixel-width remaining)))
|
||||
(if (= width (- right pixel))
|
||||
(progn (push remaining parts) (setq position end))
|
||||
(let* ((next (ebox-composite--unit-end line position))
|
||||
(next-pixel (ebox--substring-pixel-width line 0 next))
|
||||
(unit (substring line position next))
|
||||
(unit-width (ebox--string-pixel-width unit))
|
||||
(available (- next-pixel pixel)))
|
||||
(cond
|
||||
((<= unit-width available)
|
||||
(push unit parts)
|
||||
(push (ebox-pixel-space (- available unit-width)) parts))
|
||||
((ebox--display-space-width
|
||||
(get-text-property position 'display line))
|
||||
(push (ebox-composite--space line position next available)
|
||||
parts))
|
||||
(t (push (ebox-pixel-space available) parts)))
|
||||
(setq position next pixel next-pixel)))))
|
||||
(ebox-composite--join (nreverse parts))))
|
||||
|
||||
(defun ebox-composite-slice (line start end)
|
||||
"Extract LINE's pixel interval [START, END), exactly END minus START wide.
|
||||
START and END must be finite nonnegative numbers with START <= END. LINE is
|
||||
a single-line string or nil. Pixels beyond LINE are neutral blank space.
|
||||
Keep complete supported graphemes and replacement display runs, preserving
|
||||
their text property values by identity. Fixed pixel spaces can be cut at
|
||||
either edge; other partial glyphs become neutral blanks at their original
|
||||
positions. Measure original prefixes so adjacent shaping is not summed as
|
||||
isolated characters. Neither LINE nor its property values are modified."
|
||||
(unless (ebox-composite--line-p line)
|
||||
(error "Ebox composite needs a single-line string: %S" line))
|
||||
(unless (and (ebox-size--finite-number-p start) (>= start 0)
|
||||
(ebox-size--finite-number-p end) (>= end start))
|
||||
(error "Invalid Ebox composite pixel interval: %S %S" start end))
|
||||
(let* ((line (or line ""))
|
||||
(line-width (ebox--string-pixel-width line)))
|
||||
(cond
|
||||
((= start end) "")
|
||||
((and (= start 0) (<= line-width end))
|
||||
(ebox-composite--join
|
||||
(list line (ebox-pixel-space (- end line-width)))))
|
||||
((>= start line-width) (ebox-pixel-space (- end start)))
|
||||
(t
|
||||
(let ((position 0) (pixel 0) parts
|
||||
run-start run-end run-left run-right)
|
||||
(cl-labels
|
||||
((flush ()
|
||||
(when run-start
|
||||
(push (ebox-composite--fit
|
||||
line run-start run-end run-left run-right)
|
||||
parts)
|
||||
(setq run-start nil))))
|
||||
(while (and (< position (length line)) (< pixel end))
|
||||
(let* ((next (ebox-composite--unit-end line position))
|
||||
(next-pixel (ebox--substring-pixel-width line 0 next))
|
||||
(left (max start pixel))
|
||||
(right (min end next-pixel)))
|
||||
(cond
|
||||
((and (>= pixel start) (<= next-pixel end))
|
||||
(unless run-start
|
||||
(setq run-start position run-left pixel))
|
||||
(setq run-end next run-right next-pixel))
|
||||
((< left right)
|
||||
(flush)
|
||||
(push (if (ebox--display-space-width
|
||||
(get-text-property position 'display line))
|
||||
(ebox-composite--space
|
||||
line position next (- right left))
|
||||
(ebox-pixel-space (- right left)))
|
||||
parts)))
|
||||
(setq position next pixel next-pixel)))
|
||||
(flush))
|
||||
(when (> end (max start pixel))
|
||||
(push (ebox-pixel-space (- end (max start pixel))) parts))
|
||||
(ebox-composite--join (nreverse parts)))))))
|
||||
|
||||
(defun ebox-composite--uncovered (start end covered)
|
||||
"Return portions of [START, END) outside sorted merged COVERED intervals."
|
||||
(let ((cursor start) result)
|
||||
(dolist (interval covered)
|
||||
(when (and (< cursor end) (> (cdr interval) cursor))
|
||||
(when (> (car interval) cursor)
|
||||
(push (cons cursor (min end (car interval))) result))
|
||||
(setq cursor (max cursor (cdr interval)))))
|
||||
(when (< cursor end) (push (cons cursor end) result))
|
||||
(nreverse result)))
|
||||
|
||||
(defun ebox-composite--cover (start end covered)
|
||||
"Return sorted merged COVERED intervals including [START, END).
|
||||
Do not modify the input interval list or its cons cells."
|
||||
(let (result)
|
||||
(dolist (interval covered)
|
||||
(cond
|
||||
((< (cdr interval) start) (push interval result))
|
||||
((> (car interval) end)
|
||||
(push (cons start end) result)
|
||||
(setq start (car interval) end (cdr interval)))
|
||||
(t (setq start (min start (car interval))
|
||||
end (max end (cdr interval))))))
|
||||
(push (cons start end) result)
|
||||
(nreverse result)))
|
||||
|
||||
(defun ebox-composite-line (base placements width)
|
||||
"Compose BASE and opaque PLACEMENTS on a fixed WIDTH-pixel canvas.
|
||||
BASE is a single-line string or nil. PLACEMENTS is a proper list of plists
|
||||
with a single-line string :text and finite pixel :x, already ordered bottom
|
||||
to top. Each placement covers the full measured width of its text, including
|
||||
blanks. Negative and overflowing placements are clipped to the canvas.
|
||||
WIDTH must be finite and nonnegative. Uncovered pixels beyond BASE receive
|
||||
neutral fill. Compute coverage before extracting original source intervals,
|
||||
so hidden placements cannot introduce cuts into a visible source glyph.
|
||||
Do not modify BASE, PLACEMENTS, or any of their text property values."
|
||||
(unless (and (ebox-composite--line-p base)
|
||||
(proper-list-p placements)
|
||||
(ebox-size--finite-number-p width) (>= width 0))
|
||||
(error "Invalid Ebox composite canvas"))
|
||||
(let (sources covered visible)
|
||||
(dolist (placement placements)
|
||||
(let ((text (plist-get placement :text))
|
||||
(x (plist-get placement :x)))
|
||||
(unless (and (stringp text) (ebox-composite--line-p text)
|
||||
(ebox-size--finite-number-p x))
|
||||
(error "Invalid Ebox composite placement: %S" placement))
|
||||
(let* ((right (+ x (ebox--string-pixel-width text)))
|
||||
(left (max 0 x))
|
||||
(end (min width right)))
|
||||
(unless (ebox-size--finite-number-p right)
|
||||
(error "Ebox composite placement extent is not finite"))
|
||||
(when (< left end)
|
||||
(push (list left end text x) sources)))))
|
||||
;; Sources were pushed from bottom to top. Only original source metadata
|
||||
;; participates in occlusion; no sliced text is ever sliced again.
|
||||
(dolist (source (append sources (list (list 0 width base 0))))
|
||||
(pcase-let ((`(,start ,end ,text ,x) source))
|
||||
(dolist (interval (ebox-composite--uncovered start end covered))
|
||||
(push (list (car interval) (cdr interval) text x) visible))
|
||||
(when (< start end)
|
||||
(setq covered (ebox-composite--cover start end covered)))))
|
||||
(ebox-composite--join
|
||||
(mapcar
|
||||
(lambda (interval)
|
||||
(pcase-let ((`(,start ,end ,text ,x) interval))
|
||||
(ebox-composite-slice text (- start x) (- end x))))
|
||||
(sort visible (lambda (a b) (< (car a) (car b))))))))
|
||||
|
||||
(provide 'ebox-composite)
|
||||
;;; ebox-composite.el ends here
|
||||
@ -145,7 +145,11 @@ function."
|
||||
(if outer-field (cdr outer-field) 'block)))
|
||||
(style-properties
|
||||
(ebox-dsl--without-keys
|
||||
properties ebox-dsl--source-fields))
|
||||
properties (append ebox-dsl--source-fields
|
||||
ebox-interaction-properties)))
|
||||
(interactions
|
||||
(ebox-interaction-normalize
|
||||
(ebox-dsl--keep-keys properties ebox-interaction-properties)))
|
||||
(declarations
|
||||
(ebox-dsl--compile-style
|
||||
tag style-properties context)))
|
||||
@ -158,6 +162,7 @@ function."
|
||||
:id (ebox-dsl--source-field properties :id context)
|
||||
:class (ebox-dsl--source-field properties :class context)
|
||||
:declarations declarations
|
||||
:interactions interactions
|
||||
:provenance (list :adapter 'ebox-dsl :tag tag
|
||||
:source-present-p (not (null source))))
|
||||
:outer outer
|
||||
@ -166,7 +171,7 @@ function."
|
||||
tag declarations context)
|
||||
:declarations declarations
|
||||
:owned-facts
|
||||
(ebox-canonical-facts-from-declarations tag declarations)))))
|
||||
(ebox-canonical-facts-from-declarations tag declarations interactions)))))
|
||||
|
||||
(defconst ebox-dsl--tags '(text box row column flex grid)
|
||||
"The complete Ebox author tag set.")
|
||||
|
||||
590
ebox-flex.el
590
ebox-flex.el
@ -11,6 +11,7 @@
|
||||
(require 'subr-x)
|
||||
(require 'ebox-layout-config)
|
||||
(require 'ebox-layout)
|
||||
(require 'ebox-size)
|
||||
|
||||
(declare-function ebox--flex-fragment-allocation-key
|
||||
"ebox-fragment"
|
||||
@ -69,6 +70,8 @@
|
||||
"ebox-buffer-backend" (string property region-id))
|
||||
(declare-function ebox--propertize-content-line
|
||||
"ebox-buffer-backend" (line region-id idx fallback))
|
||||
(declare-function ebox--add-content-owner
|
||||
"ebox-buffer-backend" (string region-id))
|
||||
|
||||
(defvar ebox--propertize-private-content-line-p)
|
||||
(defvar ebox--flex-content-min-width-table)
|
||||
@ -178,85 +181,63 @@ That text is preformatted and must not be sent through the default wrapper."
|
||||
|
||||
(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)))
|
||||
(let ((props (copy-sequence props)))
|
||||
(when wrapper-box
|
||||
(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))))
|
||||
(plist-put props :width (list 'px 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)))
|
||||
(plist-put props :height (list 'lh (ebox--content-height wrapper-box 0)))))
|
||||
(ebox--layout-used-gaps
|
||||
props (ebox--flex-horizontal-value (plist-get props :width))
|
||||
(ebox--flex-line-value (plist-get props :height)) wrapper-box)))
|
||||
|
||||
(defconst ebox--preferred-size-keywords
|
||||
'(auto min-content max-content fit-content stretch contain)
|
||||
'(auto min-content max-content fit-content stretch)
|
||||
"Supported CSS preferred-size keywords.")
|
||||
|
||||
(defconst ebox--min-size-keywords
|
||||
'(auto min-content max-content fit-content stretch contain)
|
||||
'(auto min-content max-content fit-content stretch)
|
||||
"Supported CSS min-size keywords.")
|
||||
|
||||
(defconst ebox--max-size-keywords
|
||||
'(none min-content max-content fit-content stretch contain)
|
||||
'(none min-content max-content fit-content stretch)
|
||||
"Supported CSS max-size keywords.")
|
||||
|
||||
(defun ebox--normalize-horizontal-size-value (value allowed-keywords)
|
||||
"Normalize VALUE as a width-like size.
|
||||
"Validate VALUE as a width-like size without resolving relative units.
|
||||
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))))
|
||||
((ebox-size-value-p value nil t) value)
|
||||
(t (error "ebox: unsupported size %S; use (UNIT NUMBER) or a size keyword"
|
||||
value))))
|
||||
|
||||
(defun ebox--flex-horizontal-value (value &optional default)
|
||||
"Return VALUE as pixels using Ebox horizontal units."
|
||||
"Resolve VALUE as horizontal pixels, or return DEFAULT when unresolved."
|
||||
(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))))
|
||||
((numberp value) value)
|
||||
(t (or (ebox--size-pixels value nil 'width) default))))
|
||||
|
||||
(defun ebox--flex-line-value (value &optional default)
|
||||
"Return VALUE as line count using Ebox vertical units."
|
||||
"Resolve VALUE as whole display lines, using DEFAULT when unresolved."
|
||||
(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))
|
||||
((numberp value) (max 0 (floor value)))
|
||||
((ebox-size-value-p value nil t)
|
||||
(if-let* ((pixels (ebox--size-pixels value nil 'height)))
|
||||
(max 0 (floor (/ pixels (float (ebox--size-line-height nil)))))
|
||||
default))
|
||||
(t default)))
|
||||
|
||||
(defun ebox--flex-main-value (axis value &optional default)
|
||||
@ -274,9 +255,9 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed."
|
||||
(defun ebox--flex-gap-pair (gap)
|
||||
"Return (ROW-GAP . COLUMN-GAP) raw values from GAP shorthand."
|
||||
(cond
|
||||
((and (listp gap)
|
||||
((and (proper-list-p gap)
|
||||
(= (length gap) 2)
|
||||
(not (keywordp (car gap))))
|
||||
(not (ebox-size-value-p gap nil t)))
|
||||
(cons (nth 0 gap) (nth 1 gap)))
|
||||
(t (cons gap gap))))
|
||||
|
||||
@ -288,18 +269,45 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed."
|
||||
resolved))
|
||||
|
||||
(defun ebox--flex-row-gap-value (value)
|
||||
"Return canonical nonnegative line count for FlexConfig row gap VALUE."
|
||||
(unless (or (null value)
|
||||
(numberp value)
|
||||
(and (consp value)
|
||||
(numberp (car value))
|
||||
(null (cdr value))))
|
||||
(error "Ebox FlexConfig :row-gap is invalid: %S" value))
|
||||
(or (ebox--flex-line-value value 0) 0))
|
||||
"Return canonical, unresolved FlexConfig row gap VALUE."
|
||||
(ebox-size-validate-for-axis (or value '(lh 0)) :row-gap 'block))
|
||||
|
||||
(defun ebox--flex-column-gap-value (value)
|
||||
"Return canonical nonnegative pixels for FlexConfig column gap VALUE."
|
||||
(or (ebox--nonnegative-horizontal-size-pixels value 0) 0))
|
||||
"Return canonical, unresolved FlexConfig column gap VALUE."
|
||||
(ebox-size-validate-for-axis (or value '(px 0)) :column-gap 'inline))
|
||||
|
||||
(defun ebox--layout-used-gap (value axis available &optional box)
|
||||
"Resolve gap VALUE along AXIS against AVAILABLE using BOX font metrics.
|
||||
AVAILABLE is pixels for width and display lines for height. Indefinite
|
||||
percentage gaps contribute zero. Numbers are private, already used values."
|
||||
(if (numberp value)
|
||||
value
|
||||
(let* ((line-height (ebox--size-line-height box))
|
||||
(ebox-viewport-width (if (eq axis 'width) available
|
||||
ebox-viewport-width))
|
||||
(ebox--css-containing-height
|
||||
(if (eq axis 'height) (and available (* available line-height))
|
||||
ebox--css-containing-height))
|
||||
(percent-base (if (eq axis 'height)
|
||||
(and available (* available line-height))
|
||||
available))
|
||||
(pixels (or (ebox--size-pixels
|
||||
(or value '(px 0)) box axis (or percent-base 0)) 0)))
|
||||
(max 0 (floor (if (eq axis 'height)
|
||||
(/ pixels (float line-height))
|
||||
pixels))))))
|
||||
|
||||
(defun ebox--layout-used-gaps (props width height &optional box)
|
||||
"Return a copy of PROPS with used gaps for WIDTH, HEIGHT, and BOX.
|
||||
Only the private render configuration contains numeric gap values."
|
||||
(let ((result (copy-sequence props)))
|
||||
(plist-put result :row-gap
|
||||
(ebox--layout-used-gap (plist-get props :row-gap)
|
||||
'height height box))
|
||||
(plist-put result :column-gap
|
||||
(ebox--layout-used-gap (plist-get props :column-gap)
|
||||
'width width box))
|
||||
result))
|
||||
|
||||
(defun ebox--flex-normalize-config-props (props)
|
||||
"Normalize FlexConfig PROPS to explicit layout-owned fields."
|
||||
@ -398,8 +406,8 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed."
|
||||
ebox--flex-align-content-values)
|
||||
(let ((row-gap (plist-get props :row-gap))
|
||||
(column-gap (plist-get props :column-gap)))
|
||||
(and (integerp row-gap) (>= row-gap 0)
|
||||
(numberp column-gap) (>= column-gap 0)))))
|
||||
(and (ebox-size-value-for-axis-p row-gap 'block)
|
||||
(ebox-size-value-for-axis-p column-gap 'inline)))))
|
||||
|
||||
;;;###autoload
|
||||
(defun ebox-flex-layout-create (&rest plist)
|
||||
@ -441,7 +449,7 @@ programmatic port and are therefore rejected here."
|
||||
(let ((flex (plist-get props :flex)))
|
||||
(cond
|
||||
((numberp flex)
|
||||
(setq grow flex shrink 1 basis 0))
|
||||
(setq grow flex shrink 1 basis '(% 0)))
|
||||
((eq flex 'none)
|
||||
(setq grow 0 shrink 0 basis 'auto))
|
||||
((eq flex 'auto)
|
||||
@ -474,20 +482,40 @@ programmatic port and are therefore rejected here."
|
||||
"Return normalized flex item metadata for ITEM."
|
||||
(ebox--flex-apply-shorthand (ebox--flex-participation-props item)))
|
||||
|
||||
(defun ebox--flex-validate-item-bases (props children)
|
||||
"Validate CHILDREN's flex bases against the parent direction in PROPS."
|
||||
(let ((axis (if (eq (ebox--flex-axis props) 'row) 'inline 'block)))
|
||||
(dolist (child children)
|
||||
(let ((basis (plist-get (ebox--flex-item-props child) :flex-basis)))
|
||||
(unless (memq basis '(auto content min-content max-content fit-content stretch))
|
||||
(ebox-size-validate-for-axis basis :flex-basis axis)))))
|
||||
children)
|
||||
|
||||
(defun ebox--flex-string-width (string)
|
||||
"Return max pixel width among STRING lines."
|
||||
(ebox--string-max-pixel-width string))
|
||||
"Return STRING's layout width, excluding separately observed visible ink."
|
||||
(or (ebox--rendered-intrinsic-size string :width)
|
||||
(ebox--string-max-pixel-width string)))
|
||||
|
||||
(defun ebox--flex-source-width (source rendered)
|
||||
"Return SOURCE's layout width when RENDERED has no height to carry it."
|
||||
(if (and (ebox--flex-box-source-p source)
|
||||
(zerop (ebox-string-height rendered)))
|
||||
(let ((ebox--inline-auto-width-intrinsic-p t))
|
||||
(ebox--total-pixel source))
|
||||
(ebox--flex-string-width rendered)))
|
||||
|
||||
(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)))
|
||||
(or (ebox--rendered-intrinsic-size string :height)
|
||||
(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)
|
||||
(or (ebox--rendered-intrinsic-size string :height)
|
||||
(ebox-string-height string))
|
||||
(ebox--flex-string-width string)))
|
||||
|
||||
(defun ebox--flex-inline-viewport (axis main-size cross-size)
|
||||
@ -606,42 +634,41 @@ layout with viewport dependence."
|
||||
|
||||
(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 (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode))
|
||||
;; CSS's automatic minimum is min-content only when the author
|
||||
;; leaves `min-width' at its automatic value. A component that
|
||||
;; deliberately declares `:min-width 0' must be allowed to
|
||||
;; shrink even when its own rows remain no-wrap (the surrounding
|
||||
;; card may then own horizontal overflow).
|
||||
(if (ebox--flex-explicit-min-main-p box axis)
|
||||
(+ (ebox--side-pixel box) declared-min)
|
||||
(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)))))
|
||||
RENDERED is BOX rendered at its natural size. Automatic minima use content
|
||||
only for non-scrollable overflow; explicit minima bypass that constraint."
|
||||
(let* ((horizontal (eq axis 'row))
|
||||
(side (if horizontal (ebox--side-pixel box) (ebox--side-height box)))
|
||||
(minimum (ebox-get box (if horizontal :min-width :min-height)))
|
||||
(declared (if horizontal
|
||||
(ebox--resolve-size-content-pixel box minimum 0)
|
||||
(ebox--resolve-size-content-height box minimum 0)))
|
||||
(automatic (and (not (ebox--flex-explicit-min-main-p box axis))
|
||||
(not (memq (ebox-get box :overflow)
|
||||
'(auto scroll hidden))))))
|
||||
(if (not automatic)
|
||||
(+ side (or declared 0))
|
||||
(let* ((content-min
|
||||
(if (and horizontal
|
||||
(ebox-style-soft-wrap-p (ebox-get box :wrap-mode)))
|
||||
(ebox--content-min-pixel box)
|
||||
(max 0 (- (ebox--flex-string-main rendered axis) side))))
|
||||
(preferred (ebox-get box (if horizontal :width :height)))
|
||||
(definite (and (or (numberp preferred)
|
||||
(ebox-size-value-p preferred nil t))
|
||||
(ebox--flex-box-main-constraint
|
||||
box axis (if horizontal :width :height))))
|
||||
(maximum (ebox--flex-box-main-constraint
|
||||
box axis (if horizontal :max-width :max-height))))
|
||||
(when (and horizontal (hash-table-p ebox--flex-content-min-width-table))
|
||||
(puthash box content-min ebox--flex-content-min-width-table))
|
||||
(max side (min (+ side content-min)
|
||||
(or definite most-positive-fixnum)
|
||||
(or maximum most-positive-fixnum)))))))
|
||||
|
||||
(defun ebox--flex-explicit-min-main-p (box axis)
|
||||
"Return non-nil when BOX declares a main-axis minimum explicitly."
|
||||
(plist-get box
|
||||
(if (eq axis 'row)
|
||||
:ebox-explicit-min-width-p
|
||||
:ebox-explicit-min-height-p)))
|
||||
(not (memq (ebox-get box (if (eq axis 'row) :min-width :min-height))
|
||||
'(nil auto))))
|
||||
|
||||
(defun ebox--flex-box-main-constraint (box axis property)
|
||||
"Return BOX's total main-axis size constraint from PROPERTY."
|
||||
@ -652,8 +679,8 @@ grapheme."
|
||||
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--resolve-size-content-height
|
||||
box (ebox-get box property) nil)))
|
||||
(+ (ebox--side-height box) block-size)))))
|
||||
|
||||
(defun ebox--flex-min-main (source rendered axis)
|
||||
@ -679,20 +706,26 @@ grapheme."
|
||||
"Return SOURCE's flex base size for AXIS from BASIS."
|
||||
(cond
|
||||
((eq basis 'auto)
|
||||
(ebox--flex-string-main rendered axis))
|
||||
(if (eq axis 'row)
|
||||
(ebox--flex-source-width source rendered)
|
||||
(ebox--flex-string-main rendered axis)))
|
||||
((eq basis 'content)
|
||||
(if (ebox--flex-box-source-p source)
|
||||
(ebox--flex-box-content-basis-main source rendered axis)
|
||||
(ebox--flex-string-main rendered axis)))
|
||||
((and (eq axis 'row)
|
||||
(ebox--flex-box-source-p source))
|
||||
((ebox--flex-box-source-p source)
|
||||
(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)))
|
||||
(if (eq axis 'row)
|
||||
(or (ebox--resolve-size-content-pixel source normalized nil)
|
||||
(ebox--content-max-pixel source))
|
||||
(or (ebox--resolve-size-content-height source normalized nil)
|
||||
(max 0 (- (ebox--flex-string-main rendered axis)
|
||||
(ebox--side-height source)))))))
|
||||
(+ (if (eq axis 'row) (ebox--side-pixel source)
|
||||
(ebox--side-height source)) content-size)))
|
||||
(t
|
||||
(ebox--flex-main-value axis basis 0))))
|
||||
|
||||
@ -743,17 +776,40 @@ grapheme."
|
||||
(if horizontal
|
||||
(ebox--side-pixel box)
|
||||
(ebox--side-height box)))))
|
||||
(max (if horizontal 0 1)
|
||||
(- target outer-size))))
|
||||
(max 0 (- target outer-size))))
|
||||
|
||||
(defun ebox--flex-copy-put-size (copy property value)
|
||||
"Set COPY's layout-computed size PROPERTY to VALUE."
|
||||
(plist-put copy property value))
|
||||
|
||||
(defun ebox--flex-freeze-containing-sizes (source copy)
|
||||
"Freeze SOURCE's used sizes in COPY before assigning its item viewport.
|
||||
Percentage edges and constraints refer to the flex container. They must not
|
||||
be recalculated against the smaller viewport assigned to the item itself."
|
||||
(dolist (property '(:padding-left-pixel :padding-right-pixel
|
||||
:padding-top-height :padding-bottom-height
|
||||
:margin-left-pixel :margin-right-pixel
|
||||
:margin-top-height :margin-bottom-height
|
||||
:border-left-pixel :border-right-pixel
|
||||
:border-top-pixel :border-bottom-pixel))
|
||||
(when (consp (plist-get source property))
|
||||
(plist-put copy property (ebox-get source property))))
|
||||
(dolist (property '(:width :min-width :max-width :height :min-height :max-height))
|
||||
(let ((value (plist-get source property)))
|
||||
(when (consp value)
|
||||
(let* ((vertical (memq property '(:height :min-height :max-height)))
|
||||
(pixels (ebox--size-pixels value source (if vertical 'height 'width))))
|
||||
(when pixels
|
||||
(plist-put copy property
|
||||
(if vertical (/ pixels (float (ebox--size-line-height source)))
|
||||
pixels)))))))
|
||||
copy)
|
||||
|
||||
(defun ebox--flex-copy-node-for-size (node axis main cross stretch)
|
||||
"Return NODE or a copy with flex-computed MAIN/CROSS size."
|
||||
(if (ebox--flex-box-source-p node)
|
||||
(let ((copy (copy-sequence node)))
|
||||
(let ((copy (ebox--flex-freeze-containing-sizes
|
||||
node (copy-sequence node))))
|
||||
(ebox--ensure-region-id node)
|
||||
(plist-put copy :region-id (ebox-get node :region-id))
|
||||
(when main
|
||||
@ -803,8 +859,6 @@ The result is nil when SOURCE cannot use the persistent measurement cache;
|
||||
|
||||
(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 &optional probe)
|
||||
@ -828,8 +882,12 @@ PROBE reuses the caller's exact measurement cache lookup when supplied."
|
||||
(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))
|
||||
(rendered-main (if (eq axis 'row)
|
||||
(ebox--flex-source-width source rendered)
|
||||
(ebox--flex-string-main rendered axis)))
|
||||
(rendered-cross (if (eq axis 'column)
|
||||
(ebox--flex-source-width source rendered)
|
||||
(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))
|
||||
@ -853,10 +911,20 @@ PROBE reuses the caller's exact measurement cache lookup when supplied."
|
||||
|
||||
(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)))))
|
||||
(and (ebox-size-value-p basis nil t)
|
||||
(not (cl-intersection (ebox-size-dependencies basis)
|
||||
'(percent viewport-width viewport-height)))))
|
||||
|
||||
(defun ebox--flex-definite-property (box axis value)
|
||||
"Return definite VALUE in AXIS units using BOX font metrics.
|
||||
Context-dependent declarations remain outside the render reuse proof."
|
||||
(cond
|
||||
((numberp value) value)
|
||||
((ebox--flex-fixed-basis-value-p value)
|
||||
(when-let* ((pixels (ebox--size-pixels value box
|
||||
(if (eq axis 'row) 'width 'height))))
|
||||
(if (eq axis 'row) pixels
|
||||
(/ pixels (float (ebox--size-line-height box))))))))
|
||||
|
||||
(defun ebox--flex-unrendered-measurement (source props axis)
|
||||
"Return SOURCE flex metrics that do not require intrinsic rendering.
|
||||
@ -868,9 +936,10 @@ measurement and immediately rendering it again at the computed size."
|
||||
(let ((basis (plist-get props :flex-basis)))
|
||||
(when (and (ebox--flex-box-source-p source)
|
||||
(ebox--flex-fixed-basis-value-p basis)
|
||||
(or (not (eq axis 'row))
|
||||
(ebox-style-soft-wrap-p
|
||||
(ebox-get source :wrap-mode))))
|
||||
(if (eq axis 'row)
|
||||
(ebox-style-soft-wrap-p (ebox-get source :wrap-mode))
|
||||
(or (ebox--flex-explicit-min-main-p source axis)
|
||||
(memq (ebox-get source :overflow) '(auto scroll hidden)))))
|
||||
(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))
|
||||
@ -892,10 +961,21 @@ metrics, and render intrinsically only when layout semantics require it."
|
||||
(ebox--flex-unrendered-measurement source props axis)
|
||||
(ebox--flex-measure-source source props axis viewport probe))))
|
||||
|
||||
(defun ebox--flex-pad-width (string width &optional align)
|
||||
(defun ebox--flex-record-composite-intrinsic-size (rendered parts width height)
|
||||
"Record WIDTH and HEIGHT for empty RENDERED or separate PARTS extents."
|
||||
(if (or (string-empty-p rendered)
|
||||
(cl-some (lambda (part)
|
||||
(or (ebox--rendered-intrinsic-size part :width)
|
||||
(ebox--rendered-intrinsic-size part :height)))
|
||||
parts))
|
||||
(ebox--record-rendered-intrinsic-size rendered width height)
|
||||
rendered))
|
||||
|
||||
(defun ebox--flex-pad-width (string width &optional align owner-id)
|
||||
"Pad every line of STRING to WIDTH pixels."
|
||||
(let ((align (or align 'flex-start)))
|
||||
(ebox--maplines
|
||||
(ebox--flex-record-composite-intrinsic-size
|
||||
(ebox--maplines
|
||||
(lambda (line)
|
||||
(let* ((line-width (ebox--string-pixel-width line))
|
||||
(extra (max 0 (- width line-width)))
|
||||
@ -903,23 +983,34 @@ metrics, and render intrinsically only when layout semantics require it."
|
||||
((or 'flex-end 'end 'right 'self-end) extra)
|
||||
('center (/ extra 2))
|
||||
(_ 0))))
|
||||
(concat (ebox-pixel-space left)
|
||||
line
|
||||
(ebox-pixel-space (- extra left)))))
|
||||
string)))
|
||||
(let ((left-space (ebox-pixel-space left))
|
||||
(right-space (ebox-pixel-space (- extra left))))
|
||||
(when owner-id
|
||||
(setq left-space
|
||||
(ebox--add-content-owner left-space owner-id))
|
||||
(setq right-space
|
||||
(ebox--add-content-owner right-space owner-id)))
|
||||
(concat left-space line right-space))))
|
||||
string)
|
||||
(list string) width (ebox--rendered-intrinsic-size string :height))))
|
||||
|
||||
(defun ebox--flex-pad-height (string height offset width)
|
||||
(defun ebox--flex-pad-height (string height offset width &optional owner-id)
|
||||
"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)))))
|
||||
(blank (ebox-pixel-space width))
|
||||
(blank (if owner-id
|
||||
(ebox--add-content-owner blank owner-id)
|
||||
blank)))
|
||||
(ebox--flex-record-composite-intrinsic-size
|
||||
(ebox-lines-join
|
||||
(append (make-list top blank)
|
||||
lines
|
||||
(make-list bottom blank)))
|
||||
(list string) (ebox--rendered-intrinsic-size string :width) height)))
|
||||
|
||||
(defun ebox--flex-sized-entry (rendered main cross)
|
||||
"Return a sized flex render entry for RENDERED."
|
||||
@ -1009,6 +1100,8 @@ Each item in PARTS is a cons cell (RENDERED . WIDTH)."
|
||||
Return a sized render entry containing the rendered string and dimensions."
|
||||
(let* ((source (plist-get item :source))
|
||||
(source-box-p (ebox--flex-box-source-p source))
|
||||
(allocation-owner
|
||||
(and source-box-p (ebox--ensure-region-id source)))
|
||||
(align (ebox--flex-resolved-align item container-align))
|
||||
(stretch (memq align '(stretch normal)))
|
||||
(render-main (plist-get item :render-main))
|
||||
@ -1046,7 +1139,21 @@ Return a sized render entry containing the rendered string and dimensions."
|
||||
(and natural-context-compatible-p
|
||||
(= cross (ebox--flex-sized-entry-cross natural-entry))
|
||||
(or (not stretch)
|
||||
(not source-box-p))
|
||||
(not source-box-p)
|
||||
;; A definite cross size already equal to the assigned
|
||||
;; Box property leaves its complete geometry unchanged.
|
||||
;; Auto/relative sizes and overflow-expanded output
|
||||
;; still need the final stretched Box render.
|
||||
(let ((declared-cross
|
||||
(ebox--flex-definite-property
|
||||
source (if (eq axis 'row) 'column 'row)
|
||||
(ebox-get source (if (eq axis 'row)
|
||||
:height :width)))))
|
||||
(and (numberp declared-cross)
|
||||
(= declared-cross
|
||||
(ebox--flex-box-main-property
|
||||
source (if (eq axis 'row) 'column 'row)
|
||||
cross)))))
|
||||
natural-entry)))
|
||||
(persistent-signature
|
||||
(and (not local-entry)
|
||||
@ -1143,7 +1250,9 @@ Return a sized render entry containing the rendered string and dimensions."
|
||||
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-width
|
||||
rendered main nil allocation-owner))
|
||||
(setq rendered
|
||||
(ebox--flex-pad-height
|
||||
rendered main 0 (ebox--flex-string-width rendered)))))
|
||||
@ -1158,8 +1267,12 @@ Return a sized render entry containing the rendered string and dimensions."
|
||||
(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))))
|
||||
(setq rendered
|
||||
(ebox--flex-pad-height
|
||||
rendered cross offset width allocation-owner)))
|
||||
(setq rendered
|
||||
(ebox--flex-pad-width
|
||||
rendered cross align allocation-owner))))
|
||||
(let ((entry
|
||||
(ebox--flex-sized-entry
|
||||
rendered
|
||||
@ -1168,12 +1281,12 @@ Return a sized render entry containing the rendered string and dimensions."
|
||||
(or main (if can-use-measured
|
||||
render-main
|
||||
(ebox--flex-string-width rendered)))
|
||||
(or main (ebox-string-height rendered))))
|
||||
(or main (ebox--flex-string-main rendered axis))))
|
||||
(or (and fragment-entry (plist-get fragment-entry :cross))
|
||||
(if (eq axis 'row)
|
||||
(or cross (if can-use-measured
|
||||
render-cross
|
||||
(ebox-string-height rendered)))
|
||||
(ebox--flex-string-cross rendered axis)))
|
||||
(or cross (if can-use-measured
|
||||
render-cross
|
||||
(ebox--flex-string-width rendered))))))))
|
||||
@ -1196,12 +1309,18 @@ Return a sized render entry containing the rendered string and dimensions."
|
||||
(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)))
|
||||
"Distribute AMOUNT across WEIGHTS, retaining fractional free space.
|
||||
Whole amounts retain the established deterministic remainder order."
|
||||
(let* ((amount (max 0 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)
|
||||
(cond
|
||||
((or (<= amount 0) (<= total 0)) (make-list (length weights) 0))
|
||||
((/= amount (floor amount))
|
||||
(mapcar (lambda (weight) (* amount (/ (float (max 0 weight)) total)))
|
||||
weights))
|
||||
(t
|
||||
(setq amount (floor amount))
|
||||
(let* ((shares
|
||||
(mapcar (lambda (weight)
|
||||
(if (> weight 0)
|
||||
@ -1216,7 +1335,7 @@ Return a sized render entry containing the rendered string and dimensions."
|
||||
when (> weight 0)
|
||||
do (cl-incf (car cell))
|
||||
and do (cl-decf remaining)))
|
||||
shares))))
|
||||
shares)))))
|
||||
|
||||
(defun ebox--flex-sum (numbers)
|
||||
"Return the sum of NUMBERS."
|
||||
@ -1444,6 +1563,7 @@ metadata and rendering decisions."
|
||||
(t
|
||||
(dolist (index (or max-violations active-indices))
|
||||
(aset frozen index t)))))))))))))
|
||||
(setq targets (vconcat (ebox-size-quantize (append targets nil))))
|
||||
(cl-loop for item in line
|
||||
for index from 0
|
||||
collect (let ((copy (copy-sequence item)))
|
||||
@ -1488,7 +1608,7 @@ metadata and rendering decisions."
|
||||
(ebox--flex-render-sized-entry
|
||||
item axis (plist-get item :target) nil container-align)))
|
||||
line)))
|
||||
(max 1 (apply #'max (or sizes '(1))))))
|
||||
(apply #'max (or sizes '(0)))))
|
||||
|
||||
(defun ebox--flex-layout-cross (crosses container-cross cross-gap align single-line)
|
||||
"Return plist describing cross-axis line placement."
|
||||
@ -1520,7 +1640,8 @@ metadata and rendering decisions."
|
||||
(leading (nth 0 spacing))
|
||||
(between (nth 1 spacing))
|
||||
(trailing (nth 2 spacing))
|
||||
(parts (list (cons (ebox-pixel-space leading) leading))))
|
||||
(parts (when (> leading 0)
|
||||
(list (cons (ebox--pixel-blank leading line-cross) leading)))))
|
||||
(cl-loop for item in line
|
||||
for tail on line
|
||||
for main = (plist-get item :target)
|
||||
@ -1529,11 +1650,14 @@ metadata and rendering decisions."
|
||||
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 (and (cdr tail) (> between 0))
|
||||
do (push (cons (ebox--pixel-blank between line-cross) between) parts))
|
||||
(when (> trailing 0)
|
||||
(push (cons (ebox-pixel-space trailing) trailing) parts))
|
||||
(ebox--flex-lines-concat-horizontal-sized (nreverse parts) line-cross)))
|
||||
(push (cons (ebox--pixel-blank trailing line-cross) trailing) parts))
|
||||
(setq parts (nreverse parts))
|
||||
(ebox--flex-record-composite-intrinsic-size
|
||||
(ebox--flex-lines-concat-horizontal-sized parts line-cross)
|
||||
(mapcar #'car parts) target-main line-cross)))
|
||||
|
||||
(defun ebox--flex-render-column-line (line line-cross main-size main-gap justify align)
|
||||
"Render one column-axis flex LINE."
|
||||
@ -1554,8 +1678,11 @@ metadata and rendering decisions."
|
||||
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))))
|
||||
(setq pieces (nreverse pieces))
|
||||
(let ((result (apply #'ebox--lines-stack-vertical pieces)))
|
||||
(ebox--flex-record-composite-intrinsic-size
|
||||
(ebox--flex-pad-height result target-main 0 line-cross)
|
||||
pieces line-cross target-main))))
|
||||
|
||||
(defun ebox--render-flex-row (lines props main-size cross-size main-gap cross-gap)
|
||||
"Render flex LINES for a row-axis container."
|
||||
@ -1594,9 +1721,14 @@ metadata and rendering decisions."
|
||||
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))))
|
||||
(ebox--flex-record-composite-intrinsic-size
|
||||
(if cross-size
|
||||
(ebox--flex-pad-height result cross-size 0 container-main)
|
||||
result)
|
||||
rendered-lines container-main
|
||||
(or cross-size (+ leading (ebox--flex-sum adjusted-crosses)
|
||||
(* between (max 0 (1- (length adjusted-crosses))))
|
||||
(plist-get cross-layout :trailing)))))))
|
||||
|
||||
(defun ebox--render-flex-column (lines props main-size cross-size main-gap cross-gap)
|
||||
"Render flex LINES for a column-axis container."
|
||||
@ -1621,16 +1753,29 @@ metadata and rendering decisions."
|
||||
(ebox--flex-render-column-line
|
||||
line cross main-size main-gap justify align))
|
||||
sized adjusted-crosses))
|
||||
(parts (list (ebox-pixel-space leading))))
|
||||
(height (max (or main-size 0)
|
||||
(apply #'max (mapcar #'ebox-string-height
|
||||
(or rendered-columns '(""))))))
|
||||
(parts (when (> leading 0)
|
||||
(list (ebox--pixel-blank leading height)))))
|
||||
(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))
|
||||
when (and (cdr tail) (> between 0))
|
||||
do (push (ebox--pixel-blank between height) parts))
|
||||
(let ((result (apply #'ebox--lines-concat-horizontal (nreverse parts))))
|
||||
(if cross-size
|
||||
(ebox--flex-pad-width result cross-size)
|
||||
result))))
|
||||
(ebox--flex-record-composite-intrinsic-size
|
||||
(if cross-size
|
||||
(ebox--flex-pad-width result cross-size)
|
||||
result)
|
||||
rendered-columns
|
||||
(or cross-size (+ leading (ebox--flex-sum adjusted-crosses)
|
||||
(* between (max 0 (1- (length adjusted-crosses))))
|
||||
(plist-get cross-layout :trailing)))
|
||||
(or main-size
|
||||
(apply #'max (mapcar (lambda (column)
|
||||
(ebox--flex-string-main column 'column))
|
||||
(or rendered-columns '("")))))))))
|
||||
|
||||
(defun ebox--render-flex-box (node rendered)
|
||||
"Render flex NODE's container box around RENDERED, when present."
|
||||
@ -1645,16 +1790,32 @@ metadata and rendering decisions."
|
||||
(puthash region-id box ebox--region-box-table)))
|
||||
rendered))
|
||||
|
||||
(defun ebox--render-flex-children (config constraints children)
|
||||
"Render Flex CONFIG under frame CONSTRAINTS over flat CHILDREN."
|
||||
(let* ((props (append config constraints))
|
||||
(defun ebox--render-flex-children (config constraints children &optional box)
|
||||
"Render Flex CONFIG under resolved CONSTRAINTS over flat CHILDREN.
|
||||
Width is in pixels and height in lines; nil means unbounded, including an
|
||||
intrinsic max-content measurement. Author units and viewport defaults have
|
||||
already been resolved by the Box or legacy adapter. BOX supplies font metrics."
|
||||
(let* ((constraints
|
||||
(list :width (when-let* ((width (plist-get constraints :width)))
|
||||
(floor width))
|
||||
:height (when-let* ((height (plist-get constraints :height)))
|
||||
(floor height))))
|
||||
(props (ebox--layout-used-gaps
|
||||
config (plist-get constraints :width)
|
||||
(plist-get constraints :height) box))
|
||||
(axis (ebox--flex-axis props))
|
||||
(main-size (if (eq axis 'row)
|
||||
(ebox--flex-horizontal-value (plist-get props :width))
|
||||
(ebox--flex-line-value (plist-get props :height))))
|
||||
(plist-get constraints :width)
|
||||
(plist-get constraints :height)))
|
||||
(cross-size (if (eq axis 'row)
|
||||
(ebox--flex-line-value (plist-get props :height))
|
||||
(ebox--flex-horizontal-value (plist-get props :width))))
|
||||
(plist-get constraints :height)
|
||||
(plist-get constraints :width)))
|
||||
;; Nil is an explicit unbounded containing block, not permission for
|
||||
;; a deeper responsive Flex to recover this Box's outer viewport.
|
||||
(ebox-viewport-width (plist-get constraints :width))
|
||||
(ebox--css-containing-height
|
||||
(and (plist-get constraints :height)
|
||||
(* (plist-get constraints :height) (ebox--size-line-height box))))
|
||||
(inline-viewport (ebox--flex-inline-viewport
|
||||
axis main-size cross-size))
|
||||
(items (ebox--flex-collect-items children axis inline-viewport))
|
||||
@ -1662,9 +1823,8 @@ metadata and rendering decisions."
|
||||
(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))
|
||||
;; Only this private used configuration contains numeric gaps.
|
||||
(column-gap (or (plist-get props :column-gap) 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
|
||||
@ -1678,19 +1838,23 @@ metadata and rendering decisions."
|
||||
main-gap cross-gap))))
|
||||
|
||||
(defun ebox--flex-box-constraints (box)
|
||||
"Project BOX content dimensions to private FlexConstraints."
|
||||
(let ((width (ebox--wrapper-content-viewport-pixel box))
|
||||
(height (ebox--resolve-size-content-height
|
||||
box (ebox-get box :height) nil)))
|
||||
(list :width (and width (list width))
|
||||
:height height)))
|
||||
"Project BOX content dimensions to resolved private FlexConstraints."
|
||||
(list :width (ebox--wrapper-content-viewport-pixel box)
|
||||
:height (when-let* ((height (ebox--resolve-size-content-height
|
||||
box (ebox-get box :height) nil)))
|
||||
(floor height))))
|
||||
|
||||
(defun ebox--flex-legacy-constraints (props)
|
||||
"Resolve legacy PROPS' author units and defaults to FlexConstraints."
|
||||
(list :width (ebox--flex-horizontal-value (plist-get props :width))
|
||||
:height (ebox--flex-line-value (plist-get props :height))))
|
||||
|
||||
(defun ebox--render-flex-box-children (box props children)
|
||||
"Render canonical BOX Flex PROPS over flat CHILDREN."
|
||||
(ebox--render-flex-children
|
||||
props
|
||||
(ebox--flex-box-constraints box)
|
||||
children))
|
||||
children box))
|
||||
|
||||
(defun ebox--render-flex (node)
|
||||
"Render legacy flex NODE to a propertized string."
|
||||
@ -1702,7 +1866,7 @@ metadata and rendering decisions."
|
||||
node
|
||||
(ebox--render-flex-children
|
||||
(ebox--plist-remove-keys props '(:width :height))
|
||||
(ebox--plist-keep-keys props '(:width :height))
|
||||
(ebox--flex-legacy-constraints props)
|
||||
(ebox-tree-layout-children node)))))
|
||||
|
||||
(defun ebox--flex-prewarm-sized-item (node _target-width)
|
||||
@ -1735,8 +1899,7 @@ assembles already-rendered items."
|
||||
(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)))
|
||||
(column-gap (or (plist-get props :column-gap) 0)))
|
||||
(setq entry
|
||||
(list :children children
|
||||
:measure-index 0
|
||||
@ -1802,23 +1965,31 @@ assembles already-rendered items."
|
||||
(plist-get node :box)))
|
||||
|
||||
(defun ebox--flex-window-props (node)
|
||||
"Return NODE's resolved Flex properties for lazy row production."
|
||||
"Return NODE's Flex properties with resolved dimensions for lazy rows."
|
||||
(if (ebox--flex-window-canonical-node-p node)
|
||||
(append
|
||||
(ebox-layout-config-props (plist-get node :ebox-layout-config))
|
||||
(ebox--flex-box-constraints node))
|
||||
(ebox--flex-container-content-props
|
||||
(plist-get node :props)
|
||||
(plist-get node :raw-props)
|
||||
(plist-get node :box))))
|
||||
(let ((constraints (ebox--flex-box-constraints node)))
|
||||
(append
|
||||
(ebox--layout-used-gaps
|
||||
(ebox-layout-config-props (plist-get node :ebox-layout-config))
|
||||
(plist-get constraints :width) (plist-get constraints :height) node)
|
||||
constraints))
|
||||
(let ((props (ebox--flex-container-content-props
|
||||
(plist-get node :props)
|
||||
(plist-get node :raw-props)
|
||||
(plist-get node :box))))
|
||||
(append (ebox--plist-remove-keys props '(:width :height))
|
||||
(ebox--flex-legacy-constraints props)))))
|
||||
|
||||
(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))
|
||||
(and (memq (ebox-get box :height) '(nil auto))
|
||||
(or (memq (ebox-get box :min-height) '(nil auto))
|
||||
(equal (ebox-get box :min-height) 0)
|
||||
(and (ebox-size-value-p (ebox-get box :min-height) nil t)
|
||||
(equal (ebox--size-pixels
|
||||
(ebox-get box :min-height) box 'height) 0)))
|
||||
(memq (ebox-get box :max-height) '(nil none))
|
||||
(or (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode))
|
||||
(ebox--flex-window-canonical-node-p box))
|
||||
(eq (ebox-get box :text-align) 'left)
|
||||
@ -2429,15 +2600,11 @@ Return nil when the flex container needs the normal full renderer."
|
||||
(axis (ebox--flex-axis props))
|
||||
(children (ebox-tree-layout-children node))
|
||||
(main-size (if (eq axis 'row)
|
||||
(ebox--flex-horizontal-value
|
||||
(plist-get props :width))
|
||||
(ebox--flex-line-value
|
||||
(plist-get props :height))))
|
||||
(plist-get props :width)
|
||||
(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))))
|
||||
(plist-get props :height)
|
||||
(plist-get props :width)))
|
||||
(wrap (plist-get props :flex-wrap)))
|
||||
(unless (ebox--flex-window-supported-p
|
||||
node props axis main-size cross-size children)
|
||||
@ -2447,9 +2614,7 @@ Return nil when the flex container needs the normal full renderer."
|
||||
(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))
|
||||
(column-gap (or (plist-get props :column-gap) 0))
|
||||
(main-gap column-gap)
|
||||
(cross-gap row-gap)
|
||||
current
|
||||
@ -2471,7 +2636,11 @@ Return nil when the flex container needs the normal full renderer."
|
||||
(when add-gap
|
||||
(push (ebox--pixel-blank main-size cross-gap)
|
||||
pieces))
|
||||
(let* ((line (nreverse current))
|
||||
;; Match the full renderer's content containing block
|
||||
;; only while sizing/rendering children. The wrapper
|
||||
;; below still resolves against its outer viewport.
|
||||
(let* ((ebox-viewport-width main-size)
|
||||
(line (nreverse current))
|
||||
(line-main
|
||||
(+ (cl-reduce
|
||||
#'+ line
|
||||
@ -2514,9 +2683,10 @@ Return nil when the flex container needs the normal full renderer."
|
||||
(catch 'enough
|
||||
(while (< index (length children))
|
||||
(let* ((child (nth index children))
|
||||
(item (or (ebox--flex-window-cheap-item
|
||||
child axis index)
|
||||
(throw 'unsupported nil)))
|
||||
(item (let ((ebox-viewport-width main-size))
|
||||
(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)
|
||||
@ -2580,9 +2750,7 @@ known-row render is used only if the new item footprint is not smaller."
|
||||
(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))
|
||||
(column-gap (or (plist-get props :column-gap) 0))
|
||||
(main-gap column-gap))
|
||||
(when (ebox--flex-default-order-p children)
|
||||
(let* ((items (ebox--flex-collect-items-for-source-ids
|
||||
@ -2636,9 +2804,7 @@ Return nil when NODE's flex configuration needs a full rerender."
|
||||
(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))
|
||||
(column-gap (or (plist-get props :column-gap) 0))
|
||||
(main-gap column-gap)
|
||||
(lines (ebox--flex-break-lines items main-size main-gap wrap)))
|
||||
(when (eq wrap 'wrap-reverse)
|
||||
|
||||
@ -108,7 +108,8 @@
|
||||
(info
|
||||
(and name
|
||||
(ignore-errors
|
||||
(font-info name (and window (window-frame window)))))))
|
||||
(font-info name (and window (window-frame window))))))
|
||||
(zero-width (string-pixel-width (propertize "0" 'face face))))
|
||||
(list
|
||||
:identity
|
||||
(if font
|
||||
@ -124,6 +125,7 @@
|
||||
:metrics
|
||||
(if info
|
||||
(list :pixel-size (aref info 2)
|
||||
:zero-width zero-width
|
||||
:height (aref info 3)
|
||||
:baseline-offset (aref info 4)
|
||||
:max-width (aref info 7)
|
||||
@ -132,6 +134,7 @@
|
||||
:space-width (aref info 10)
|
||||
:average-width (aref info 11))
|
||||
(list :probe-width (string-pixel-width probe)
|
||||
:zero-width zero-width
|
||||
:space-width
|
||||
(string-pixel-width (propertize " " 'face face)))))))
|
||||
|
||||
|
||||
470
ebox-grid.el
470
ebox-grid.el
@ -4,7 +4,7 @@
|
||||
|
||||
;;; Commentary:
|
||||
;; Grid is a two-dimensional layout node. It deliberately reuses Ebox's
|
||||
;; existing pixel/line units, box renderer, and retained tree identity rather
|
||||
;; shared CSS size expressions, box renderer, and retained tree identity rather
|
||||
;; than introducing a second styling or measurement model.
|
||||
|
||||
;;; Code:
|
||||
@ -16,6 +16,8 @@
|
||||
(require 'ebox-layout)
|
||||
(require 'ebox-flex)
|
||||
|
||||
(declare-function ebox--viewport-dependent-subtree-p "ebox-incremental" (node))
|
||||
(declare-function ebox--viewport-height-dependent-subtree-p "ebox-incremental" (node))
|
||||
(declare-function ebox--ensure-node-id "ebox" (node))
|
||||
(declare-function ebox--ensure-region-id "ebox" (box))
|
||||
(declare-function ebox-box-node-p "ebox-canonical" (node))
|
||||
@ -34,6 +36,10 @@
|
||||
(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))
|
||||
(declare-function ebox--record-rendered-intrinsic-size
|
||||
"ebox-layout" (body width height))
|
||||
(declare-function ebox--rendered-intrinsic-size
|
||||
"ebox-layout" (rendered axis))
|
||||
(defvar ebox--region-box-table)
|
||||
|
||||
(defconst ebox--grid-layout-prop-keys
|
||||
@ -84,14 +90,15 @@
|
||||
(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))))
|
||||
(or (memq (car value) '(fr minmax repeat))
|
||||
(ebox-size-value-p value nil t))))
|
||||
|
||||
(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))
|
||||
((symbolp value) (list value))
|
||||
(t value)))
|
||||
|
||||
(defun ebox-grid--expand-repeat (tracks)
|
||||
@ -107,9 +114,7 @@
|
||||
(let ((body
|
||||
(cond
|
||||
((or (ebox-grid--track-form-p value)
|
||||
(numberp value) (symbolp value)
|
||||
(and (consp value) (numberp (car value))
|
||||
(null (cdr value))))
|
||||
(symbolp value))
|
||||
(list value))
|
||||
((proper-list-p value) value)
|
||||
(t (error "ebox-grid: invalid repeat body %S" value)))))
|
||||
@ -118,17 +123,17 @@
|
||||
(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))
|
||||
(defun ebox-grid--normalize-track (value axis &optional property)
|
||||
"Normalize track VALUE for AXIS, reporting errors against PROPERTY."
|
||||
(setq property (or property (if (eq axis 'columns)
|
||||
:grid-template-columns :grid-template-rows)))
|
||||
(cond
|
||||
((eq value 'auto) '(:kind auto :factor 0))
|
||||
((memq value '(min-content max-content))
|
||||
(list :kind value))
|
||||
((and (consp value) (eq (car value) 'fr))
|
||||
(unless (and (numberp (cadr value)) (> (cadr value) 0))
|
||||
(unless (and (proper-list-p value) (= (length value) 2)
|
||||
(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))
|
||||
@ -136,38 +141,24 @@
|
||||
(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)))
|
||||
(let ((minimum (ebox-grid--normalize-track (cadr value) axis property))
|
||||
(maximum (ebox-grid--normalize-track (caddr value) axis property)))
|
||||
(unless (memq (plist-get minimum :kind)
|
||||
'(fixed auto min-content max-content))
|
||||
(error "ebox-grid: minmax minimum must be fixed or auto: %S" value))
|
||||
(unless (memq (plist-get maximum :kind)
|
||||
'(fixed auto min-content max-content fr))
|
||||
(error "ebox-grid: minmax maximum cannot be nested: %S" value))
|
||||
(when (and (eq (plist-get maximum :kind) 'fixed)
|
||||
(eq (plist-get minimum :kind) 'fixed)
|
||||
(> (plist-get minimum :size) (plist-get maximum :size)))
|
||||
(error "ebox-grid: minmax minimum exceeds maximum: %S" value))
|
||||
(list :kind 'minmax :min minimum :max maximum)))
|
||||
((and (eq axis 'columns)
|
||||
(or (numberp value)
|
||||
(and (consp value)
|
||||
(numberp (car value))
|
||||
(null (cdr value)))))
|
||||
(list :kind 'fixed
|
||||
:size (ebox--nonnegative-horizontal-size-pixels value 0)))
|
||||
((and (eq axis 'rows)
|
||||
(or (numberp value)
|
||||
(and (consp value)
|
||||
(numberp (car value))
|
||||
(null (cdr value)))))
|
||||
(let ((lines (ebox--flex-line-value value 0)))
|
||||
(list :kind 'fixed :size lines)))
|
||||
((ebox-size-value-p value nil t)
|
||||
(list :kind 'fixed :size
|
||||
(ebox-size-validate-for-axis
|
||||
value property (if (eq axis 'columns) 'inline 'block))))
|
||||
(t (error "ebox-grid: unsupported %S track: %S" axis value))))
|
||||
|
||||
(defun ebox-grid--normalize-tracks (value axis)
|
||||
"Normalize a track template VALUE along AXIS."
|
||||
(mapcar (lambda (track) (ebox-grid--normalize-track track axis))
|
||||
(defun ebox-grid--normalize-tracks (value axis &optional property)
|
||||
"Normalize track template VALUE along AXIS for optional PROPERTY."
|
||||
(mapcar (lambda (track) (ebox-grid--normalize-track track axis property))
|
||||
(ebox-grid--expand-repeat (ebox-grid--track-list value))))
|
||||
|
||||
(defun ebox-grid--gap-pair (props)
|
||||
@ -180,8 +171,8 @@
|
||||
((plist-member props :column-gap)
|
||||
(plist-get props :column-gap))
|
||||
(t (cdr pair)))))
|
||||
(cons (or (ebox--flex-line-value row 0) 0)
|
||||
(or (ebox--nonnegative-horizontal-size-pixels column 0) 0))))
|
||||
(cons (ebox--flex-row-gap-value row)
|
||||
(ebox--flex-column-gap-value column))))
|
||||
|
||||
(defun ebox-grid--enum-value (value default allowed property)
|
||||
"Return VALUE or DEFAULT after checking ALLOWED values for PROPERTY."
|
||||
@ -192,7 +183,7 @@
|
||||
|
||||
(defun ebox-grid--one-auto-track (value axis property)
|
||||
"Return VALUE normalized as zero or one auto track for AXIS and PROPERTY."
|
||||
(let ((tracks (ebox-grid--normalize-tracks value axis)))
|
||||
(let ((tracks (ebox-grid--normalize-tracks value axis property)))
|
||||
(when (> (length tracks) 1)
|
||||
(error "Ebox GridConfig %S accepts at most one track" property))
|
||||
(car tracks)))
|
||||
@ -246,8 +237,8 @@
|
||||
(ebox-grid--normalize-config-props nil)
|
||||
"Computed default property set for GridConfig.")
|
||||
|
||||
(defun ebox-grid--canonical-atomic-track-p (track allowed-kinds)
|
||||
"Return non-nil when TRACK is canonical and its kind is in ALLOWED-KINDS."
|
||||
(defun ebox-grid--canonical-atomic-track-p (track allowed-kinds &optional axis)
|
||||
"Return non-nil when TRACK has an ALLOWED-KINDS kind and valid AXIS units."
|
||||
(and (proper-list-p track)
|
||||
(memq (plist-get track :kind) allowed-kinds)
|
||||
(pcase (plist-get track :kind)
|
||||
@ -256,7 +247,8 @@
|
||||
('max-content (equal track '(:kind max-content)))
|
||||
('fixed
|
||||
(let ((size (plist-get track :size)))
|
||||
(and (numberp size) (>= size 0)
|
||||
(and (ebox-size-value-for-axis-p
|
||||
size (pcase axis ('columns 'inline) ('rows 'block) (_ 'length)))
|
||||
(equal track (list :kind 'fixed :size size)))))
|
||||
('fr
|
||||
(let ((factor (plist-get track :factor)))
|
||||
@ -264,17 +256,17 @@
|
||||
(equal track (list :kind 'fr :factor factor)))))
|
||||
(_ nil))))
|
||||
|
||||
(defun ebox-grid--canonical-track-p (track)
|
||||
"Return non-nil when TRACK is one canonical top-level track descriptor."
|
||||
(defun ebox-grid--canonical-track-p (track &optional axis)
|
||||
"Return non-nil when TRACK is canonical, including optional AXIS units."
|
||||
(or (ebox-grid--canonical-atomic-track-p
|
||||
track '(fixed auto min-content max-content fr))
|
||||
track '(fixed auto min-content max-content fr) axis)
|
||||
(and (proper-list-p track)
|
||||
(eq (plist-get track :kind) 'minmax)
|
||||
(ebox-grid--canonical-atomic-track-p
|
||||
(plist-get track :min) '(fixed auto min-content max-content))
|
||||
(plist-get track :min) '(fixed auto min-content max-content) axis)
|
||||
(ebox-grid--canonical-atomic-track-p
|
||||
(plist-get track :max)
|
||||
'(fixed auto min-content max-content fr))
|
||||
'(fixed auto min-content max-content fr) axis)
|
||||
(equal track
|
||||
(list :kind 'minmax
|
||||
:min (plist-get track :min)
|
||||
@ -291,21 +283,21 @@
|
||||
cursor (cddr cursor)
|
||||
keys (cdr keys)))
|
||||
(and valid (null cursor)))
|
||||
(cl-every #'ebox-grid--canonical-track-p
|
||||
(cl-every (lambda (track) (ebox-grid--canonical-track-p track 'columns))
|
||||
(plist-get props :grid-template-columns))
|
||||
(cl-every #'ebox-grid--canonical-track-p
|
||||
(cl-every (lambda (track) (ebox-grid--canonical-track-p track 'rows))
|
||||
(plist-get props :grid-template-rows))
|
||||
(let ((auto-column (plist-get props :grid-auto-columns))
|
||||
(auto-row (plist-get props :grid-auto-rows)))
|
||||
(and (or (null auto-column)
|
||||
(ebox-grid--canonical-track-p auto-column))
|
||||
(ebox-grid--canonical-track-p auto-column 'columns))
|
||||
(or (null auto-row)
|
||||
(ebox-grid--canonical-track-p auto-row))))
|
||||
(ebox-grid--canonical-track-p auto-row 'rows))))
|
||||
(memq (plist-get props :grid-auto-flow) '(row column))
|
||||
(let ((row-gap (plist-get props :row-gap))
|
||||
(column-gap (plist-get props :column-gap)))
|
||||
(and (integerp row-gap) (>= row-gap 0)
|
||||
(numberp column-gap) (>= column-gap 0)))
|
||||
(and (ebox-size-value-for-axis-p row-gap 'block)
|
||||
(ebox-size-value-for-axis-p column-gap 'inline)))
|
||||
(memq (plist-get props :justify-items)
|
||||
ebox--grid-justify-items-values)
|
||||
(memq (plist-get props :align-items)
|
||||
@ -424,8 +416,8 @@ grid-gap aliases are rejected at the author schema boundary."
|
||||
(when (> rows (length result))
|
||||
(setq result
|
||||
(vconcat result
|
||||
(make-vector (- rows (length result))
|
||||
(make-vector target-columns nil)))))
|
||||
(ebox-grid--occupancy (- rows (length result))
|
||||
target-columns))))
|
||||
result))
|
||||
|
||||
(defun ebox-grid--free-p (matrix row column row-span column-span)
|
||||
@ -512,7 +504,7 @@ grid-gap aliases are rejected at the author schema boundary."
|
||||
(ebox-grid--mark matrix entry row column row-span column-span)
|
||||
(push entry entries))))
|
||||
(list (nreverse entries) matrix (length matrix)
|
||||
(length (aref matrix 0)))))
|
||||
(if (> (length matrix) 0) (length (aref matrix 0)) columns))))
|
||||
|
||||
(defun ebox-grid--string-min-content-pixel (string)
|
||||
"Return STRING's widest unbreakable run in pixels."
|
||||
@ -524,29 +516,52 @@ grid-gap aliases are rejected at the author schema boundary."
|
||||
"Return ENTRY's intrinsic size from RENDERED for AXIS and MODE."
|
||||
(let* ((node (plist-get entry :node))
|
||||
(string (gethash node rendered)))
|
||||
(if (eq axis 'rows)
|
||||
(ebox-string-height string)
|
||||
(pcase mode
|
||||
('min-content
|
||||
(if (ebox-box-node-p node)
|
||||
(+ (ebox--side-pixel node) (ebox--content-min-pixel node))
|
||||
(ebox-grid--string-min-content-pixel string)))
|
||||
('max-content
|
||||
(if (ebox-box-node-p node)
|
||||
(+ (ebox--side-pixel node) (ebox--content-max-pixel node))
|
||||
(ebox--string-max-pixel-width string)))
|
||||
(_ (ebox--string-pixel-width string))))))
|
||||
(if (eq mode 'auto-min)
|
||||
(let* ((horizontal (eq axis 'columns))
|
||||
(span (plist-get entry (if horizontal :column-span :row-span))))
|
||||
(if (ebox-box-node-p node)
|
||||
(if (and (> (or span 1) 1)
|
||||
(not (ebox--flex-explicit-min-main-p
|
||||
node (if horizontal 'row 'column))))
|
||||
0
|
||||
(/ (ebox--flex-box-min-main
|
||||
node (or string "") (if horizontal 'row 'column))
|
||||
(float (or span 1))))
|
||||
(if horizontal
|
||||
(or (ebox--rendered-intrinsic-size string :width)
|
||||
(ebox-grid--string-min-content-pixel (or string "")))
|
||||
(or (ebox--rendered-intrinsic-size string :height)
|
||||
(ebox-string-height (or string ""))))))
|
||||
(if (eq axis 'rows)
|
||||
(or (ebox--rendered-intrinsic-size string :height)
|
||||
(ebox-string-height string))
|
||||
(pcase mode
|
||||
('min-content
|
||||
(if (ebox-box-node-p node)
|
||||
(+ (ebox--side-pixel node) (ebox--content-min-pixel node))
|
||||
(or (ebox--rendered-intrinsic-size string :width)
|
||||
(ebox-grid--string-min-content-pixel string))))
|
||||
('max-content
|
||||
(if (ebox-box-node-p node)
|
||||
(+ (ebox--side-pixel node) (ebox--content-max-pixel node))
|
||||
(or (ebox--rendered-intrinsic-size string :width)
|
||||
(ebox--string-max-pixel-width string))))
|
||||
(_ (if (and (ebox-box-node-p node)
|
||||
(zerop (ebox-string-height string)))
|
||||
(ebox--flex-source-width node string)
|
||||
(or (ebox--rendered-intrinsic-size string :width)
|
||||
(ebox--string-pixel-width string)))))))))
|
||||
|
||||
(defun ebox-grid--track-intrinsic (track axis entries rendered key slot-index)
|
||||
"Return intrinsic size for TRACK from RENDERED grid ENTRIES."
|
||||
(pcase (plist-get track :kind)
|
||||
('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))
|
||||
(mode (plist-get track :kind))
|
||||
(mode (if (eq (plist-get track :kind) 'fr)
|
||||
'auto-min (plist-get track :kind)))
|
||||
(best 0))
|
||||
(dolist (entry entries best)
|
||||
(when (and (<= (plist-get entry key) position)
|
||||
@ -589,11 +604,50 @@ letting the grid fall back to each child's intrinsic width."
|
||||
(min size maximum)
|
||||
size))
|
||||
|
||||
(defun ebox-grid--used-track (track axis available &optional box)
|
||||
"Resolve TRACK for AXIS and AVAILABLE using BOX's font metrics.
|
||||
An indefinite percentage track participates as auto. Canonical source
|
||||
descriptors remain unresolved, so subsequent layouts can use a new context."
|
||||
(pcase (plist-get track :kind)
|
||||
('fixed
|
||||
(let* ((horizontal (eq axis 'columns))
|
||||
(line-height (ebox--size-line-height box))
|
||||
(ebox-viewport-width (if horizontal available ebox-viewport-width))
|
||||
(ebox--css-containing-height
|
||||
(if horizontal ebox--css-containing-height
|
||||
(and available (* available line-height))))
|
||||
(value (plist-get track :size))
|
||||
(pixels (if (numberp value) value
|
||||
(ebox--size-pixels value box
|
||||
(if horizontal 'width 'height)))))
|
||||
(if pixels
|
||||
(list :kind 'fixed :size
|
||||
(max 0 (if (or horizontal (numberp value)) pixels
|
||||
(/ pixels (float line-height)))))
|
||||
'(:kind auto :factor 0))))
|
||||
('minmax
|
||||
(let ((minimum (ebox-grid--used-track
|
||||
(plist-get track :min) axis available box))
|
||||
(maximum (ebox-grid--used-track
|
||||
(plist-get track :max) axis available box)))
|
||||
;; CSS ignores a maximum below the minimum once both are definite.
|
||||
(when (and (eq (plist-get minimum :kind) 'fixed)
|
||||
(eq (plist-get maximum :kind) 'fixed)
|
||||
(< (plist-get maximum :size) (plist-get minimum :size)))
|
||||
(setq maximum minimum))
|
||||
(list :kind 'minmax :min minimum :max maximum)))
|
||||
(_ track)))
|
||||
|
||||
(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 count axis entries rendered gap available &optional auto-track box)
|
||||
"Resolve TRACKS to COUNT sizes along AXIS within AVAILABLE space.
|
||||
ENTRIES and RENDERED supply intrinsic content, GAP is the used gap, BOX
|
||||
supplies font metrics, and AUTO-TRACK describes implicit tracks."
|
||||
(let* ((auto-track (ebox-grid--used-track
|
||||
(or auto-track '(:kind auto :factor 0)) axis available box))
|
||||
(tracks (mapcar (lambda (track)
|
||||
(ebox-grid--used-track track axis available box))
|
||||
tracks))
|
||||
(tracks (append tracks
|
||||
(make-list (max 0 (- count (length tracks)))
|
||||
auto-track)))
|
||||
@ -606,19 +660,68 @@ letting the grid fall back to each child's intrinsic width."
|
||||
track axis entries rendered
|
||||
(if (eq axis 'columns) :column :row)
|
||||
index))))
|
||||
(fr-total (apply #'+ (mapcar #'ebox-grid--track-flex-factor 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
|
||||
for factor = (ebox-grid--track-flex-factor track)
|
||||
when (> factor 0)
|
||||
do (setf (nth index sizes)
|
||||
(flexible (cl-loop for track in tracks for index from 0
|
||||
when (> (ebox-grid--track-flex-factor track) 0)
|
||||
collect index)))
|
||||
(when (and (null available) flexible)
|
||||
(let ((fraction
|
||||
(cl-loop for index in flexible
|
||||
for factor = (ebox-grid--track-flex-factor (nth index tracks))
|
||||
maximize
|
||||
(/ (float
|
||||
(max (nth index sizes)
|
||||
(floor (* remaining
|
||||
(/ (float factor) fr-total))))))))
|
||||
sizes))
|
||||
(ebox-grid--track-intrinsic
|
||||
'(:kind max-content) axis entries rendered
|
||||
(if (eq axis 'columns) :column :row) index)))
|
||||
(max 1 factor)))))
|
||||
(dolist (index flexible)
|
||||
(setf (nth index sizes)
|
||||
(* fraction (ebox-grid--track-flex-factor (nth index tracks)))))))
|
||||
(when (and available flexible)
|
||||
(let ((remaining
|
||||
(- available (* gap (max 0 (1- count)))
|
||||
(cl-loop for size in sizes for index from 0
|
||||
unless (memq index flexible) sum size))))
|
||||
(while flexible
|
||||
(let* ((factor-total
|
||||
(cl-loop for index in flexible
|
||||
sum (ebox-grid--track-flex-factor (nth index tracks))))
|
||||
(fraction (/ (float (max 0 remaining)) (max 1 factor-total)))
|
||||
(frozen
|
||||
(cl-remove-if-not
|
||||
(lambda (index)
|
||||
(> (nth index sizes)
|
||||
(* fraction (ebox-grid--track-flex-factor
|
||||
(nth index tracks)))))
|
||||
flexible)))
|
||||
(if frozen
|
||||
(dolist (index frozen)
|
||||
(setq remaining (- remaining (nth index sizes))
|
||||
flexible (delq index flexible)))
|
||||
(dolist (index flexible)
|
||||
(setf (nth index sizes)
|
||||
(* fraction (ebox-grid--track-flex-factor (nth index tracks)))))
|
||||
(setq flexible nil))))))
|
||||
(ebox-size-quantize sizes)))
|
||||
|
||||
(defun ebox-grid--content-independent-columns-p (tracks count available &optional children)
|
||||
"Return non-nil when TRACKS determine COUNT widths without cell content.
|
||||
Require a definite AVAILABLE width and explicit fixed/fr tracks. CHILDREN
|
||||
must have zero automatic minima when fractional tracks are present."
|
||||
(and (numberp available) (> available 0)
|
||||
(>= (length tracks) count)
|
||||
(cl-every (lambda (track)
|
||||
(memq (plist-get track :kind) '(fixed fr)))
|
||||
tracks)
|
||||
(or (cl-every (lambda (track) (eq (plist-get track :kind) 'fixed)) tracks)
|
||||
(cl-every
|
||||
(lambda (child)
|
||||
(and (ebox-box-node-p child)
|
||||
(or (and (memq (ebox-get child :min-width) '(nil auto))
|
||||
(memq (ebox-get child :overflow) '(auto scroll hidden)))
|
||||
(equal (ebox-get child :min-width) 0)
|
||||
(equal (ebox-get child :min-width) '(px 0)))))
|
||||
children))))
|
||||
|
||||
(defun ebox-grid--distribute (amount count)
|
||||
"Return COUNT integer shares that sum to AMOUNT."
|
||||
@ -646,6 +749,26 @@ letting the grid fall back to each child's intrinsic width."
|
||||
:trailing (nth 2 spacing)
|
||||
:target target)))))
|
||||
|
||||
(defun ebox-grid--final-content-layout (sizes gap-value axis available alignment box)
|
||||
"Lay out SIZES with GAP-VALUE on AXIS in AVAILABLE space for BOX.
|
||||
ALIGNMENT positions tracks. Cyclic percentage gaps contribute zero during
|
||||
intrinsic sizing, then resolve against that intrinsic size for final layout.
|
||||
The resulting gap may overflow the unchanged intrinsic container extent."
|
||||
(let* ((gap (ebox--layout-used-gap gap-value axis available box))
|
||||
(cyclic (and (null available)
|
||||
(ebox-size-value-p gap-value nil t)
|
||||
(memq 'percent (ebox-size-dependencies gap-value))))
|
||||
(intrinsic (+ (ebox-grid--sum sizes)
|
||||
(* gap (max 0 (1- (length sizes))))))
|
||||
(used-gap (if cyclic
|
||||
(ebox--layout-used-gap gap-value axis intrinsic box)
|
||||
gap))
|
||||
(layout (ebox-grid--content-layout sizes used-gap available alignment)))
|
||||
(when cyclic
|
||||
(plist-put layout :target intrinsic)
|
||||
(plist-put layout :intrinsic-size intrinsic))
|
||||
layout))
|
||||
|
||||
(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)))
|
||||
@ -658,13 +781,13 @@ letting the grid fall back to each child's intrinsic width."
|
||||
(defun ebox-grid--auto-width-node-p (node)
|
||||
"Return non-nil when NODE derives its width from the active viewport."
|
||||
(let ((width (plist-get node :width)))
|
||||
;; `stretch' and `contain' are containing-block widths. Treating them
|
||||
;; `stretch' is a containing-block width. Treating it
|
||||
;; as intrinsic leaves fractional Grid tracks empty and makes composite
|
||||
;; cards hug their natural width instead of filling the assigned track.
|
||||
(or (null width) (memq width '(auto stretch contain)))))
|
||||
(or (null width) (memq width '(auto stretch)))))
|
||||
|
||||
(defun ebox-grid--entry-source (entry rendered width props)
|
||||
"Return ENTRY's content rendered within WIDTH when it is auto-sized.
|
||||
"Return ENTRY's content rendered within its containing WIDTH.
|
||||
|
||||
Stretching is the default grid item alignment. Re-render an auto-sized
|
||||
child at its assigned width for that alignment, while preserving its natural
|
||||
@ -673,10 +796,15 @@ size for start/center/end alignment unless it would overflow its track."
|
||||
(source (gethash node rendered))
|
||||
(auto-width-p (ebox-grid--auto-width-node-p node))
|
||||
(justify (or (plist-get props :justify-items) 'stretch))
|
||||
(stretch-p (memq justify '(normal stretch)))
|
||||
(overflow-p (> (ebox--string-max-pixel-width source) width)))
|
||||
(if (and auto-width-p (or stretch-p overflow-p))
|
||||
(let ((ebox-viewport-width width))
|
||||
(stretch-p (memq justify '(normal stretch))))
|
||||
(unless (or (and auto-width-p stretch-p) (stringp source))
|
||||
(error "ebox-grid: Grid item requires missing intrinsic output"))
|
||||
(if (or (and auto-width-p
|
||||
(or stretch-p (> (ebox--string-max-pixel-width source) width)))
|
||||
(and (not auto-width-p)
|
||||
(ebox--viewport-dependent-subtree-p node)))
|
||||
(let ((ebox-viewport-width width)
|
||||
(ebox--render-root-parent-kind 'grid))
|
||||
(ebox--render-with-cache node))
|
||||
source)))
|
||||
|
||||
@ -692,7 +820,10 @@ size for start/center/end alignment unless it would overflow its track."
|
||||
(defun ebox-grid--align (string width height justify align)
|
||||
"Align STRING to WIDTH and HEIGHT using JUSTIFY and ALIGN."
|
||||
(let ((result (ebox--lines-justify
|
||||
string width
|
||||
(if (or (null string) (string-empty-p string))
|
||||
(ebox-grid--blank width height)
|
||||
string)
|
||||
width
|
||||
(pcase justify
|
||||
((or 'end 'right) 'right)
|
||||
('center 'center)
|
||||
@ -700,17 +831,46 @@ size for start/center/end alignment unless it would overflow its track."
|
||||
(ebox--lines-align-vertical
|
||||
result height
|
||||
(pcase align
|
||||
((or 'end 'bottom) 'bottom)
|
||||
((or 'end 'flex-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."
|
||||
"Size and align ENTRY's width-sized RENDERED output within its rectangle.
|
||||
WIDTHS and HEIGHTS hold track sizes separated by COL-GAP and ROW-GAP.
|
||||
PROPS provides the container's item alignment defaults.
|
||||
An auto-height Box stretches its own geometry and paint to the assigned rows.
|
||||
Its normal Box renderer still owns margins, padding, and min/max constraints."
|
||||
(let* ((width (ebox-grid--entry-size entry widths col-gap))
|
||||
(height (ebox-grid--row-entry-size entry heights row-gap))
|
||||
(source (ebox-grid--entry-source entry rendered width props))
|
||||
(node (plist-get entry :node))
|
||||
(source (gethash node rendered))
|
||||
(justify (or (plist-get props :justify-items) 'stretch))
|
||||
(align (or (plist-get props :align-items) 'stretch)))
|
||||
(self (plist-get node :align-self))
|
||||
(align (if (memq self '(nil auto))
|
||||
(or (plist-get props :align-items) 'stretch)
|
||||
self))
|
||||
(stretch-p (and (memq align '(normal stretch))
|
||||
(ebox-box-node-p node)
|
||||
(memq (ebox-get node :height) '(nil auto)))))
|
||||
(when (or (and stretch-p (/= height (ebox-string-height source)))
|
||||
(ebox--viewport-height-dependent-subtree-p node))
|
||||
(let* ((ebox-viewport-width width)
|
||||
(ebox--css-containing-height
|
||||
(* height (ebox--size-line-height)))
|
||||
(ebox--render-root-parent-kind 'grid)
|
||||
;; Height assignment must not stretch the other axis when its
|
||||
;; already sized output fits the track under start/center/end.
|
||||
(ebox--inline-auto-width-intrinsic-p
|
||||
(and (not (memq justify '(normal stretch)))
|
||||
(< (ebox--string-max-pixel-width source) width)))
|
||||
(sized (if stretch-p (copy-sequence node) node)))
|
||||
(when stretch-p
|
||||
(plist-put sized :region-id (ebox--ensure-region-id node))
|
||||
(plist-put sized :height 'stretch))
|
||||
(setq source (ebox--render-with-cache sized))
|
||||
(when stretch-p
|
||||
(puthash (ebox-get node :region-id) node ebox--region-box-table))))
|
||||
(ebox-grid--align source width height justify align)))
|
||||
|
||||
(defun ebox-grid--sized-rendered-entries (entries rendered widths gap props)
|
||||
@ -731,14 +891,16 @@ size for start/center/end alignment unless it would overflow its track."
|
||||
(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)))))
|
||||
"Return non-nil when ENTRY's slice starts at zero-based ROW and COLUMN."
|
||||
(let ((start (1- (plist-get entry :row))))
|
||||
(and (<= start row)
|
||||
(< row (+ start (plist-get entry :row-span)))
|
||||
(= 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) ""))))
|
||||
(ebox-lines-join (make-list (max 0 height) ""))))
|
||||
|
||||
(defun ebox-grid--slice (string offset height)
|
||||
"Return HEIGHT lines from STRING beginning at OFFSET."
|
||||
@ -765,9 +927,14 @@ size for start/center/end alignment unless it would overflow its track."
|
||||
col-gap row-gap props)
|
||||
strings))
|
||||
(dotimes (row (length heights))
|
||||
(let ((parts nil)
|
||||
(column 0))
|
||||
(while (< column (length widths))
|
||||
(let* ((row-height (nth row heights))
|
||||
(gap-after (if (< row (1- (length heights))) row-gap 0))
|
||||
(height (+ row-height gap-after))
|
||||
(parts nil)
|
||||
(column 0))
|
||||
(when (and (> height 0) (null widths))
|
||||
(push (ebox-grid--blank total-width height) parts))
|
||||
(while (and (> height 0) (< 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))
|
||||
@ -775,15 +942,31 @@ size for start/center/end alignment unless it would overflow its track."
|
||||
(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)
|
||||
(continues-p
|
||||
(> (+ start (plist-get entry :row-span)) (1+ row)))
|
||||
(slice-height (if continues-p height row-height))
|
||||
(piece (and (> slice-height 0)
|
||||
(ebox-grid--slice
|
||||
string offset slice-height))))
|
||||
;; A spanning cell owns the intervening gap lines too.
|
||||
;; Other cells leave that same area blank after clipping
|
||||
;; their output to the allocated row height.
|
||||
(when (and (> gap-after 0) (not continues-p))
|
||||
(let ((gap (ebox-grid--blank
|
||||
(ebox-grid--entry-size entry widths col-gap)
|
||||
gap-after)))
|
||||
(setq piece (if piece
|
||||
(ebox-lines-join (list piece gap))
|
||||
gap))))
|
||||
(push piece parts)
|
||||
(setq column (+ column (plist-get entry :column-span))))
|
||||
(let ((width (nth column widths)))
|
||||
(push (ebox-grid--blank width (nth row heights)) parts)
|
||||
(push (ebox-grid--blank width height) parts)
|
||||
(setq column (1+ column))))
|
||||
(when (< column (length widths))
|
||||
(when (and (< column (length widths)) (> col-gap 0))
|
||||
(push (ebox-pixel-space col-gap) parts))))
|
||||
(setq result
|
||||
(when parts
|
||||
(setq result
|
||||
(append result
|
||||
(list (apply #'ebox--lines-concat-horizontal
|
||||
(append
|
||||
@ -793,11 +976,7 @@ size for start/center/end alignment unless it would overflow its track."
|
||||
(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)))))
|
||||
(plist-get column-layout :trailing))))))))))
|
||||
))
|
||||
(ebox-lines-join
|
||||
(append
|
||||
@ -838,9 +1017,11 @@ size for start/center/end alignment unless it would overflow its track."
|
||||
:height (ebox--resolve-size-content-height
|
||||
box (ebox-get box :height) nil)))
|
||||
|
||||
(defun ebox--render-grid-children (config constraints children)
|
||||
"Render Grid CONFIG under frame CONSTRAINTS over flat CHILDREN."
|
||||
(let* ((props config)
|
||||
(defun ebox--render-grid-children (config constraints children &optional box)
|
||||
"Render Grid CONFIG under CONSTRAINTS over CHILDREN using BOX font metrics."
|
||||
(let* ((props (ebox--layout-used-gaps
|
||||
config (plist-get constraints :width)
|
||||
(plist-get constraints :height) box))
|
||||
(columns (plist-get props :grid-template-columns))
|
||||
(rows (plist-get props :grid-template-rows))
|
||||
(auto-columns (plist-get props :grid-auto-columns))
|
||||
@ -849,48 +1030,69 @@ size for start/center/end alignment unless it would overflow its track."
|
||||
(plist-get props :column-gap)))
|
||||
(flow (or (plist-get props :grid-auto-flow) 'row))
|
||||
(placed (ebox-grid--place-children children
|
||||
(max 1 (length columns))
|
||||
(max 1 (length rows)) flow))
|
||||
(if children (max 1 (length columns))
|
||||
(length columns))
|
||||
(if children (max 1 (length rows))
|
||||
(length rows)) flow))
|
||||
(entries (nth 0 placed))
|
||||
(matrix (nth 1 placed))
|
||||
(row-count (nth 2 placed))
|
||||
(column-count (nth 3 placed))
|
||||
(available (plist-get constraints :width))
|
||||
(height (plist-get constraints :height))
|
||||
(available (when-let* ((width (plist-get constraints :width)))
|
||||
(floor width)))
|
||||
(height (when-let* ((height (plist-get constraints :height)))
|
||||
(floor height)))
|
||||
(ebox--css-containing-height
|
||||
(and height (* height (ebox--size-line-height box))))
|
||||
(content-independent-p
|
||||
(and (ebox-grid--content-independent-columns-p
|
||||
columns column-count available children)
|
||||
(memq (or (plist-get props :justify-items) 'stretch)
|
||||
'(normal stretch))))
|
||||
(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)))
|
||||
(let ((ebox-viewport-width nil)
|
||||
(ebox--intrinsic-layout-measurement t))
|
||||
(puthash child (ebox--render-with-cache child) rendered))))
|
||||
;; Neither column sizing nor unconditional stretch consumes this
|
||||
;; natural output. The final Box render still resolves its own
|
||||
;; min/max constraints before its output is used to size rows.
|
||||
(unless (and content-independent-p
|
||||
(ebox-box-node-p child)
|
||||
(ebox-grid--auto-width-node-p child))
|
||||
(let ((ebox-viewport-width nil)
|
||||
(ebox--intrinsic-layout-measurement t))
|
||||
(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))
|
||||
available auto-columns box))
|
||||
(column-layout
|
||||
(ebox-grid--content-layout
|
||||
widths (cdr gaps) available
|
||||
(or (plist-get props :justify-content) 'start)))
|
||||
(ebox-grid--final-content-layout
|
||||
widths (plist-get config :column-gap) 'width available
|
||||
(or (plist-get props :justify-content) 'start) box))
|
||||
(sized-rendered
|
||||
(ebox-grid--sized-rendered-entries
|
||||
entries rendered (plist-get column-layout :sizes)
|
||||
(plist-get column-layout :between) props))
|
||||
(heights (ebox-grid--resolve-sizes rows row-count 'rows
|
||||
entries sized-rendered (car gaps)
|
||||
height auto-rows))
|
||||
height auto-rows box))
|
||||
(row-layout
|
||||
(ebox-grid--content-layout
|
||||
heights (car gaps) height
|
||||
(or (plist-get props :align-content) 'start)))
|
||||
(ebox-grid--final-content-layout
|
||||
heights (plist-get config :row-gap) 'height height
|
||||
(or (plist-get props :align-content) 'start) box))
|
||||
(body (ebox-grid--render entries matrix column-layout row-layout
|
||||
props sized-rendered)))
|
||||
body)))
|
||||
(ebox--record-rendered-intrinsic-size
|
||||
body (or (plist-get column-layout :intrinsic-size)
|
||||
(and (string-empty-p body)
|
||||
(plist-get column-layout :target)))
|
||||
(plist-get row-layout :intrinsic-size)))))
|
||||
|
||||
(defun ebox--render-grid-box-children (box config children)
|
||||
"Render canonical BOX Grid CONFIG over flat CHILDREN."
|
||||
(ebox--render-grid-children
|
||||
config (ebox-grid--box-constraints box) children))
|
||||
config (ebox-grid--box-constraints box) children box))
|
||||
|
||||
(defun ebox--render-grid (node)
|
||||
"Render legacy GRID NODE to a propertized string."
|
||||
|
||||
1732
ebox-incremental.el
1732
ebox-incremental.el
File diff suppressed because it is too large
Load Diff
588
ebox-interaction.el
Normal file
588
ebox-interaction.el
Normal file
@ -0,0 +1,588 @@
|
||||
;;; ebox-interaction.el --- Fixed native node capabilities -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Validates Ebox's finite interaction inputs and projects them to native text
|
||||
;; properties. These node facts do not participate in CSS cascade or geometry.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'tp-core)
|
||||
(require 'tp-style)
|
||||
|
||||
(declare-function ebox-style-compile-form "ebox-style" (tag plist))
|
||||
(declare-function ebox-style-declaration-properties "ebox-style"
|
||||
(declarations predicate))
|
||||
(declare-function ebox-buffer--text-decoration-face "ebox-buffer-backend" (style))
|
||||
(declare-function ebox-buffer-paint-text-properties "ebox-buffer-backend"
|
||||
(style role))
|
||||
(declare-function ebox-get "ebox" (box property))
|
||||
(declare-function ebox--ensure-region-id "ebox" (box))
|
||||
(declare-function ebox-runtime-index-get "ebox-runtime-index" (key index &optional default))
|
||||
(declare-function ebox-fragment-style-source-node "ebox-fragment" (node))
|
||||
(declare-function ebox-surface-buffer-mounted-p "ebox-surface" (buffer))
|
||||
(declare-function ebox-surface--buffer-client-state "ebox-surface" (buffer))
|
||||
(declare-function ebox-surface--paint-node-chain "ebox-surface" (state role-ids))
|
||||
(defvar ebox-region-types)
|
||||
(defvar ebox-buffer--decoration-style-map)
|
||||
|
||||
(defconst ebox-interaction-properties
|
||||
'(:help-echo :pointer :hover-style :keymap)
|
||||
"Fixed node capabilities accepted outside the Ebox style schema.")
|
||||
|
||||
(defconst ebox-interaction--hover-properties
|
||||
'(:color :background-color :text-decoration-line
|
||||
:text-decoration-color :text-decoration-style)
|
||||
"Geometry-independent Ebox declarations accepted by `:hover-style'.")
|
||||
|
||||
(defun ebox-interaction--command (callback)
|
||||
"Wrap zero-argument CALLBACK as a keyboard or mouse command."
|
||||
(lambda (&optional event)
|
||||
(interactive (list (and (mouse-event-p last-input-event) last-input-event)))
|
||||
(if (null event)
|
||||
(funcall callback)
|
||||
(unless (and (consp event) (mouse-event-p event))
|
||||
(user-error "Ebox activation requires a mouse text event"))
|
||||
(let* ((position (event-start event))
|
||||
(window (posn-window position))
|
||||
(point (posn-point position)))
|
||||
(unless (and (window-live-p window) (null (posn-area position))
|
||||
(integer-or-marker-p point))
|
||||
(user-error "Ebox activation requires a live text window"))
|
||||
(with-current-buffer (window-buffer window)
|
||||
(unless (and (or (integerp point)
|
||||
(eq (marker-buffer point) (current-buffer)))
|
||||
(<= (point-min) point (point-max)))
|
||||
(user-error "Ebox activation position is outside its text buffer"))
|
||||
(funcall callback))))))
|
||||
|
||||
;;;###autoload
|
||||
(defun ebox-keymap-create (&rest options)
|
||||
"Create an ordinary native keymap from OPTIONS.
|
||||
`:activate' is an optional zero-argument callback for RET, return, SPC and
|
||||
the primary mouse button. Nil omits these activation bindings. `:bindings'
|
||||
is an alist of string key descriptions or key vectors paired with
|
||||
zero-argument callbacks.
|
||||
Callbacks are not invoked until a generated command runs. Keyboard commands
|
||||
use the current buffer; mouse commands use the event window's buffer without
|
||||
selecting a window or moving point. Duplicate or overlapping keys are errors.
|
||||
This function does not supply focus navigation, state or event propagation."
|
||||
(unless (and (proper-list-p options) (zerop (% (length options) 2)))
|
||||
(error "Ebox keymap options must be an even plist"))
|
||||
(let ((map (make-sparse-keymap)) seen keys)
|
||||
(cl-loop for (key _value) on options by #'cddr do
|
||||
(unless (memq key '(:activate :bindings))
|
||||
(error "Unknown Ebox keymap option: %S" key))
|
||||
(when (memq key seen)
|
||||
(error "Repeated Ebox keymap option: %S" key))
|
||||
(push key seen))
|
||||
(let ((activate (plist-get options :activate))
|
||||
(bindings (plist-get options :bindings)))
|
||||
(unless (proper-list-p bindings)
|
||||
(error "Ebox keymap :bindings must be an alist"))
|
||||
(when activate
|
||||
(setq bindings (append (mapcar (lambda (key) (cons key activate))
|
||||
'("RET" [return] "SPC" [mouse-1]))
|
||||
bindings)))
|
||||
(dolist (entry bindings)
|
||||
(unless (and (consp entry) (functionp (cdr entry)))
|
||||
(error "Ebox keymap bindings require a key and a callback"))
|
||||
(let ((key (car entry)) (callback (cdr entry)))
|
||||
(unless (or (stringp key) (vectorp key))
|
||||
(error "Ebox keymap keys must be strings or vectors"))
|
||||
(unless (or (autoloadp (indirect-function callback))
|
||||
(zerop (car (func-arity callback))))
|
||||
(error "Ebox keymap callbacks must accept zero arguments"))
|
||||
(setq key (vconcat (kbd (if (stringp key) key (key-description key)))))
|
||||
(when (zerop (length key))
|
||||
(error "Ebox keymap keys must not be empty"))
|
||||
(when (cl-some (lambda (other)
|
||||
(let ((length (min (length key) (length other))))
|
||||
(equal (cl-subseq key 0 length)
|
||||
(cl-subseq other 0 length))))
|
||||
keys)
|
||||
(error "Ebox keymap binding overlaps an existing key: %s"
|
||||
(key-description key)))
|
||||
(define-key map key (ebox-interaction--command callback))
|
||||
(push key keys))))
|
||||
map))
|
||||
|
||||
(defun ebox-interaction--navigable-keymap-p (map)
|
||||
"Return non-nil when MAP contains a command, including inherited bindings.
|
||||
Inspect prefix and menu maps without invoking menu filters or enable forms."
|
||||
(when (keymapp map)
|
||||
(let ((copy (ebox-interaction--keymap-snapshot map))
|
||||
(seen (make-hash-table :test #'eq)) commands)
|
||||
(cl-labels
|
||||
((binding
|
||||
(value)
|
||||
(cond
|
||||
((keymapp value) (visit value) value)
|
||||
((eq (car-safe value) 'menu-item) (binding (nth 2 value)))
|
||||
((and (consp value) (stringp (car value))) (binding (cdr value)))
|
||||
(t (when (and (not (eq value 'undefined)) (commandp value))
|
||||
(cl-pushnew value commands :test #'eq))
|
||||
value)))
|
||||
(visit
|
||||
(value)
|
||||
(unless (gethash value seen)
|
||||
(puthash value t seen)
|
||||
(let ((parent (keymap-parent value))
|
||||
(local (copy-sequence value)))
|
||||
(set-keymap-parent local nil)
|
||||
(map-keymap (lambda (event definition)
|
||||
(define-key value (vector event) (binding definition)))
|
||||
local)
|
||||
(when parent (visit parent))))))
|
||||
;; Native lookup composes inherited prefix maps instead of treating
|
||||
;; a local prefix as shadowing its parent's entire prefix. Remove
|
||||
;; menu wrappers only in this detached copy before using that lookup.
|
||||
(visit copy)
|
||||
(cl-some (lambda (command)
|
||||
(where-is-internal command (list copy) 'non-ascii nil t))
|
||||
commands)))))
|
||||
|
||||
(defun ebox-interaction--navigation-owner (position state)
|
||||
"Resolve POSITION's live native keymap to its declaring node in STATE.
|
||||
Rendered region ownership and the retained parent index identify inheritance;
|
||||
an explicit nil on a descendant blocks an ancestor's declaration."
|
||||
(when-let* ((map (get-text-property position 'keymap))
|
||||
((keymapp map)))
|
||||
(let ((chain (ebox-surface--paint-node-chain
|
||||
state (cl-loop for (role . property) in ebox-region-types
|
||||
for region = (get-text-property position property)
|
||||
when region collect (cons role region)))))
|
||||
(catch 'owner
|
||||
(dolist (node-id chain)
|
||||
(let* ((node (ebox-runtime-index-get node-id (plist-get state :node-table)))
|
||||
(source (ebox-fragment-style-source-node node))
|
||||
(entry (plist-member (ebox-get source :surface-properties) 'keymap)))
|
||||
(when entry
|
||||
(throw 'owner
|
||||
(and (cadr entry)
|
||||
(ebox-interaction-surface-equal-p
|
||||
(list 'keymap map) (list 'keymap (cadr entry)))
|
||||
node-id)))))))))
|
||||
|
||||
(defun ebox-interaction--move (count)
|
||||
"Move by COUNT interaction stops in the accessible live Ebox surface.
|
||||
Resolve every stop from current rendered properties; keep no navigation state."
|
||||
(unless (integerp count)
|
||||
(signal 'wrong-type-argument (list 'integerp count)))
|
||||
(unless (ebox-surface-buffer-mounted-p (current-buffer))
|
||||
(user-error "Current buffer has no live Ebox surface"))
|
||||
(if (zerop count)
|
||||
(point)
|
||||
(let* ((state (ebox-surface--buffer-client-state (current-buffer)))
|
||||
(origin (point))
|
||||
(current-owner (and (< origin (point-max))
|
||||
(ebox-interaction--navigation-owner origin state)))
|
||||
(seen (make-hash-table :test #'equal))
|
||||
(maps (make-hash-table :test #'eq))
|
||||
(position (point-min)) stops)
|
||||
(while (< position (point-max))
|
||||
(let* ((end (next-char-property-change position (point-max)))
|
||||
(map (get-text-property position 'keymap))
|
||||
(owner (and map (ebox-interaction--navigation-owner position state))))
|
||||
;; Structural line breaks carry no activation target. A property
|
||||
;; run can begin at one after narrowing or native publication.
|
||||
(while (and (< position end) (eq (char-after position) ?\n))
|
||||
(setq position (1+ position)))
|
||||
(when (and owner (< position end) (not (invisible-p position))
|
||||
(not (equal (get-text-property position 'display) ""))
|
||||
(not (gethash owner seen)))
|
||||
(let ((navigable (gethash map maps 'unknown)))
|
||||
(when (eq navigable 'unknown)
|
||||
(setq navigable (ebox-interaction--navigable-keymap-p map))
|
||||
(puthash map navigable maps))
|
||||
(when navigable
|
||||
(puthash owner t seen)
|
||||
(push (cons position owner) stops))))
|
||||
(setq position end)))
|
||||
(when (> count 0) (setq stops (nreverse stops)))
|
||||
(let* ((eligible
|
||||
(cl-remove-if-not
|
||||
(lambda (stop)
|
||||
(and (not (equal (cdr stop) current-owner))
|
||||
(if (> count 0) (> (car stop) origin)
|
||||
(< (car stop) origin))))
|
||||
stops))
|
||||
(target (nth (1- (abs count)) eligible)))
|
||||
(unless target
|
||||
(user-error "No %s Ebox interaction" (if (> count 0) "next" "previous")))
|
||||
(goto-char (car target))))))
|
||||
|
||||
;;;###autoload
|
||||
(defun ebox-next-interaction (&optional count)
|
||||
"Move point to the next rendered Ebox keymap owner; return its position.
|
||||
With COUNT, move that many stops; a negative count moves backward, zero stays.
|
||||
Each declaring node is one stop, at its first accessible rendered character,
|
||||
even across multiple lines or inherited child content. Explicit child maps
|
||||
are separate stops; nil and maps without commands are skipped. From within
|
||||
an owner, skip that owner and seek the next stop after point.
|
||||
Respect narrowing and invisible text. Use the current committed surface, so
|
||||
updates, removal and scrolling need no navigation refresh. At a boundary,
|
||||
signal `user-error' without moving; do not wrap or bind keys automatically.
|
||||
Ordinary buffer, overlay and global keymaps do not create stops. Menu enable
|
||||
forms and filters are not evaluated; a declared command remains a stop even
|
||||
when an application dynamically disables its menu item."
|
||||
(interactive "p")
|
||||
(ebox-interaction--move (or count 1)))
|
||||
|
||||
;;;###autoload
|
||||
(defun ebox-previous-interaction (&optional count)
|
||||
"Move point to the previous rendered Ebox keymap owner; return its position.
|
||||
COUNT defaults to one. Negative COUNT moves forward. See
|
||||
`ebox-next-interaction' for ownership, narrowing and boundary behavior."
|
||||
(interactive "p")
|
||||
(ebox-interaction--move (- (or count 1))))
|
||||
|
||||
;;;###autoload
|
||||
(defun ebox-help-create (function)
|
||||
"Adapt zero-argument FUNCTION to a native `help-echo' callback.
|
||||
The help object's buffer, or the live window buffer for a displayed string,
|
||||
becomes current while FUNCTION runs. Neither point nor window selection is
|
||||
changed. FUNCTION runs only when Emacs requests help and must return a string
|
||||
or nil. Existing native three-argument callbacks need no adapter."
|
||||
(unless (and (functionp function)
|
||||
(or (autoloadp (indirect-function function))
|
||||
(zerop (car (func-arity function)))))
|
||||
(error "Ebox help requires a zero-argument function"))
|
||||
(lambda (window object _position)
|
||||
(let ((buffer (cond ((bufferp object) object)
|
||||
((overlayp object) (overlay-buffer object))
|
||||
((and (stringp object) (window-live-p window))
|
||||
(window-buffer window)))))
|
||||
(unless (buffer-live-p buffer)
|
||||
(user-error "Ebox help requires a live text buffer"))
|
||||
(with-current-buffer buffer
|
||||
(let ((value (funcall function)))
|
||||
(unless (or (null value) (stringp value))
|
||||
(error "Ebox help function must return a string or nil"))
|
||||
value)))))
|
||||
|
||||
(defun ebox-interaction--keymap-snapshot (keymap)
|
||||
"Copy native KEYMAP, resolving named root, prefix and parent maps.
|
||||
Only native map positions are resolved; ordinary command symbols and literal
|
||||
callback environments retain their identities. Sharing and cycles survive."
|
||||
(let ((copies (make-hash-table :test #'eq)))
|
||||
(cl-labels
|
||||
((binding
|
||||
(value)
|
||||
(cond
|
||||
((keymapp value) (map-copy value))
|
||||
((functionp value) value)
|
||||
((and (consp value) (eq (car value) 'menu-item))
|
||||
(let ((copy (tp-property-value-copy value)))
|
||||
(when (cddr copy)
|
||||
(setcar (cddr copy) (binding (nth 2 value))))
|
||||
copy))
|
||||
((and (consp value) (stringp (car value)))
|
||||
(cons (tp-property-value-copy (car value)) (binding (cdr value))))
|
||||
((and (consp value) (keymapp (car value)))
|
||||
(cons (map-copy (car value)) (tp-property-value-copy (cdr value))))
|
||||
(t (tp-property-value-copy value))))
|
||||
(map-copy
|
||||
(source)
|
||||
(setq source (if (symbolp source) (indirect-function source) source))
|
||||
(or (gethash source copies)
|
||||
(let* ((parent (keymap-parent source))
|
||||
(local (copy-sequence source))
|
||||
(copy (tp-property-value-copy source)))
|
||||
(puthash source copy copies)
|
||||
;; Only the copied spine is edited, leaving source bindings
|
||||
;; and character tables untouched while enumerating locals.
|
||||
;; Replacing existing entries also preserves native menu order.
|
||||
(set-keymap-parent local nil)
|
||||
(map-keymap
|
||||
(lambda (event definition)
|
||||
(define-key copy (vector event) (binding definition)))
|
||||
local)
|
||||
(when parent (set-keymap-parent copy (map-copy parent)))
|
||||
copy))))
|
||||
(map-copy keymap))))
|
||||
|
||||
(defun ebox-interaction--hover-face (value)
|
||||
"Validate hover declarations VALUE and return a native mouse face."
|
||||
(unless (and (proper-list-p value) (zerop (% (length value) 2)))
|
||||
(error "Ebox :hover-style must be an even declaration plist"))
|
||||
(cl-loop for (key val) on value by #'cddr
|
||||
unless (memq key ebox-interaction--hover-properties)
|
||||
do (error "Ebox :hover-style does not support %S" key)
|
||||
when (memq key '(:color :background-color :text-decoration-color))
|
||||
unless (or (stringp val)
|
||||
(and (eq key :text-decoration-color)
|
||||
(eq val 'currentColor)))
|
||||
do (error "Ebox :hover-style %S requires a color string" key))
|
||||
(when value
|
||||
(require 'ebox-style)
|
||||
(require 'ebox-buffer-backend)
|
||||
(let* ((style (ebox-style-declaration-properties
|
||||
(ebox-style-compile-form 'text value) (lambda (_property) t)))
|
||||
(face (ebox-buffer--text-decoration-face style)))
|
||||
;; A solid underline is represented by t in a face, not an empty plist.
|
||||
(when (and (plist-member face :underline)
|
||||
(null (plist-get face :underline)))
|
||||
(setq face (plist-put face :underline t)))
|
||||
(when (plist-member style :color)
|
||||
(setq face (plist-put face :foreground (plist-get style :color))))
|
||||
(when (plist-member style :background-color)
|
||||
(setq face (plist-put face :background
|
||||
(plist-get style :background-color))))
|
||||
(when (eq (plist-get style :text-decoration-line) 'none)
|
||||
(setq face (append face '(:underline nil :overline nil
|
||||
:strike-through nil))))
|
||||
face)))
|
||||
|
||||
(defun ebox-interaction-normalize (properties)
|
||||
"Return an owned, validated copy of node interaction PROPERTIES.
|
||||
Absent keys inherit enclosing surface values; explicit nil blocks them.
|
||||
Callback values remain literal functions. Mutable keymaps are snapshotted;
|
||||
replace the property to publish subsequent binding changes."
|
||||
(unless (and (proper-list-p properties)
|
||||
(zerop (% (length properties) 2)))
|
||||
(error "Ebox interaction properties must be an even plist"))
|
||||
(let (seen)
|
||||
(cl-loop for (key value) on properties by #'cddr do
|
||||
(when (memq key seen)
|
||||
(error "Ebox interaction property repeated: %S" key))
|
||||
(push key seen)
|
||||
(pcase key
|
||||
(:help-echo
|
||||
(unless (or (null value) (stringp value) (functionp value))
|
||||
(error "Ebox :help-echo requires nil, a string or a function")))
|
||||
(:pointer
|
||||
(unless (memq value '(nil text arrow vdrag modeline hand
|
||||
hdrag nhdrag hourglass))
|
||||
(error "Ebox :pointer has unsupported shape: %S" value)))
|
||||
(:keymap
|
||||
(unless (or (null value) (keymapp value))
|
||||
(error "Ebox :keymap requires nil or a native keymap")))
|
||||
(:hover-style (ebox-interaction--hover-face value))
|
||||
(_ (error "Ebox has no interaction property %S" key)))))
|
||||
(let ((owned (tp-property-value-copy properties)))
|
||||
(when (plist-get properties :keymap)
|
||||
(setq owned (plist-put owned :keymap
|
||||
(ebox-interaction--keymap-snapshot
|
||||
(plist-get properties :keymap)))))
|
||||
owned))
|
||||
|
||||
(defun ebox-interaction-surface-properties (properties)
|
||||
"Project normalized node PROPERTIES to native surface facts."
|
||||
(cl-loop for (key value) on properties by #'cddr
|
||||
append (if (eq key :hover-style)
|
||||
(let* ((face (ebox-interaction--hover-face value))
|
||||
(decoration
|
||||
(cl-loop for (property entry) on value by #'cddr
|
||||
when (memq property
|
||||
'(:text-decoration-line
|
||||
:text-decoration-color
|
||||
:text-decoration-style))
|
||||
append (list property entry)))
|
||||
(paint
|
||||
(cl-loop for (property entry) on face by #'cddr
|
||||
unless (memq property
|
||||
'(:underline :overline
|
||||
:strike-through))
|
||||
append (list property entry))))
|
||||
(list 'ebox--hover-style
|
||||
(when value (list :face paint :decoration decoration))
|
||||
'mouse-face face))
|
||||
(list (pcase key
|
||||
(:help-echo 'help-echo) (:pointer 'pointer)
|
||||
(:keymap 'keymap)) value))))
|
||||
|
||||
(defun ebox-interaction--face-attribute (face attribute)
|
||||
"Return FACE's explicit ATTRIBUTE entry, following native face precedence."
|
||||
(cond
|
||||
((null face) nil)
|
||||
((and (consp face) (keywordp (car face)))
|
||||
(if-let* ((entry (plist-member face attribute)))
|
||||
(unless (eq (cadr entry) 'unspecified) (list attribute (cadr entry)))
|
||||
(ebox-interaction--face-attribute (plist-get face :inherit) attribute)))
|
||||
((listp face)
|
||||
(cl-some (lambda (entry) (ebox-interaction--face-attribute entry attribute))
|
||||
face))
|
||||
((facep face)
|
||||
(let ((value (face-attribute face attribute nil t)))
|
||||
(unless (eq value 'unspecified) (list attribute value))))))
|
||||
|
||||
(defun ebox-interaction--hover-decoration-face (declarations base)
|
||||
"Apply partial decoration DECLARATIONS to the effective BASE face."
|
||||
(when declarations
|
||||
(let* ((line-entry (plist-member declarations :text-decoration-line))
|
||||
(line (cadr line-entry))
|
||||
(lines (if (listp line) line (list line)))
|
||||
(color-entry (plist-member declarations :text-decoration-color))
|
||||
(color (cadr color-entry))
|
||||
(style-entry (plist-member declarations :text-decoration-style))
|
||||
(style (alist-get (cadr style-entry) ebox-buffer--decoration-style-map))
|
||||
face)
|
||||
(dolist (entry '((underline . :underline) (overline . :overline)
|
||||
(line-through . :strike-through)))
|
||||
(let* ((property (cdr entry))
|
||||
(old (cadr (ebox-interaction--face-attribute base property)))
|
||||
(active (if line-entry (memq (car entry) lines) old)))
|
||||
(when (or line-entry active)
|
||||
(setq face
|
||||
(plist-put
|
||||
face property
|
||||
(when active
|
||||
(if (eq property :underline)
|
||||
(let ((value (cond ((stringp old) (list :color old))
|
||||
((consp old) (copy-sequence old)))))
|
||||
(when color-entry
|
||||
(if (eq color 'currentColor)
|
||||
(cl-remf value :color)
|
||||
(setq value (plist-put value :color color))))
|
||||
(when style-entry
|
||||
(if (eq style 'line)
|
||||
(cl-remf value :style)
|
||||
(setq value (plist-put value :style style))))
|
||||
(or value t))
|
||||
(if color-entry
|
||||
(if (eq color 'currentColor) t color)
|
||||
(or old t)))))))))
|
||||
face)))
|
||||
|
||||
(defvar ebox-interaction--hover-face-cache
|
||||
(make-hash-table :test #'equal :weakness 'value)
|
||||
"Weak cache preserving one native face identity per hover owner and paint.")
|
||||
|
||||
(defun ebox-interaction--node-surface-properties (node)
|
||||
"Return NODE's native surface facts with its computed hover base.
|
||||
Native mouse highlighting paints one face across a contiguous region. Its
|
||||
unspecified attributes therefore use the hover owner's style, even when its
|
||||
children have different ordinary text colors. Explicit child hover remains
|
||||
an independent region. Physical border paint is handled during projection."
|
||||
(let* ((properties (ebox-get node :surface-properties))
|
||||
(declaration (plist-get properties 'ebox--hover-style)))
|
||||
(if (null declaration)
|
||||
properties
|
||||
(plist-put
|
||||
(copy-sequence properties) 'ebox--hover-style
|
||||
(append declaration
|
||||
(list :owner (ebox--ensure-region-id node)
|
||||
:base (plist-get
|
||||
(ebox-buffer-paint-text-properties node 'content)
|
||||
'face)))))))
|
||||
|
||||
(defun ebox-interaction--hover-owner-base (declaration state)
|
||||
"Return DECLARATION's current owner face, resolving it in STATE when supplied."
|
||||
(let* ((owner (plist-get declaration :owner))
|
||||
(node-id (and state owner
|
||||
(ebox-runtime-index-get
|
||||
owner (plist-get state :region-node-table))))
|
||||
(node (and node-id
|
||||
(ebox-runtime-index-get node-id (plist-get state :node-table))))
|
||||
(box (and node (ebox-fragment-style-source-node node))))
|
||||
(if box
|
||||
(plist-get (ebox-buffer-paint-text-properties box 'content) 'face)
|
||||
(plist-get declaration :base))))
|
||||
|
||||
(defun ebox-interaction--refresh-hover! (string start end &optional face state)
|
||||
"Project owned hover paint in STRING between START and END.
|
||||
FACE supplies a fallback for legacy declarations without an owner. STATE
|
||||
resolves current owner paint during retained updates. All interior runs with
|
||||
the same owner and paint share the exact same native mouse face, including
|
||||
padding. Border glyphs are excluded; horizontal strokes keep their own paint."
|
||||
(let ((position start))
|
||||
(while (< position end)
|
||||
(setq position (or (text-property-not-all
|
||||
position end 'ebox--hover-style nil string) end))
|
||||
(when (< position end)
|
||||
(let* ((next (next-property-change position string end))
|
||||
(declaration (get-text-property position 'ebox--hover-style string))
|
||||
(normal (or face (get-text-property position 'face string)))
|
||||
(base (if (plist-member declaration :owner)
|
||||
(ebox-interaction--hover-owner-base declaration state)
|
||||
normal))
|
||||
(hover (append
|
||||
(ebox-interaction--hover-decoration-face
|
||||
(plist-get declaration :decoration) base)
|
||||
(plist-get declaration :face)))
|
||||
(faces (cond ((null base) nil)
|
||||
((and (consp base) (keywordp (car base))) (list base))
|
||||
((listp base) base)
|
||||
(t (list base))))
|
||||
(side (or (get-text-property position 'ebox-bl string)
|
||||
(get-text-property position 'ebox-br string)))
|
||||
(strokes
|
||||
(append
|
||||
(when (get-text-property position 'ebox-bt string)
|
||||
(ebox-interaction--face-attribute normal :overline))
|
||||
(when (get-text-property position 'ebox-bb string)
|
||||
(ebox-interaction--face-attribute normal :underline))))
|
||||
(combined (append (when strokes (list strokes))
|
||||
(when hover (list hover)) faces))
|
||||
(value (if (cdr combined) combined (car combined)))
|
||||
(key (list (plist-get declaration :owner) value))
|
||||
(shared (or (gethash key ebox-interaction--hover-face-cache)
|
||||
(when value
|
||||
(puthash key value ebox-interaction--hover-face-cache)))))
|
||||
(when (and state (plist-member declaration :owner)
|
||||
(not (equal base (plist-get declaration :base))))
|
||||
(put-text-property
|
||||
position next 'ebox--hover-style
|
||||
(plist-put (copy-sequence declaration) :base base) string))
|
||||
(put-text-property position next 'mouse-face
|
||||
(unless side shared) string)
|
||||
(setq position next)))))
|
||||
string)
|
||||
|
||||
(defun ebox-interaction-surface-equal-p (left right)
|
||||
"Compare native surface plists LEFT and RIGHT without collapsing commands.
|
||||
Literal callbacks retain identity; keymap snapshots use TP's native property
|
||||
policy, which compares bindings while retaining command identity. Paint and
|
||||
data values use native value equality, before surface publication loads."
|
||||
(or (eq left right)
|
||||
(and (= (length left) (length right))
|
||||
(cl-loop for (property value) on left by #'cddr
|
||||
for entry = (plist-member right property)
|
||||
always (and entry
|
||||
(cond
|
||||
((or (keymapp value) (keymapp (cadr entry)))
|
||||
(funcall
|
||||
(tp-property-policy-equality
|
||||
(tp-register-text-property property))
|
||||
value (cadr entry)))
|
||||
((or (functionp value) (functionp (cadr entry)))
|
||||
(eq value (cadr entry)))
|
||||
(t (equal-including-properties
|
||||
value (cadr entry)))))))))
|
||||
|
||||
(defun ebox-interaction--fill-property (string start end property value)
|
||||
"Fill surface PROPERTY with VALUE in STRING between START and END.
|
||||
Newlines are structural. An inner explicit nil blocks enclosing values of
|
||||
the four supported native properties, just as an inner non-nil value does."
|
||||
(let ((position start)
|
||||
(presence-p (memq property '(help-echo pointer mouse-face keymap
|
||||
ebox--hover-style))))
|
||||
(when (or value presence-p)
|
||||
(while (< position end)
|
||||
(let ((next (min (or (next-property-change position string end) end)
|
||||
(or (string-match "\n" string position) end))))
|
||||
(if (= position next)
|
||||
(setq position (1+ position))
|
||||
(unless (if presence-p
|
||||
(let ((props (text-properties-at position string)))
|
||||
(or (plist-member props property)
|
||||
(and (eq property 'ebox--hover-style)
|
||||
(plist-member props 'mouse-face))))
|
||||
(get-text-property position property string))
|
||||
(add-text-properties position next (list property value) string))
|
||||
(when (eq property 'keymap)
|
||||
;; Key lookup otherwise consults the preceding character at
|
||||
;; the start of a non-front-sticky text-property interval.
|
||||
(dolist (sticky '(front-sticky rear-nonsticky))
|
||||
(let ((existing (get-text-property position sticky string)))
|
||||
(unless (eq existing t)
|
||||
(put-text-property
|
||||
position next sticky
|
||||
(cons 'keymap (remq 'keymap (copy-sequence existing)))
|
||||
string)))))
|
||||
(setq position next)))))))
|
||||
|
||||
(provide 'ebox-interaction)
|
||||
|
||||
;;; ebox-interaction.el ends here
|
||||
358
ebox-layer.el
Normal file
358
ebox-layer.el
Normal file
@ -0,0 +1,358 @@
|
||||
;;; ebox-layer.el --- Retained box placement and text composition -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Compose visible pieces without changing the logical tree. Horizontal
|
||||
;; coordinates are pixels; vertical coordinates share the host's text rows.
|
||||
;; This module never writes a buffer or owns application state.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'ebox-tree)
|
||||
(require 'ebox-composite)
|
||||
|
||||
(declare-function ebox-get "ebox" (box property))
|
||||
(declare-function ebox--ensure-region-id "ebox" (box))
|
||||
(declare-function ebox--substring-pixel-width "ebox-measure" (string start end))
|
||||
(declare-function ebox--string-pixel-width "ebox-measure" (string))
|
||||
(declare-function ebox-string-lines "ebox" (string))
|
||||
(declare-function ebox-lines-join "ebox" (lines))
|
||||
(declare-function ebox--render-with-cache "ebox" (node &optional width probe))
|
||||
(declare-function ebox--call-with-box-content-context "ebox-layout" (box function))
|
||||
(declare-function ebox--wrapper-content-viewport-pixel "ebox-layout" (box))
|
||||
(declare-function ebox--content-height "ebox-layout" (box height &optional rendered))
|
||||
(declare-function ebox--size-pixels "ebox-layout" (value &optional box axis percent-base))
|
||||
(declare-function ebox--size-line-height "ebox-layout" (&optional box))
|
||||
(declare-function ebox--record-rendered-uniform-width "ebox-layout" (rendered width))
|
||||
(declare-function ebox--record-rendered-intrinsic-size "ebox-layout" (rendered width height))
|
||||
(declare-function ebox-pixel-space "ebox" (pixels))
|
||||
(defvar ebox-region-types)
|
||||
(defvar ebox--intrinsic-layout-measurement)
|
||||
(defvar ebox--render-source-index)
|
||||
|
||||
(defvar ebox-layer--render-root nil
|
||||
"Root of this spatial materialization, never a live buffer root.")
|
||||
(defvar ebox-layer--portals nil
|
||||
"Candidate-local absolute nodes awaiting root composition.")
|
||||
|
||||
(defvar ebox-layer--active-p 'unknown
|
||||
"Whether the current candidate contains layered layout.
|
||||
Retained publication supplies a known boolean; isolated rendering resolves
|
||||
the fact once for its spatial root.")
|
||||
|
||||
(defun ebox-layer-active-p (&optional node)
|
||||
"Return the current candidate's layer capability without repeated scans."
|
||||
(if (eq ebox-layer--active-p 'unknown)
|
||||
(if ebox-layer--render-root
|
||||
(setq ebox-layer--active-p
|
||||
(ebox-layer-subtree-p ebox-layer--render-root))
|
||||
(ebox-layer-subtree-p node))
|
||||
ebox-layer--active-p))
|
||||
|
||||
(defun ebox-layer-positioned-p (node)
|
||||
"Return non-nil when NODE opts into positioned box painting."
|
||||
(memq (plist-get node :position) '(relative absolute)))
|
||||
|
||||
(defun ebox-layer-host-p (node)
|
||||
"Return non-nil when NODE composes positioned direct children."
|
||||
(and (eq (plist-get node :ebox-kind) 'box)
|
||||
(cl-some #'ebox-layer-positioned-p
|
||||
(ebox-tree-layout-children node))))
|
||||
|
||||
(defun ebox-layer-subtree-p (node)
|
||||
"Return non-nil when NODE's logical subtree contains positioned painting."
|
||||
(and (listp node)
|
||||
(or (ebox-layer-host-p node)
|
||||
(ebox-layer-positioned-p node)
|
||||
(cl-some #'ebox-layer-subtree-p (ebox-tree-node-children node)))))
|
||||
|
||||
(defun ebox-layer-portals-p (node)
|
||||
"Return non-nil when NODE's subtree projects content to its render root."
|
||||
(and (listp node)
|
||||
(or (eq (plist-get node :layer) 'root)
|
||||
(cl-some #'ebox-layer-portals-p (ebox-tree-node-children node)))))
|
||||
|
||||
(defun ebox-layer--owned-p (line position region)
|
||||
"Whether LINE at POSITION visibly belongs to REGION, excluding margins."
|
||||
(and (not (cl-some (lambda (role)
|
||||
(equal region (get-text-property position role line)))
|
||||
'(ebox-ml ebox-mr ebox-mt ebox-mb)))
|
||||
(or (member region (get-text-property position 'ebox-content-owners line))
|
||||
(equal region (get-text-property position 'ebox-content-owner line))
|
||||
(cl-some (lambda (role)
|
||||
(equal region (get-text-property position (cdr role) line)))
|
||||
ebox-region-types))))
|
||||
|
||||
(defun ebox-layer--pieces (lines node)
|
||||
"Return NODE's visible source intervals in LINES, before positioning."
|
||||
(let ((region (ebox--ensure-region-id node)) pieces)
|
||||
(cl-loop for line in lines for row from 0 do
|
||||
(let ((start 0) (limit (length line)))
|
||||
(while (< start limit)
|
||||
(let ((end (next-property-change start line limit)))
|
||||
(when (ebox-layer--owned-p line start region)
|
||||
(let ((x (ebox--substring-pixel-width line 0 start))
|
||||
(right (ebox--substring-pixel-width line 0 end)))
|
||||
(when (> right x)
|
||||
;; Keep the full row as source so shaping and shared
|
||||
;; display objects are measured in their own context.
|
||||
(push (list :line line :start x :end right :y row)
|
||||
pieces))))
|
||||
(setq start end)))))
|
||||
;; Property boundaries must not become artificial glyph clipping edges.
|
||||
(let (merged)
|
||||
(dolist (piece (nreverse pieces))
|
||||
(let ((last (car merged)))
|
||||
(if (and last (= (plist-get last :y) (plist-get piece :y))
|
||||
(= (plist-get last :end) (plist-get piece :start)))
|
||||
(plist-put last :end (plist-get piece :end))
|
||||
(push piece merged))))
|
||||
(nreverse merged))))
|
||||
|
||||
(defun ebox-layer--bounds (pieces)
|
||||
"Return (X Y WIDTH HEIGHT) covering nonempty PIECES, or nil."
|
||||
(when pieces
|
||||
(let ((left (apply #'min (mapcar (lambda (p) (plist-get p :start)) pieces)))
|
||||
(right (apply #'max (mapcar (lambda (p) (plist-get p :end)) pieces)))
|
||||
(top (apply #'min (mapcar (lambda (p) (plist-get p :y)) pieces)))
|
||||
(bottom (apply #'max (mapcar (lambda (p) (plist-get p :y)) pieces))))
|
||||
(list left top (- right left) (1+ (- bottom top))))))
|
||||
|
||||
(defun ebox-layer--offset (node property host extent)
|
||||
"Resolve NODE's PROPERTY against HOST and containing axis EXTENT."
|
||||
(let* ((vertical (eq property :top))
|
||||
(value (or (plist-get node property) (if vertical '(lh 0) '(px 0))))
|
||||
(pixels (ebox--size-pixels value host (if vertical 'height 'width)
|
||||
(if vertical
|
||||
(* extent (ebox--size-line-height host))
|
||||
extent))))
|
||||
(unless (numberp pixels)
|
||||
(error "Ebox layer offset has no definite containing size: %S" value))
|
||||
(if vertical (floor (/ pixels (float (ebox--size-line-height host)))) pixels)))
|
||||
|
||||
(defun ebox-layer--validate-child (node host)
|
||||
"Check text backend placement contracts for NODE inside HOST."
|
||||
(ebox-layer-validate-node node)
|
||||
(when (ebox-layer-positioned-p node)
|
||||
(cl-labels ((check (child)
|
||||
(unless (= (ebox--size-line-height child)
|
||||
(ebox--size-line-height host))
|
||||
(error "Ebox positioned boxes must share their host's line height"))
|
||||
(mapc #'check (ebox-tree-node-children child))))
|
||||
(check node))))
|
||||
|
||||
(defun ebox-layer-validate-node (node)
|
||||
"Validate NODE's placement combinations independently of visibility."
|
||||
(when (and (or (plist-get node :anchor)
|
||||
(eq (plist-get node :layer) 'root))
|
||||
(not (eq (plist-get node :position) 'absolute)))
|
||||
(error "Ebox :anchor and :layer root require :position absolute")))
|
||||
|
||||
(defun ebox-layer--ordered-records (host records)
|
||||
"Sort candidate RECORDS by layer and HOST logical document order."
|
||||
(let ((order (make-hash-table :test #'eq)) (index 0))
|
||||
(cl-labels ((visit (node)
|
||||
(puthash node (cl-incf index) order)
|
||||
(mapc #'visit (ebox-tree-node-children node))))
|
||||
(visit host))
|
||||
(cl-stable-sort
|
||||
(copy-sequence records)
|
||||
(lambda (a b)
|
||||
(let* ((an (plist-get a :node)) (bn (plist-get b :node))
|
||||
(az (or (plist-get an :z-index) 0))
|
||||
(bz (or (plist-get bn :z-index) 0)))
|
||||
(cond
|
||||
((/= az bz) (< az bz))
|
||||
((not (eq (and (ebox-layer-positioned-p an) t)
|
||||
(and (ebox-layer-positioned-p bn) t)))
|
||||
(not (ebox-layer-positioned-p an)))
|
||||
(t (< (gethash an order 0) (gethash bn order 0)))))))))
|
||||
|
||||
(defun ebox-layer--compose-rows (host records width height)
|
||||
"Compose HOST's visible RECORDS in a WIDTH by HEIGHT content canvas."
|
||||
(let ((rows (make-vector height nil)))
|
||||
(dolist (record (ebox-layer--ordered-records host records))
|
||||
(let ((row (plist-get record :y)))
|
||||
(when (and (>= row 0) (< row height))
|
||||
(push record (aref rows row)))))
|
||||
(cl-loop for row across rows collect
|
||||
(ebox-composite-line "" (nreverse row) width))))
|
||||
|
||||
(defun ebox-layer--anchor-node (host id)
|
||||
"Find one logical anchor ID under HOST, rejecting ambiguous IDs."
|
||||
(let ((name (format "%s" id)) matches)
|
||||
(cl-labels ((visit (node)
|
||||
(when (equal name (ebox-tree-node-id ebox--render-source-index node))
|
||||
(push node matches))
|
||||
(mapc #'visit (ebox-tree-node-children node))))
|
||||
(visit host))
|
||||
(when (cdr matches) (error "Ebox layer anchor is ambiguous: %s" name))
|
||||
(car matches)))
|
||||
|
||||
(defun ebox-layer--absolute-origin (node host bounds width height layer-width layer-height)
|
||||
"Resolve NODE's origin in HOST, or nil for an invisible anchor.
|
||||
BOUNDS is the already resolved anchor rectangle, or nil.
|
||||
WIDTH and HEIGHT bound the host; LAYER-WIDTH and LAYER-HEIGHT bound the panel."
|
||||
(let ((x (ebox-layer--offset node :left host width))
|
||||
(y (ebox-layer--offset node :top host height))
|
||||
(anchor (plist-get node :anchor)))
|
||||
(if (not anchor) (list x y)
|
||||
(when bounds
|
||||
(let* ((placement (or (plist-get node :placement) 'bottom-start))
|
||||
(above (memq placement '(top-start top-end)))
|
||||
(left (+ (car bounds) x
|
||||
(if (memq placement '(bottom-end top-end))
|
||||
(- (nth 2 bounds) layer-width) 0)))
|
||||
(top (+ y (nth 1 bounds)
|
||||
(if above (- layer-height) (nth 3 bounds))))
|
||||
(other (+ y (nth 1 bounds)
|
||||
(if above (nth 3 bounds) (- layer-height)))))
|
||||
(when (and (or (< top 0) (> (+ top layer-height) height))
|
||||
(>= other 0) (<= (+ other layer-height) height))
|
||||
(setq top other))
|
||||
(list (max 0 (min left (max 0 (- width layer-width))))
|
||||
(max 0 (min top (max 0 (- height layer-height))))))))))
|
||||
|
||||
(defun ebox-layer--visible-bounds (bounds origin width height)
|
||||
"Translate BOUNDS by ORIGIN and intersect the WIDTH by HEIGHT canvas."
|
||||
(when (and bounds origin)
|
||||
(let* ((x (+ (car bounds) (car origin)))
|
||||
(y (+ (cadr bounds) (cadr origin)))
|
||||
(left (max 0 x)) (top (max 0 y))
|
||||
(right (min width (+ x (nth 2 bounds))))
|
||||
(bottom (min height (+ y (nth 3 bounds)))))
|
||||
(when (and (> right left) (> bottom top))
|
||||
(list left top (- right left) (- bottom top))))))
|
||||
|
||||
(defun ebox-layer--absolute-records (host nodes base width height)
|
||||
"Resolve HOST's absolute NODES and return opaque paint records.
|
||||
BASE supplies normal-flow anchor fragments. Anchor dependencies are resolved
|
||||
before painting, so source order and z order never select the geometry.
|
||||
Missing or clipped anchors suppress their dependents; cycles signal before
|
||||
publication. Nested root projections join this same candidate-local pass."
|
||||
(let ((panels (make-hash-table :test #'eq))
|
||||
(parents (make-hash-table :test #'eq))
|
||||
(status (make-hash-table :test #'eq))
|
||||
(queue (copy-sequence nodes)) order records)
|
||||
(cl-labels
|
||||
((index (node)
|
||||
(dolist (child (ebox-tree-node-children node))
|
||||
(puthash child node parents)
|
||||
(index child)))
|
||||
(panel-owner (node)
|
||||
(while (and node (not (gethash node panels)))
|
||||
(setq node (gethash node parents)))
|
||||
node)
|
||||
(resolve (node)
|
||||
(pcase (gethash node status)
|
||||
('visiting (error "Ebox layer anchor dependency is cyclic"))
|
||||
('done (plist-get (gethash node panels) :origin))
|
||||
(_
|
||||
(puthash node 'visiting status)
|
||||
(let* ((panel (gethash node panels))
|
||||
(anchor (plist-get node :anchor))
|
||||
(target (and anchor (ebox-layer--anchor-node host anchor)))
|
||||
(provider (and target (panel-owner target)))
|
||||
(bounds
|
||||
(when (and target (not (eq (plist-get target :visibility) 'hidden)))
|
||||
(if provider
|
||||
(let ((origin (resolve provider)))
|
||||
(ebox-layer--visible-bounds
|
||||
(ebox-layer--bounds
|
||||
(ebox-layer--pieces
|
||||
(plist-get (gethash provider panels) :lines) target))
|
||||
origin width height))
|
||||
(ebox-layer--bounds (ebox-layer--pieces base target)))))
|
||||
(origin
|
||||
(unless (eq (plist-get node :visibility) 'hidden)
|
||||
(ebox-layer--absolute-origin
|
||||
node host bounds width height
|
||||
(plist-get panel :width) (length (plist-get panel :lines))))))
|
||||
(plist-put panel :origin origin)
|
||||
(puthash node 'done status)
|
||||
origin)))))
|
||||
(index host)
|
||||
(while queue
|
||||
(let ((node (pop queue)))
|
||||
(unless (gethash node panels)
|
||||
(ebox-layer--validate-child node host)
|
||||
(let* ((text
|
||||
(unless (eq (plist-get node :visibility) 'hidden)
|
||||
(ebox--call-with-box-content-context
|
||||
host (lambda () (ebox--render-with-cache node)))))
|
||||
(lines (and text (ebox-string-lines text))))
|
||||
(puthash node
|
||||
(list :lines lines :origin nil
|
||||
:width (if lines (apply #'max (mapcar #'ebox--string-pixel-width lines)) 0))
|
||||
panels)
|
||||
(push node order)
|
||||
(when (eq host ebox-layer--render-root)
|
||||
(dolist (portal ebox-layer--portals)
|
||||
(unless (or (gethash portal panels) (memq portal queue))
|
||||
(setq queue (append queue (list portal))))))))))
|
||||
(dolist (node (nreverse order))
|
||||
(let ((origin (resolve node)))
|
||||
(when origin
|
||||
(dolist (piece (ebox-layer--pieces (plist-get (gethash node panels) :lines) node))
|
||||
(push (list :node node
|
||||
:x (+ (car origin) (plist-get piece :start))
|
||||
:y (+ (cadr origin) (plist-get piece :y))
|
||||
:text (ebox-composite-slice (plist-get piece :line)
|
||||
(plist-get piece :start)
|
||||
(plist-get piece :end))) records))))))
|
||||
records))
|
||||
|
||||
(defun ebox-layer-render-children (host children renderer)
|
||||
"Compose HOST's CHILDREN using ordinary flow RENDERER.
|
||||
RENDERER accepts a child list. Logical nodes are never reordered or removed.
|
||||
Only candidate-local lists of visible paint records are sorted."
|
||||
(let* ((flow (cl-remove-if (lambda (child)
|
||||
(eq (plist-get child :position) 'absolute)) children))
|
||||
(base (funcall renderer flow)))
|
||||
(if ebox--intrinsic-layout-measurement base
|
||||
(let* ((lines (ebox-string-lines base))
|
||||
(width (or (ebox--wrapper-content-viewport-pixel host)
|
||||
(if lines (apply #'max (mapcar #'ebox--string-pixel-width lines)) 0)))
|
||||
(flow-height (length lines))
|
||||
(height (max flow-height (ceiling (ebox--content-height host flow-height base))))
|
||||
(absolute (cl-remove-if-not (lambda (child)
|
||||
(eq (plist-get child :position) 'absolute)) children))
|
||||
records)
|
||||
(dolist (child children) (ebox-layer--validate-child child host))
|
||||
;; Normal children preserve their layout slots. Relative children use
|
||||
;; the same source intervals and shift only their paint coordinates.
|
||||
(dolist (child flow)
|
||||
(unless (eq (plist-get child :visibility) 'hidden)
|
||||
(let ((dx (if (eq (plist-get child :position) 'relative)
|
||||
(ebox-layer--offset child :left host width) 0))
|
||||
(dy (if (eq (plist-get child :position) 'relative)
|
||||
(ebox-layer--offset child :top host height) 0)))
|
||||
(dolist (piece (ebox-layer--pieces lines child))
|
||||
(push (list :node child :y (+ dy (plist-get piece :y))
|
||||
:x (+ dx (plist-get piece :start))
|
||||
:text (ebox-composite-slice (plist-get piece :line)
|
||||
(plist-get piece :start)
|
||||
(plist-get piece :end))) records)))))
|
||||
(unless (eq host ebox-layer--render-root)
|
||||
(dolist (child absolute)
|
||||
(when (eq (plist-get child :layer) 'root)
|
||||
(cl-pushnew child ebox-layer--portals :test #'eq)))
|
||||
(setq absolute (cl-remove-if (lambda (child)
|
||||
(eq (plist-get child :layer) 'root)) absolute)))
|
||||
(when (eq host ebox-layer--render-root)
|
||||
(setq absolute (append absolute (reverse ebox-layer--portals))))
|
||||
(when (cl-some (lambda (child) (plist-get child :anchor)) absolute)
|
||||
;; Anchor geometry follows relative movement and visible clipping,
|
||||
;; not the original flow slot. This extra composition is needed
|
||||
;; only by anchored panels.
|
||||
(setq lines (ebox-layer--compose-rows host records width height)))
|
||||
(setq records (nconc (ebox-layer--absolute-records host absolute lines width height)
|
||||
records))
|
||||
(ebox--record-rendered-intrinsic-size
|
||||
(ebox--record-rendered-uniform-width
|
||||
(ebox-lines-join (ebox-layer--compose-rows host records width height)) width)
|
||||
width flow-height)))))
|
||||
|
||||
(provide 'ebox-layer)
|
||||
|
||||
;;; ebox-layer.el ends here
|
||||
@ -9,6 +9,7 @@
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'seq)
|
||||
(require 'ebox-size)
|
||||
|
||||
(declare-function ebox-flex-layout-config-props-p
|
||||
"ebox-flex" (props))
|
||||
@ -28,13 +29,14 @@
|
||||
'(start center end stretch)
|
||||
"Canonical Row/Column cross-axis alignment values.")
|
||||
|
||||
(defun ebox-layout-config--simple-axis-props-p (props)
|
||||
"Return non-nil when PROPS is canonical Row/Column config data."
|
||||
(defun ebox-layout-config--simple-axis-props-p (props kind)
|
||||
"Return non-nil when PROPS is canonical Row/Column KIND config data."
|
||||
(and (equal (mapcar #'car (seq-partition props 2))
|
||||
'(:item-gap :cross-align))
|
||||
(let ((gap (plist-get props :item-gap))
|
||||
(align (plist-get props :cross-align)))
|
||||
(and (integerp gap) (>= gap 0)
|
||||
(and (ebox-size-value-for-axis-p
|
||||
gap (if (eq kind 'column) 'block 'inline))
|
||||
(memq align ebox-layout-config--simple-cross-align-values)))))
|
||||
|
||||
(defun ebox-layout-config--simple-axis-create (kind plist)
|
||||
@ -51,11 +53,14 @@
|
||||
(let ((props
|
||||
(list :item-gap (if (plist-member plist :item-gap)
|
||||
(plist-get plist :item-gap)
|
||||
0)
|
||||
(if (eq kind 'column) '(lh 0) '(px 0)))
|
||||
:cross-align (if (plist-member plist :cross-align)
|
||||
(plist-get plist :cross-align)
|
||||
'stretch))))
|
||||
(unless (ebox-layout-config--simple-axis-props-p props)
|
||||
(ebox-size-validate-for-axis
|
||||
(plist-get props :item-gap) :item-gap
|
||||
(if (eq kind 'column) 'block 'inline))
|
||||
(unless (ebox-layout-config--simple-axis-props-p props kind)
|
||||
(error "Invalid Ebox %S LayoutConfig properties: %S" kind props))
|
||||
(ebox-layout-config--create :kind kind :props props)))
|
||||
|
||||
@ -69,7 +74,10 @@
|
||||
(pcase kind
|
||||
('normal (null props))
|
||||
((or 'row 'column)
|
||||
(ebox-layout-config--simple-axis-props-p props))
|
||||
(ebox-size-validate-for-axis
|
||||
(plist-get props :item-gap) :item-gap
|
||||
(if (eq kind 'column) 'block 'inline))
|
||||
(ebox-layout-config--simple-axis-props-p props kind))
|
||||
('flex
|
||||
(and (fboundp 'ebox-flex-layout-config-props-p)
|
||||
(ebox-flex-layout-config-props-p props)))
|
||||
|
||||
1220
ebox-layout.el
1220
ebox-layout.el
File diff suppressed because it is too large
Load Diff
120
ebox-measure.el
120
ebox-measure.el
@ -9,10 +9,12 @@
|
||||
(require 'cl-lib)
|
||||
(require 'ebox-cache)
|
||||
(require 'ebox-font)
|
||||
(require 'ebox-size)
|
||||
|
||||
(declare-function ebox--register-render-owned-text-value
|
||||
"ebox-render-context" (property value))
|
||||
(declare-function ebox-string-lines "ebox" (string))
|
||||
(declare-function ebox-lines-join "ebox" (lines))
|
||||
(declare-function ebox--string-repeat-lines "ebox" (string count))
|
||||
|
||||
;; Measurement Cache Model:
|
||||
@ -74,11 +76,72 @@ of pixel measurements it triggers do not each rebuild the display signature.")
|
||||
|
||||
(defun ebox--display-space-width (display)
|
||||
"Return fixed pixel width for DISPLAY space specs, or nil."
|
||||
(when (and (consp display) (eq (car display) 'space))
|
||||
(ebox--fixed-pixel-space-width display))
|
||||
|
||||
(defun ebox--fixed-pixel-space-width (display)
|
||||
"Return DISPLAY's nonnegative fixed pixel width, or nil for other specs.
|
||||
Bare space widths use character units; align-to spaces depend on placement."
|
||||
(when (and (consp display) (eq (car display) 'space)
|
||||
(not (plist-member (cdr display) :align-to)))
|
||||
(let ((width (plist-get (cdr display) :width)))
|
||||
(cond
|
||||
((numberp width) width)
|
||||
((and (consp width) (numberp (car width))) (car width))))))
|
||||
(when (and (consp width) (null (cdr width))
|
||||
(ebox-size--finite-number-p (car width)) (>= (car width) 0))
|
||||
(car width)))))
|
||||
|
||||
(defun ebox--fractional-pixel-spaces-p (string)
|
||||
"Return non-nil when STRING has a nonintegral fixed-pixel space glyph."
|
||||
(let ((position 0) (end (length string)))
|
||||
(catch 'fractional
|
||||
(while (< position end)
|
||||
(let* ((next (next-single-property-change position 'display string end))
|
||||
(width (ebox--fixed-pixel-space-width
|
||||
(get-text-property position 'display string))))
|
||||
(when (and width (/= width (floor width))
|
||||
(cl-position-if (lambda (char) (/= char ?\n)) string
|
||||
:start position :end next))
|
||||
(throw 'fractional t))
|
||||
(setq position next)))
|
||||
nil)))
|
||||
|
||||
(defun ebox--quantize-pixel-spaces (string)
|
||||
"Return complete STRING lines with cumulative whole-pixel space extents.
|
||||
Keep raw layout strings and their cached geometry private. Only the final
|
||||
output copy changes: equal line boundaries share the same rounding regardless
|
||||
of text alignment, blank fill, borders, padding, or sibling segmentation.
|
||||
Ordinary glyph advances are whole pixels, so only fixed space extents affect
|
||||
the fractional remainder. Preserve zero-width glyphs and all other properties.
|
||||
An already integral STRING is returned unchanged."
|
||||
(if (not (ebox--fractional-pixel-spaces-p string))
|
||||
string
|
||||
(let ((result (copy-sequence string))
|
||||
(start 0)
|
||||
(end (length string)))
|
||||
(while (< start end)
|
||||
(let* ((line-end (or (string-match "\n" string start) end))
|
||||
(position start)
|
||||
positions widths)
|
||||
(while (< position line-end)
|
||||
(let* ((next (next-single-property-change
|
||||
position 'display string line-end))
|
||||
(width (ebox--fixed-pixel-space-width
|
||||
(get-text-property position 'display string))))
|
||||
(when width
|
||||
(push (cons position next) positions)
|
||||
(push width widths))
|
||||
(setq position next)))
|
||||
(cl-mapc
|
||||
(lambda (range used)
|
||||
(let ((display (get-text-property (car range) 'display string)))
|
||||
(unless (= used (ebox--fixed-pixel-space-width display))
|
||||
(let ((updated (cons 'space (copy-sequence (cdr display)))))
|
||||
(setcdr updated (plist-put (cdr updated) :width (list used)))
|
||||
(put-text-property
|
||||
(car range) (cdr range) 'display
|
||||
(ebox--register-render-owned-text-value 'display updated)
|
||||
result)))))
|
||||
(nreverse positions) (ebox-size-quantize (nreverse widths)))
|
||||
(setq start (1+ line-end))))
|
||||
result)))
|
||||
|
||||
(defun ebox--string-with-text-scale (string factor)
|
||||
"Return STRING with FACTOR applied as an explicit face height."
|
||||
@ -162,7 +225,16 @@ constant across the selected slice."
|
||||
(let* ((sample (if (or start end)
|
||||
(substring line (or start 0) (or end (length line)))
|
||||
line))
|
||||
(width (ebox--scaled-string-pixel-width sample factor))
|
||||
(space-width
|
||||
(and (> (length sample) 0)
|
||||
(ebox--display-space-width
|
||||
(get-text-property 0 'display sample))))
|
||||
;; This cache key proves uniform visual properties. Preserve
|
||||
;; display-space fractions just as the segmented path does;
|
||||
;; Emacs's glyph measurement quantizes them before padding.
|
||||
(width (if space-width
|
||||
space-width
|
||||
(ebox--scaled-string-pixel-width sample 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
|
||||
@ -193,17 +265,21 @@ keep their absolute pixel width."
|
||||
(ebox--cached-string-pixel-width line factor cache-key)
|
||||
(while (< pos end)
|
||||
(let* ((display (get-text-property pos 'display line))
|
||||
(space-width (ebox--display-space-width display))
|
||||
(next
|
||||
(cl-loop for property in
|
||||
'(face display)
|
||||
minimize (or (next-single-property-change
|
||||
pos property line end)
|
||||
end)))
|
||||
(space-width (ebox--display-space-width display)))
|
||||
(if (or (stringp display)
|
||||
(memq (car-safe display) '(space image)))
|
||||
;; Emacs replaces an entire EQ display run once. Face
|
||||
;; boundaries inside it do not create additional glyphs.
|
||||
(next-single-property-change pos 'display line end)
|
||||
(cl-loop for property in '(face display)
|
||||
minimize (or (next-single-property-change
|
||||
pos property line end)
|
||||
end)))))
|
||||
(setq total
|
||||
(+ total
|
||||
(if space-width
|
||||
(* space-width (- next pos))
|
||||
space-width
|
||||
;; The segment ends at every visual property change.
|
||||
;; Build only its property-free cache key on hits; a
|
||||
;; propertized substring is materialized solely on miss.
|
||||
@ -357,18 +433,28 @@ Nested uses reuse the outer validation."
|
||||
(ebox--space-pixel-width))
|
||||
|
||||
(defsubst ebox-pixel-space (pixel-width)
|
||||
"Generate a space string with PIXEL-WIDTH."
|
||||
"Generate a space string with PIXEL-WIDTH.
|
||||
Canonicalize whole pixels so equal computed lengths have identical display
|
||||
properties. Preserve a genuine fractional remainder until display."
|
||||
(if (or (null pixel-width) (<= pixel-width 0))
|
||||
""
|
||||
(let ((display
|
||||
(let* ((whole-pixels (truncate pixel-width))
|
||||
(pixel-width (if (= pixel-width whole-pixels)
|
||||
whole-pixels pixel-width))
|
||||
(display
|
||||
(ebox--register-render-owned-text-value
|
||||
'display `(space :width (,pixel-width)))))
|
||||
(propertize " " 'display display))))
|
||||
|
||||
(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)))
|
||||
"Generate a blank area of PIXEL-WIDTH and HEIGHT lines.
|
||||
A zero pixel width still preserves the requested positive line height."
|
||||
(when (and pixel-width height (>= pixel-width 0) (> height 0))
|
||||
(ebox--string-repeat-lines
|
||||
(if (zerop pixel-width)
|
||||
(ebox-lines-join '(""))
|
||||
(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)."
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'ebox-runtime-index)
|
||||
|
||||
(declare-function ebox-native-reflow-layout-ready-p "ebox-native-reflow" ())
|
||||
(declare-function ebox-native-reflow-execute-sync
|
||||
@ -22,6 +23,8 @@
|
||||
"ebox-native-reflow" (&rest arguments))
|
||||
(declare-function ebox-native-reflow-fork-session
|
||||
"ebox-native-reflow" (session &rest options))
|
||||
(declare-function ebox-native-reflow-release-session
|
||||
"ebox-native-reflow" (session))
|
||||
(declare-function ebox-native-reflow-confirm-native-frame
|
||||
"ebox-native-reflow"
|
||||
(session generation key confirmed-revision))
|
||||
@ -31,6 +34,10 @@
|
||||
"ebox-native-reflow" (frame))
|
||||
(declare-function ebox--literal-root-pixel-width "ebox" (box))
|
||||
(declare-function ebox-get "ebox" (box key))
|
||||
(declare-function ebox-put "ebox" (box key value))
|
||||
(declare-function ebox--scroll-set-state "ebox" (region-id state))
|
||||
(declare-function ebox--scroll-clear-state "ebox" (region-id))
|
||||
(defvar ebox--scroll-global-state)
|
||||
(declare-function ebox-style-cascade-active-p "ebox-style" ())
|
||||
(declare-function ebox-style-property "ebox-style" (name))
|
||||
(declare-function ebox-tree-node-key "ebox-tree" (source-index node))
|
||||
@ -43,7 +50,7 @@
|
||||
"ebox-incremental" (node))
|
||||
|
||||
(defun ebox-native-commit-topology-stable-p
|
||||
(old-state candidate-state prepared)
|
||||
(old-state candidate-state prepared)
|
||||
"Return non-nil when PREPARED preserves OLD-STATE's object topology."
|
||||
(let ((old-nodes (plist-get old-state :node-table))
|
||||
(new-nodes (plist-get candidate-state :node-table))
|
||||
@ -60,22 +67,22 @@
|
||||
(lambda (entry)
|
||||
(eq (plist-get entry :dirty-kind) 'structure))
|
||||
(plist-get prepared :dirty-set)))
|
||||
(hash-table-p old-nodes) (hash-table-p new-nodes)
|
||||
(hash-table-p old-parents) (hash-table-p new-parents)
|
||||
(hash-table-p old-regions) (hash-table-p new-regions)
|
||||
(ebox-runtime-index-like-p old-nodes) (ebox-runtime-index-like-p new-nodes)
|
||||
(ebox-runtime-index-like-p old-parents) (ebox-runtime-index-like-p new-parents)
|
||||
(ebox-runtime-index-like-p old-regions) (ebox-runtime-index-like-p new-regions)
|
||||
(hash-table-p old-objects)
|
||||
(= (hash-table-count old-nodes)
|
||||
(hash-table-count new-nodes))
|
||||
(= (hash-table-count old-regions)
|
||||
(hash-table-count new-regions))))
|
||||
(= (ebox-runtime-index-size old-nodes)
|
||||
(ebox-runtime-index-size new-nodes))
|
||||
(= (ebox-runtime-index-size old-regions)
|
||||
(ebox-runtime-index-size new-regions))))
|
||||
(when stable
|
||||
(maphash
|
||||
(ebox-runtime-index-map
|
||||
(lambda (node-id node)
|
||||
(let ((old-node (gethash node-id old-nodes missing)))
|
||||
(let ((old-node (ebox-runtime-index-get node-id old-nodes missing)))
|
||||
(unless (and (not (eq old-node missing))
|
||||
(gethash node-id old-objects)
|
||||
(equal (gethash node-id old-parents missing)
|
||||
(gethash node-id new-parents missing))
|
||||
(equal (ebox-runtime-index-get node-id old-parents missing)
|
||||
(ebox-runtime-index-get node-id new-parents missing))
|
||||
(eq (plist-get old-node :ebox-type)
|
||||
(plist-get node :ebox-type))
|
||||
(equal
|
||||
@ -86,9 +93,9 @@
|
||||
(setq stable nil))))
|
||||
new-nodes))
|
||||
(when stable
|
||||
(maphash
|
||||
(ebox-runtime-index-map
|
||||
(lambda (region-id node-id)
|
||||
(unless (equal (gethash region-id old-regions missing) node-id)
|
||||
(unless (equal (ebox-runtime-index-get region-id old-regions missing) node-id)
|
||||
(setq stable nil)))
|
||||
new-regions))
|
||||
stable))
|
||||
@ -103,7 +110,7 @@
|
||||
(while (and node-id (not (gethash node-id seen)))
|
||||
(puthash node-id t seen)
|
||||
(push node-id result)
|
||||
(setq node-id (and parents (gethash node-id parents))))))
|
||||
(setq node-id (and parents (ebox-runtime-index-get node-id parents))))))
|
||||
(nreverse result)))
|
||||
|
||||
(defun ebox-native-commit-inherited-dirty-node-ids (prepared)
|
||||
@ -121,8 +128,8 @@
|
||||
"Return NODE-ID to `(PARENT-ID . POSITION)' table for STATE."
|
||||
(let ((nodes (plist-get state :node-table))
|
||||
(table (make-hash-table :test 'equal)))
|
||||
(when (hash-table-p nodes)
|
||||
(maphash
|
||||
(when (ebox-runtime-index-like-p nodes)
|
||||
(ebox-runtime-index-map
|
||||
(lambda (parent-id node)
|
||||
(cl-loop for child in (ebox-tree--children-raw node)
|
||||
for position from 0
|
||||
@ -164,22 +171,22 @@ type, key, parent, and sibling position are identical."
|
||||
(plist-get old-state :source-index) old-root)
|
||||
(ebox-tree-node-key
|
||||
(plist-get candidate-state :source-index) new-root))
|
||||
(hash-table-p old-nodes) (hash-table-p new-nodes)
|
||||
(ebox-runtime-index-like-p old-nodes) (ebox-runtime-index-like-p new-nodes)
|
||||
(hash-table-p old-objects)))
|
||||
result)
|
||||
(dolist (node-id (plist-get prepared :touched-node-ids))
|
||||
(if (gethash node-id new-nodes)
|
||||
(if (ebox-runtime-index-get node-id new-nodes)
|
||||
(puthash node-id t requested)
|
||||
(setq valid nil)))
|
||||
(dolist (node-id (plist-get prepared :removed-node-ids))
|
||||
(if (and (gethash node-id old-nodes)
|
||||
(not (gethash node-id new-nodes)))
|
||||
(if (and (ebox-runtime-index-get node-id old-nodes)
|
||||
(not (ebox-runtime-index-get node-id new-nodes)))
|
||||
(puthash node-id t removed)
|
||||
(setq valid nil)))
|
||||
(when valid
|
||||
(maphash
|
||||
(ebox-runtime-index-map
|
||||
(lambda (node-id _node)
|
||||
(when (and (not (gethash node-id new-nodes))
|
||||
(when (and (not (ebox-runtime-index-get node-id new-nodes))
|
||||
(not (gethash node-id removed)))
|
||||
(setq valid nil)))
|
||||
old-nodes))
|
||||
@ -187,7 +194,7 @@ type, key, parent, and sibling position are identical."
|
||||
(cl-labels
|
||||
((visit (node)
|
||||
(let* ((node-id (plist-get node :node-id))
|
||||
(old-node (gethash node-id old-nodes missing))
|
||||
(old-node (ebox-runtime-index-get node-id old-nodes missing))
|
||||
(old-object (gethash node-id old-objects))
|
||||
(position (gethash node-id new-positions))
|
||||
(parent-id (car-safe position))
|
||||
@ -227,12 +234,12 @@ type, key, parent, and sibling position are identical."
|
||||
(setq stable
|
||||
(and (plist-get old-state :viewport-dependent-node-ids-ready)
|
||||
(plist-member old-state :viewport-dependent-node-id-axes)
|
||||
(hash-table-p old-nodes)
|
||||
(hash-table-p new-nodes)))
|
||||
(ebox-runtime-index-like-p old-nodes)
|
||||
(ebox-runtime-index-like-p new-nodes)))
|
||||
(dolist (entry (plist-get prepared :dirty-set) stable)
|
||||
(let* ((node-id (plist-get entry :node-id))
|
||||
(old (and stable (gethash node-id old-nodes)))
|
||||
(new (and stable (gethash node-id new-nodes))))
|
||||
(old (and stable (ebox-runtime-index-get node-id old-nodes)))
|
||||
(new (and stable (ebox-runtime-index-get node-id new-nodes))))
|
||||
(unless (and old new
|
||||
(eq (ebox--node-direct-viewport-width-dependent-p old)
|
||||
(ebox--node-direct-viewport-width-dependent-p new))
|
||||
@ -265,8 +272,7 @@ the native continuation contract: a live session, a confirmable prior frame,
|
||||
the committed text/fragment bases, compiler postorder, and projection support."
|
||||
(and (eq (plist-get previous-state :projection-kind) 'native-frame)
|
||||
(plist-get previous-state :native-sync-session)
|
||||
(or (plist-get previous-state :native-sync-pending)
|
||||
(plist-get previous-state :native-sync-confirmed-p))
|
||||
(plist-get previous-state :native-sync-confirmed-p)
|
||||
(stringp (plist-get previous-state :native-committed-rendered))
|
||||
(vectorp
|
||||
(plist-get previous-state :native-committed-fragment-template))
|
||||
@ -282,20 +288,51 @@ the committed text/fragment bases, compiler postorder, and projection support."
|
||||
(ebox-native-commit-projection-eligible-p
|
||||
previous-state state nil))))
|
||||
|
||||
(defun ebox-native-commit--create-private-session (&optional session)
|
||||
"Create a private native session, forking SESSION when it is present."
|
||||
(if session
|
||||
(ebox-native-reflow-fork-session session)
|
||||
(ebox-native-reflow-create-session
|
||||
:workers 1 :max-jobs 4 :max-results 4)))
|
||||
|
||||
(defun ebox-native-commit--isolate-session
|
||||
(previous-state candidate-state)
|
||||
"Give CANDIDATE-STATE a private child of PREVIOUS-STATE's native session."
|
||||
(when-let* ((session (plist-get previous-state :native-sync-session))
|
||||
(fork
|
||||
(condition-case nil
|
||||
(ebox-native-reflow-fork-session session)
|
||||
(error nil))))
|
||||
(plist-put candidate-state :native-sync-session fork)
|
||||
;; A pending/confirmed frame is local to the previous Rust handle. The
|
||||
;; fork retains compiler facts but begins its own publication sequence.
|
||||
(plist-put candidate-state :native-sync-pending nil)
|
||||
(plist-put candidate-state :native-sync-confirmed-p nil)
|
||||
t))
|
||||
"Give CANDIDATE-STATE a private native session.
|
||||
Fork PREVIOUS-STATE's committed session when present; otherwise bootstrap a
|
||||
new session with the same bounded configuration used by retained frames."
|
||||
(let ((source (plist-get previous-state :native-sync-session)))
|
||||
(cl-remf candidate-state :native-session-setup-failure)
|
||||
(condition-case condition
|
||||
(when-let* ((private
|
||||
(ebox-native-commit--create-private-session source)))
|
||||
(plist-put candidate-state :native-sync-session private)
|
||||
(plist-put candidate-state :native-sync-pending nil)
|
||||
;; A strict native fork shares the source's immutable confirmed
|
||||
;; baseline. Bootstrap sessions deliberately begin unconfirmed.
|
||||
(plist-put candidate-state :native-sync-confirmed-p
|
||||
(not (null source)))
|
||||
t)
|
||||
(error
|
||||
;; The ordinary renderer remains the semantic fallback, but the failed
|
||||
;; native attempt must stay observable in the committed report.
|
||||
(plist-put candidate-state :native-session-setup-failure
|
||||
(list :phase (if source 'fork 'create)
|
||||
:condition (copy-tree condition)))
|
||||
nil))))
|
||||
|
||||
(defun ebox-native-commit-attach-confirmed-base (previous-state state)
|
||||
"Attach PREVIOUS-STATE's confirmed geometry identity to candidate STATE."
|
||||
(when (and previous-state (plist-get state :native-sync-confirmed-p))
|
||||
(let* ((root (plist-get previous-state :root-node))
|
||||
(box (ebox-native-commit--root-box root)))
|
||||
(plist-put state :native-base-viewport-width
|
||||
(plist-get previous-state :viewport-width))
|
||||
(plist-put state :native-base-viewport-height
|
||||
(plist-get previous-state :viewport-height))
|
||||
(plist-put state :native-base-root-width
|
||||
(or (and box (ebox--literal-root-pixel-width box))
|
||||
(plist-get previous-state :viewport-width)))))
|
||||
state)
|
||||
|
||||
(defun ebox-native-commit-prepare-viewport-continuity
|
||||
(previous-state candidate-state)
|
||||
@ -307,6 +344,7 @@ the committed text/fragment bases, compiler postorder, and projection support."
|
||||
(progn
|
||||
(plist-put candidate-state :native-topology-stable-p t)
|
||||
(plist-put candidate-state :native-touched-node-ids nil)
|
||||
(plist-put candidate-state :native-local-dirty-entries nil)
|
||||
(plist-put candidate-state :native-removed-node-ids nil)
|
||||
t)))
|
||||
|
||||
@ -330,6 +368,12 @@ the committed text/fragment bases, compiler postorder, and projection support."
|
||||
(plist-put state :native-touched-node-ids layout-touched)
|
||||
(plist-put state :native-topology-stable-p stable)
|
||||
(when (and (or stable layout-touched)
|
||||
;; A stable native patch reuses prior TP objects and skips a
|
||||
;; full Surface style projection. Its replacement nodes must
|
||||
;; therefore already carry the computed-style certificate.
|
||||
;; A topology-changing full bootstrap projects every node and
|
||||
;; does not need this local-style shortcut certificate.
|
||||
(or (not stable) (plist-get prepared :styles-prepared-p))
|
||||
(not (ebox-style-cascade-active-p))
|
||||
(ebox-native-commit-projection-eligible-p
|
||||
old-state state nil)
|
||||
@ -337,6 +381,8 @@ the committed text/fragment bases, compiler postorder, and projection support."
|
||||
old-state candidate-state))
|
||||
(plist-put candidate-state :native-topology-stable-p stable)
|
||||
(plist-put candidate-state :native-touched-node-ids layout-touched)
|
||||
(plist-put candidate-state :native-local-dirty-entries
|
||||
(copy-tree (plist-get prepared :dirty-set)))
|
||||
(plist-put candidate-state :native-inherited-dirty-node-ids
|
||||
(ebox-native-commit-inherited-dirty-node-ids prepared))
|
||||
(when context-axes-stable
|
||||
@ -357,42 +403,116 @@ the committed text/fragment bases, compiler postorder, and projection support."
|
||||
|
||||
(defun ebox-native-commit--region-box (state region-id)
|
||||
"Resolve REGION-ID's candidate box directly from STATE indexes."
|
||||
(when-let* ((node-id (gethash region-id
|
||||
(when-let* ((node-id (ebox-runtime-index-get region-id
|
||||
(plist-get state :region-node-table)))
|
||||
(node (gethash node-id (plist-get state :node-table))))
|
||||
(node (ebox-runtime-index-get node-id (plist-get state :node-table))))
|
||||
(pcase (plist-get node :ebox-type)
|
||||
('box node)
|
||||
('flex (plist-get node :box)))))
|
||||
|
||||
(defun ebox-native-commit-root-scroll-continuation-p (state)
|
||||
"Return non-nil for STATE's confirmed complete sole-root scroll producer."
|
||||
(let ((producer (plist-get state :native-root-scroll-producer))
|
||||
(box (ebox-native-commit--root-box (plist-get state :root-node))))
|
||||
(and (vectorp producer) (= (length producer) 6) box
|
||||
(equal (aref producer 0) (plist-get box :region-id))
|
||||
(plist-get state :native-sync-session)
|
||||
(plist-get state :native-sync-confirmed-p))))
|
||||
|
||||
(defun ebox-native-commit--region-index-safe-p (state)
|
||||
"Return non-nil when STATE has an unambiguous, non-scrolling region index."
|
||||
(let ((counts (plist-get state :region-box-count-table))
|
||||
(scroll (plist-get state :scroll-state-table))
|
||||
(root-scroll (ebox-native-commit-root-scroll-continuation-p state))
|
||||
(root-region (plist-get (ebox-native-commit--root-box
|
||||
(plist-get state :root-node)) :region-id))
|
||||
safe)
|
||||
(setq safe
|
||||
(and (hash-table-p counts)
|
||||
(or (null scroll)
|
||||
(and (hash-table-p scroll)
|
||||
(zerop (hash-table-count scroll))))))
|
||||
(or (zerop (hash-table-count scroll))
|
||||
(and root-scroll (= (hash-table-count scroll) 1)
|
||||
(gethash root-region scroll)))))))
|
||||
(when safe
|
||||
(maphash
|
||||
(lambda (region-id count)
|
||||
(let ((box (ebox-native-commit--region-box state region-id)))
|
||||
(unless (and (= count 1) box
|
||||
(zerop (or (ebox-get box :scroll-offset) 0)))
|
||||
(or (zerop (or (ebox-get box :scroll-offset) 0))
|
||||
(and root-scroll (equal region-id root-region))))
|
||||
(setq safe nil))))
|
||||
counts))
|
||||
safe))
|
||||
|
||||
(defun ebox-native-commit--install-root-scroll-producer (state producer)
|
||||
"Install complete PRODUCER in STATE's isolated candidate scroll table."
|
||||
(let* ((box (ebox-native-commit--root-box (plist-get state :root-node)))
|
||||
(region (plist-get box :region-id))
|
||||
(previous (plist-get state :native-root-scroll-producer))
|
||||
(previous-scroll (gethash region ebox--scroll-global-state)))
|
||||
(unless (and box (vectorp producer) (= (length producer) 6)
|
||||
(equal region (aref producer 0)))
|
||||
(error "Native scroll producer does not own the actual root"))
|
||||
(when (or (eq (aref producer 4) :reuse) (eq (aref producer 5) :reuse))
|
||||
(unless (and (eq (aref producer 4) :reuse) (eq (aref producer 5) :reuse)
|
||||
(eq (plist-get state :native-frame-kind) 'patch)
|
||||
(ebox-native-commit-root-scroll-continuation-p state)
|
||||
(= (aref producer 1) (aref previous 1))
|
||||
(> (aref previous 1) (aref previous 2))
|
||||
(listp (aref previous 4)) (listp (aref previous 5)))
|
||||
(error "Native scroll producer reuse has no confirmed complete base"))
|
||||
(setq producer (copy-sequence producer))
|
||||
(aset producer 4 (aref previous 4))
|
||||
(aset producer 5 (aref previous 5)))
|
||||
(unless (= (or (ebox-get box :scroll-offset) 0) (aref producer 3))
|
||||
;; Viewport candidates may share their unchanged root. Clamp only a new
|
||||
;; root spine, and compile that normalized offset on the next native job.
|
||||
(let* ((root (copy-sequence (plist-get state :root-node)))
|
||||
(root-id (plist-get root :node-id))
|
||||
(nodes (plist-get state :node-table)))
|
||||
(if (eq (plist-get root :ebox-type) 'flex)
|
||||
(progn (setq box (copy-sequence (plist-get root :box)))
|
||||
(plist-put root :box box))
|
||||
(setq box root))
|
||||
(ebox-put box :scroll-offset (aref producer 3))
|
||||
(plist-put state :root-node root)
|
||||
(plist-put state :node-table
|
||||
(ebox-runtime-index-put root-id root
|
||||
(if (hash-table-p nodes) (copy-hash-table nodes) nodes)))
|
||||
(let ((regions (copy-hash-table (plist-get state :region-box-table))))
|
||||
(puthash region box regions)
|
||||
(plist-put state :region-box-table regions))
|
||||
(plist-put state :native-root-scroll-offset-dirty t)))
|
||||
(plist-put state :native-root-scroll-producer producer)
|
||||
(if (> (aref producer 1) (aref producer 2))
|
||||
(if (and previous-scroll
|
||||
(eq (plist-get previous-scroll :content-lines) (aref producer 4))
|
||||
(eq (plist-get previous-scroll :rendered-content-lines) (aref producer 5)))
|
||||
(let ((scroll-state (copy-sequence previous-scroll)))
|
||||
(plist-put scroll-state :scroll-offset (aref producer 3))
|
||||
(plist-put scroll-state :content-height (aref producer 2))
|
||||
(plist-put scroll-state :box box)
|
||||
(puthash region scroll-state ebox--scroll-global-state))
|
||||
(ebox--scroll-set-state
|
||||
region (list :scroll-offset (aref producer 3)
|
||||
:content-height (aref producer 2)
|
||||
:content-lines (aref producer 4)
|
||||
:rendered-content-lines (aref producer 5)
|
||||
:content-lines-complete-p t :box box)))
|
||||
(ebox--scroll-clear-state region))))
|
||||
|
||||
(defun ebox-native-commit--runtime-types-supported-p (state)
|
||||
"Return non-nil when STATE contains only executable native node types."
|
||||
(let ((nodes (plist-get state :node-table))
|
||||
(supported t))
|
||||
(when (hash-table-p nodes)
|
||||
(maphash
|
||||
(when (ebox-runtime-index-like-p nodes)
|
||||
(ebox-runtime-index-map
|
||||
(lambda (_node-id node)
|
||||
(unless (memq (plist-get node :ebox-type)
|
||||
'(box concat stack flex))
|
||||
(unless (and (memq (plist-get node :ebox-type)
|
||||
'(box concat stack flex))
|
||||
(not (ebox-layer-host-p node))
|
||||
(not (ebox-layer-positioned-p node)))
|
||||
(setq supported nil)))
|
||||
nodes))
|
||||
supported))
|
||||
@ -429,17 +549,27 @@ the committed text/fragment bases, compiler postorder, and projection support."
|
||||
:viewport-height viewport-height
|
||||
:root-width root-width
|
||||
:runtime-revision
|
||||
;; Ebox's internal runtime revision names the TP base revision
|
||||
;; for this candidate. The published TP revision is confirmed
|
||||
;; separately after the transaction commits.
|
||||
(or (plist-get state :runtime-revision) 0)
|
||||
:context-hash
|
||||
(sxhash-equal
|
||||
(list viewport-width viewport-height root-width
|
||||
(plist-get state :display-signature)))
|
||||
;; Viewport and root geometry are matched by their typed fields
|
||||
;; below. Keep this hash for the measurement/display context so
|
||||
;; content changes can still use the confirmed frame as a patch
|
||||
;; base while font/display changes invalidate it.
|
||||
(sxhash-equal (plist-get state :display-signature))
|
||||
:complete t
|
||||
:root-scroll-producer t
|
||||
:root-metadata nil)
|
||||
(when (plist-get state :native-sync-confirmed-p)
|
||||
(list :base-viewport-width viewport-width
|
||||
:base-viewport-height viewport-height
|
||||
:base-root-width root-width))))))
|
||||
(when (and (plist-get state :native-sync-confirmed-p)
|
||||
(plist-get state :native-topology-stable-p))
|
||||
(list :base-viewport-width
|
||||
(plist-get state :native-base-viewport-width)
|
||||
:base-viewport-height
|
||||
(plist-get state :native-base-viewport-height)
|
||||
:base-root-width
|
||||
(plist-get state :native-base-root-width)))))))
|
||||
|
||||
(defun ebox-native-commit--apply-patches (source patches)
|
||||
"Return SOURCE with ordered native PATCHES applied."
|
||||
@ -472,25 +602,32 @@ the committed text/fragment bases, compiler postorder, and projection support."
|
||||
(aset target index record))))
|
||||
target))
|
||||
|
||||
(defun ebox-native-commit--promote-pending-frame (state session)
|
||||
"Promote STATE's previously published pending frame in SESSION."
|
||||
(defun ebox-native-commit-confirm-published-frame (state published-revision)
|
||||
"Confirm STATE's pending native frame at its exact published revision.
|
||||
This function must run after TP installed STATE as client state and while the
|
||||
publication transaction can still roll back."
|
||||
(when-let* ((pending (plist-get state :native-sync-pending)))
|
||||
(ebox-native-reflow-confirm-native-frame
|
||||
session (plist-get pending :generation) (plist-get pending :key)
|
||||
(plist-get pending :confirmed-revision))
|
||||
(plist-put state :native-sync-confirmed-p t)
|
||||
(cl-remf state :native-sync-pending)))
|
||||
(let ((revision published-revision))
|
||||
(unless (and (integerp revision) (>= revision 0)
|
||||
(= revision (plist-get pending :confirmed-revision)))
|
||||
(error "Native published revision %S does not match pending revision %S"
|
||||
revision (plist-get pending :confirmed-revision)))
|
||||
(ebox-native-reflow-confirm-native-frame
|
||||
(plist-get state :native-sync-session)
|
||||
(plist-get pending :generation)
|
||||
(plist-get pending :key)
|
||||
revision)
|
||||
(plist-put state :native-sync-confirmed-p t)
|
||||
(cl-remf state :native-sync-pending)))
|
||||
state)
|
||||
|
||||
(defun ebox-native-commit--retained-frame (state node frame-spec)
|
||||
"Return NODE's full or patch-derived retained native frame for STATE."
|
||||
(let ((session
|
||||
(or (plist-get state :native-sync-session)
|
||||
(let ((created
|
||||
(ebox-native-reflow-create-session
|
||||
:workers 1 :max-jobs 4 :max-results 4)))
|
||||
(let ((created (ebox-native-commit--create-private-session)))
|
||||
(plist-put state :native-sync-session created)
|
||||
created))))
|
||||
(ebox-native-commit--promote-pending-frame state session)
|
||||
(let* ((result
|
||||
(ebox-native-reflow-execute-session-sync
|
||||
session node frame-spec nil state))
|
||||
@ -558,39 +695,102 @@ the committed text/fragment bases, compiler postorder, and projection support."
|
||||
(1+ (or (plist-get state :runtime-revision) 0))))
|
||||
(plist-put state :native-frame-kind
|
||||
(if (plist-get frame :native-patch) 'patch 'full))
|
||||
(cl-remf state :native-local-dirty-entries)
|
||||
frame)))
|
||||
|
||||
(defconst ebox-native-commit--failed-render-state-keys
|
||||
'(:native-sync-session
|
||||
:native-root-scroll-producer
|
||||
:native-root-scroll-offset-dirty
|
||||
:native-sync-pending
|
||||
:native-sync-confirmed-p
|
||||
:native-render-p
|
||||
:native-render-output
|
||||
:native-render-fragments
|
||||
:native-render-frame
|
||||
:native-render-fragment-template
|
||||
:native-render-fallback
|
||||
:native-frame-kind
|
||||
:native-committed-rendered
|
||||
:native-committed-fragment-template
|
||||
:native-committed-owned-ranges
|
||||
:native-coordinate-patches
|
||||
:native-reuse-mount-projection-p
|
||||
:native-reuse-ownership-p
|
||||
:native-inherited-dirty-node-ids
|
||||
:native-local-dirty-entries
|
||||
:native-topology-stable-p
|
||||
:native-touched-node-ids
|
||||
:native-removed-node-ids)
|
||||
"Candidate fields that are invalid after native rendering fails.")
|
||||
|
||||
(defun ebox-native-commit--downgrade-candidate (state reason)
|
||||
"Downgrade STATE after native rendering fails for diagnostic REASON."
|
||||
(let ((session (plist-get state :native-sync-session)))
|
||||
(dolist (key ebox-native-commit--failed-render-state-keys)
|
||||
(cl-remf state key))
|
||||
;; Once native execution begins STATE owns an isolated candidate session.
|
||||
;; Release it before removing the only reference, while containing cleanup
|
||||
;; faults so the ordinary semantic fallback remains the primary result.
|
||||
(when session
|
||||
(let ((inhibit-quit t) (quit-flag nil))
|
||||
(condition-case nil
|
||||
(ebox-native-reflow-release-session session)
|
||||
((error quit) nil)))))
|
||||
(plist-put state :projection-kind nil)
|
||||
;; Surface consumes this once into the transaction report, then removes it
|
||||
;; from runtime state. It never participates in projection eligibility.
|
||||
(plist-put state :native-render-fallback reason)
|
||||
nil)
|
||||
|
||||
(defun ebox-native-commit-render (state node)
|
||||
"Return NODE's native rendered string after committing effects into STATE.
|
||||
Return nil without mutating STATE when the native capability proof is absent."
|
||||
Return nil without mutating STATE when the native capability proof is absent.
|
||||
Once native execution begins, failure downgrades STATE to an ordinary
|
||||
projection so the caller may publish its existing Elisp fallback truthfully."
|
||||
(when (and (require 'ebox-native-reflow nil t)
|
||||
(ebox-native-reflow-layout-ready-p)
|
||||
(eq (plist-get state :projection-kind) 'native-frame)
|
||||
(ebox-native-commit--runtime-types-supported-p state)
|
||||
(ebox-native-commit--region-index-safe-p state))
|
||||
(when-let* ((frame-spec (ebox-native-commit--frame-spec state node)))
|
||||
(when (plist-get state :native-root-scroll-offset-dirty)
|
||||
(let ((root-id (plist-get node :node-id)))
|
||||
(plist-put state :native-touched-node-ids
|
||||
(cons root-id (remove root-id (plist-get state :native-touched-node-ids))))
|
||||
(plist-put state :native-local-dirty-entries
|
||||
(cons (list :node-id root-id :dirty-kind 'geometry
|
||||
:changed-keys '(:scroll-offset))
|
||||
(plist-get state :native-local-dirty-entries))))
|
||||
(cl-remf state :native-root-scroll-offset-dirty))
|
||||
(condition-case err
|
||||
(let* ((frame (ebox-native-commit--retained-frame
|
||||
state node frame-spec))
|
||||
(effects (plist-get frame :effect-tape))
|
||||
(rendered (plist-get frame :rendered))
|
||||
(fragment-template
|
||||
(and (not (plist-get effects :scroll-window-p))
|
||||
(effects (plist-get frame :effect-tape))
|
||||
(rendered (plist-get frame :rendered))
|
||||
(producer (plist-get effects :root-scroll-producer))
|
||||
(fragment-template
|
||||
(and (or (not (plist-get effects :scroll-window-p)) producer)
|
||||
(plist-get effects :fragment-span-template))))
|
||||
(when (and (stringp rendered) (vectorp fragment-template))
|
||||
(ebox-native-commit--install-region-boxes state)
|
||||
(plist-put state :render-owned-text-values
|
||||
(make-hash-table :test #'eq))
|
||||
(plist-put state :native-render-output rendered)
|
||||
(plist-put state :native-render-frame frame)
|
||||
(plist-put state :native-render-fragment-template
|
||||
fragment-template)
|
||||
(plist-put state :native-render-p t)
|
||||
rendered))
|
||||
(if (and (stringp rendered) (vectorp fragment-template))
|
||||
(progn
|
||||
(if producer
|
||||
(ebox-native-commit--install-root-scroll-producer state producer)
|
||||
(cl-remf state :native-root-scroll-producer))
|
||||
(ebox-native-commit--install-region-boxes state)
|
||||
(plist-put state :render-owned-text-values
|
||||
(make-hash-table :test #'eq))
|
||||
(plist-put state :native-render-output rendered)
|
||||
(plist-put state :native-render-frame frame)
|
||||
(plist-put state :native-render-fragment-template
|
||||
fragment-template)
|
||||
(plist-put state :native-render-p t)
|
||||
rendered)
|
||||
(ebox-native-commit--downgrade-candidate
|
||||
state "Native rendering returned an incomplete frame")))
|
||||
(error
|
||||
(plist-put state :native-render-fallback
|
||||
(error-message-string err))
|
||||
nil)))))
|
||||
(ebox-native-commit--downgrade-candidate
|
||||
state (error-message-string err)))))))
|
||||
|
||||
(provide 'ebox-native-commit)
|
||||
;;; ebox-native-commit.el ends here
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -12,8 +12,8 @@
|
||||
(defconst ebox--longhand
|
||||
'(:content ""
|
||||
:box-sizing border-box
|
||||
:width nil :min-width 0 :max-width nil
|
||||
:height nil :min-height 0 :max-height nil
|
||||
:width nil :min-width auto :max-width nil
|
||||
:height nil :min-height auto :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
|
||||
@ -53,7 +53,7 @@ style projection.")
|
||||
(defconst ebox-node-factory--box-longhand
|
||||
'(:content ""
|
||||
:box-sizing border-box
|
||||
:min-width 0 :min-height 0
|
||||
:min-width auto :min-height auto
|
||||
:padding-left-pixel 0 :padding-right-pixel 0
|
||||
:padding-top-height 0 :padding-bottom-height 0
|
||||
:margin-left-pixel 0 :margin-right-pixel 0
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'subr-x)
|
||||
(require 'ebox-runtime-index)
|
||||
|
||||
(define-error 'ebox-patch-plan-error "Invalid Ebox patch plan input")
|
||||
|
||||
@ -115,14 +116,14 @@
|
||||
"Return OWNER-ID's strict ancestors from immutable PARENT-TABLE."
|
||||
(let ((ancestors (make-hash-table :test #'equal))
|
||||
(seen (make-hash-table :test #'equal))
|
||||
(walk (gethash owner-id parent-table)))
|
||||
(walk (ebox-runtime-index-get owner-id parent-table)))
|
||||
(while walk
|
||||
(when (gethash walk seen)
|
||||
(signal 'ebox-patch-plan-error
|
||||
(list :cyclic-parent-table :node-id walk)))
|
||||
(puthash walk t seen)
|
||||
(puthash walk t ancestors)
|
||||
(setq walk (gethash walk parent-table)))
|
||||
(setq walk (ebox-runtime-index-get walk parent-table)))
|
||||
ancestors))
|
||||
|
||||
(defun ebox-patch-plan--make-index ()
|
||||
@ -216,7 +217,7 @@
|
||||
PARENT-TABLE maps a node id to its parent id and is treated as immutable.
|
||||
OPERATIONS are patch artifact plists. This function mutates neither input and
|
||||
has no publication capability."
|
||||
(unless (hash-table-p parent-table)
|
||||
(unless (ebox-runtime-index-like-p parent-table)
|
||||
(signal 'ebox-patch-plan-error
|
||||
(list :malformed-parent-table parent-table)))
|
||||
(unless (proper-list-p operations)
|
||||
|
||||
@ -9,6 +9,13 @@
|
||||
|
||||
(require 'cl-lib)
|
||||
|
||||
(defvar ebox-layer--active-p)
|
||||
(defvar ebox-incremental--render-portals-p)
|
||||
(defvar ebox--render-source-index)
|
||||
|
||||
(declare-function ebox-interaction--refresh-hover! "ebox-interaction"
|
||||
(string start end &optional face))
|
||||
|
||||
(define-error 'ebox-layout-context-error
|
||||
"Ebox layout context is unavailable")
|
||||
|
||||
@ -67,6 +74,16 @@
|
||||
|
||||
(defvar ebox-viewport-width nil)
|
||||
(defvar ebox-viewport-height nil)
|
||||
(defvar ebox--css-viewport-width nil
|
||||
"Root viewport width in pixels, unchanged by nested containing blocks.")
|
||||
(defvar ebox--css-viewport-height nil
|
||||
"Root viewport height in pixels, unchanged by nested containing blocks.")
|
||||
(defvar ebox--css-containing-height nil
|
||||
"Definite containing block content height in pixels, or nil.")
|
||||
(defvar ebox--css-size-context-active-p nil
|
||||
"Non-nil when root CSS viewport dimensions have been captured.")
|
||||
(defvar ebox--css-font-fact nil
|
||||
"Effective font projection for the current layout node.")
|
||||
(defvar ebox--render-runtime-revision nil)
|
||||
(defvar ebox--surface-materialization-active nil)
|
||||
(defvar ebox--render-root-parent-kind nil
|
||||
@ -76,6 +93,7 @@
|
||||
(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--css-viewport-dependent-subtree-cache nil)
|
||||
(defvar ebox--flex-content-min-width-table nil
|
||||
"Render-owned box measurements reusable by native scene compilation.")
|
||||
|
||||
@ -152,7 +170,9 @@ the render boundary."
|
||||
(defun ebox--add-render-face! (string start end face &optional append)
|
||||
"Add Ebox FACE to STRING after capturing its caller baseline."
|
||||
(ebox--capture-paint-origins! string start end)
|
||||
(add-face-text-property start end face append string))
|
||||
(add-face-text-property start end face append string)
|
||||
(when (fboundp 'ebox-interaction--refresh-hover!)
|
||||
(ebox-interaction--refresh-hover! string start end)))
|
||||
|
||||
(defun ebox--strip-paint-origins! (string)
|
||||
"Remove transient caller-face carriers from STRING before it escapes.
|
||||
@ -242,8 +262,24 @@ including strings returned from a render-cache hit."
|
||||
(plist-get ,render-state :viewport-width))
|
||||
(ebox-viewport-height
|
||||
(plist-get ,render-state :viewport-height))
|
||||
(ebox--css-viewport-width ebox-viewport-width)
|
||||
(ebox--css-viewport-height
|
||||
(and (numberp ebox-viewport-height)
|
||||
(* ebox-viewport-height (frame-char-height))))
|
||||
(ebox--css-containing-height ebox--css-viewport-height)
|
||||
(ebox--css-size-context-active-p t)
|
||||
(ebox--render-runtime-revision
|
||||
(plist-get ,render-state :runtime-revision))
|
||||
(ebox--render-source-index
|
||||
(plist-get ,render-state :source-index))
|
||||
(ebox-layer--active-p
|
||||
(if (plist-member ,render-state :layered-p)
|
||||
(plist-get ,render-state :layered-p)
|
||||
'unknown))
|
||||
(ebox-incremental--render-portals-p
|
||||
(if (plist-member ,render-state :root-portals-p)
|
||||
(plist-get ,render-state :root-portals-p)
|
||||
'unknown))
|
||||
(ebox--surface-materialization-active t)
|
||||
(ebox--render-cache-table
|
||||
(plist-get ,render-state :render-cache))
|
||||
@ -257,6 +293,9 @@ including strings returned from a render-cache hit."
|
||||
(ebox--viewport-dependent-subtree-cache
|
||||
(or ebox--viewport-dependent-subtree-cache
|
||||
(make-hash-table :test 'eq)))
|
||||
(ebox--css-viewport-dependent-subtree-cache
|
||||
(or ebox--css-viewport-dependent-subtree-cache
|
||||
(make-hash-table :test 'eq)))
|
||||
(ebox--viewport-height-dependent-subtree-cache
|
||||
(or ebox--viewport-height-dependent-subtree-cache
|
||||
(plist-get
|
||||
|
||||
237
ebox-runtime-index.el
Normal file
237
ebox-runtime-index.el
Normal file
@ -0,0 +1,237 @@
|
||||
;;; ebox-runtime-index.el --- Immutable runtime indexes for Ebox -*- lexical-binding: t; -*-
|
||||
|
||||
;; SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; Compressed persistent radix maps used by runtime state. Branches retain
|
||||
;; only nibble positions where codes diverge; leaves hold exact equal-key
|
||||
;; collision buckets. Adapters continue to accept legacy hash tables.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
|
||||
(cl-defstruct (ebox-runtime-index
|
||||
(:constructor ebox-runtime-index--create
|
||||
(root count key-set-token)))
|
||||
"Immutable compressed radix index."
|
||||
root
|
||||
count
|
||||
key-set-token)
|
||||
|
||||
(defun ebox-runtime-index-empty ()
|
||||
"Return an empty immutable runtime index."
|
||||
(ebox-runtime-index--create nil 0 nil))
|
||||
|
||||
(defun ebox-runtime-index-like-p (object)
|
||||
"Return non-nil when OBJECT is a supported runtime index."
|
||||
(or (ebox-runtime-index-p object) (hash-table-p object)))
|
||||
|
||||
(defun ebox-runtime-index--code (key)
|
||||
"Return a stable 64-bit radix code for KEY."
|
||||
(logand (if (and (integerp key) (>= key 0)) key (sxhash-equal key))
|
||||
#xffffffffffffffff))
|
||||
|
||||
(defun ebox-runtime-index--branch (shift code &optional original)
|
||||
"Return a fresh branch for nibble SHIFT and representative CODE.
|
||||
Copy ORIGINAL when supplied; its metadata already carries SHIFT and CODE.
|
||||
All branch allocations, including path copies, pass through this function."
|
||||
(if original
|
||||
(copy-sequence original)
|
||||
(let ((branch (make-vector 18 nil)))
|
||||
(aset branch 0 shift)
|
||||
(aset branch 1 code)
|
||||
branch)))
|
||||
|
||||
(defun ebox-runtime-index--join (left left-code right right-code)
|
||||
"Join LEFT and RIGHT above the first differing nibble of their codes.
|
||||
LEFT-CODE and RIGHT-CODE are distinct 64-bit representative codes."
|
||||
(let* ((difference (logxor left-code right-code))
|
||||
(shift (logand (logcount (1- (logand difference (- difference))))
|
||||
-4))
|
||||
(branch (ebox-runtime-index--branch shift left-code)))
|
||||
(aset branch (+ 2 (logand (ash left-code (- shift)) 15)) left)
|
||||
(aset branch (+ 2 (logand (ash right-code (- shift)) 15)) right)
|
||||
branch))
|
||||
|
||||
(defun ebox-runtime-index--root-get (root key missing)
|
||||
"Return KEY from persistent ROOT, or MISSING."
|
||||
(let ((node root)
|
||||
(code (ebox-runtime-index--code key)))
|
||||
(while (vectorp node)
|
||||
(setq node (aref node (+ 2 (logand (ash code (- (aref node 0))) 15)))))
|
||||
(if (and node (= code (car node)))
|
||||
(let ((pair (assoc key (cdr node))))
|
||||
(if pair (cdr pair) missing))
|
||||
missing)))
|
||||
|
||||
(defun ebox-runtime-index-get (key index &optional missing)
|
||||
"Return KEY's value from INDEX, or MISSING.
|
||||
INDEX may be an immutable runtime index or a legacy hash table."
|
||||
(cond
|
||||
((ebox-runtime-index-p index)
|
||||
(ebox-runtime-index--root-get (ebox-runtime-index-root index) key missing))
|
||||
((hash-table-p index) (gethash key index missing))
|
||||
(t missing)))
|
||||
|
||||
(defun ebox-runtime-index-contains-p (key index)
|
||||
"Return non-nil when INDEX contains KEY, including a nil value."
|
||||
(let ((missing (make-symbol "ebox-runtime-index-missing")))
|
||||
(not (eq (ebox-runtime-index-get key index missing) missing))))
|
||||
|
||||
(defun ebox-runtime-index--root-put (root key value code)
|
||||
"Return `(ROOT . ADDED)' after mapping KEY to VALUE using CODE."
|
||||
(cond
|
||||
((null root) (cons (cons code (list (cons key value))) t))
|
||||
((vectorp root)
|
||||
(let ((shift (aref root 0))
|
||||
(representative (aref root 1)))
|
||||
(if (/= (logand (logxor code representative) (1- (ash 1 shift))) 0)
|
||||
(cons (ebox-runtime-index--join
|
||||
root representative (cons code (list (cons key value))) code)
|
||||
t)
|
||||
(let* ((slot (+ 2 (logand (ash code (- shift)) 15)))
|
||||
(result (ebox-runtime-index--root-put
|
||||
(aref root slot) key value code))
|
||||
(copy (ebox-runtime-index--branch shift representative root)))
|
||||
(aset copy slot (car result))
|
||||
(cons copy (cdr result))))))
|
||||
((= code (car root))
|
||||
(let* ((bucket (cdr root))
|
||||
(present (assoc key bucket)))
|
||||
(cons (cons code
|
||||
(cons (cons key value)
|
||||
(if present
|
||||
(cl-remove key bucket :key #'car :test #'equal)
|
||||
bucket)))
|
||||
(not present))))
|
||||
(t (cons (ebox-runtime-index--join
|
||||
root (car root) (cons code (list (cons key value))) code)
|
||||
t))))
|
||||
|
||||
(defun ebox-runtime-index-put (key value index)
|
||||
"Return INDEX with KEY mapped to VALUE.
|
||||
Immutable indexes return a new value. Legacy hash tables are mutated and
|
||||
returned for compatibility."
|
||||
(cond
|
||||
((ebox-runtime-index-p index)
|
||||
(let ((result
|
||||
(ebox-runtime-index--root-put
|
||||
(ebox-runtime-index-root index) key value
|
||||
(ebox-runtime-index--code key))))
|
||||
(ebox-runtime-index--create
|
||||
(car result)
|
||||
(+ (ebox-runtime-index-count index) (if (cdr result) 1 0))
|
||||
(if (cdr result)
|
||||
(make-symbol "ebox-runtime-index-key-set")
|
||||
(ebox-runtime-index-key-set-token index)))))
|
||||
((hash-table-p index) (puthash key value index) index)
|
||||
(t (signal 'wrong-type-argument (list 'ebox-runtime-index-like-p index)))))
|
||||
|
||||
(defun ebox-runtime-index--root-delete (root key code)
|
||||
"Return `(ROOT . REMOVED)' after deleting KEY using CODE."
|
||||
(cond
|
||||
((null root) (cons nil nil))
|
||||
((vectorp root)
|
||||
(let* ((shift (aref root 0))
|
||||
(slot (+ 2 (logand (ash code (- shift)) 15)))
|
||||
(result (ebox-runtime-index--root-delete (aref root slot) key code)))
|
||||
(if (not (cdr result))
|
||||
(cons root nil)
|
||||
(let ((children 0) survivor)
|
||||
(unless (car result)
|
||||
(dotimes (offset 16)
|
||||
(let ((position (+ offset 2)))
|
||||
(when (and (/= position slot) (aref root position))
|
||||
(cl-incf children)
|
||||
(setq survivor (aref root position))))))
|
||||
(if (= children 1)
|
||||
(cons survivor t)
|
||||
(let ((copy (ebox-runtime-index--branch shift (aref root 1) root)))
|
||||
(aset copy slot (car result))
|
||||
(cons copy t)))))))
|
||||
((and (= code (car root)) (assoc key (cdr root)))
|
||||
(let ((bucket (cl-remove key (cdr root) :key #'car :test #'equal)))
|
||||
(cons (and bucket (cons code bucket)) t)))
|
||||
(t (cons root nil))))
|
||||
|
||||
(defun ebox-runtime-index-delete (key index)
|
||||
"Return INDEX without KEY.
|
||||
Immutable indexes return a new value. Legacy hash tables are mutated and
|
||||
returned for compatibility."
|
||||
(cond
|
||||
((ebox-runtime-index-p index)
|
||||
(let ((result
|
||||
(ebox-runtime-index--root-delete
|
||||
(ebox-runtime-index-root index) key
|
||||
(ebox-runtime-index--code key))))
|
||||
(if (cdr result)
|
||||
(let ((count (1- (ebox-runtime-index-count index))))
|
||||
(ebox-runtime-index--create
|
||||
(car result) count
|
||||
(and (> count 0)
|
||||
(make-symbol "ebox-runtime-index-key-set"))))
|
||||
index)))
|
||||
((hash-table-p index) (remhash key index) index)
|
||||
(t (signal 'wrong-type-argument (list 'ebox-runtime-index-like-p index)))))
|
||||
|
||||
(defun ebox-runtime-index-size (index)
|
||||
"Return the number of entries in INDEX."
|
||||
(cond
|
||||
((ebox-runtime-index-p index) (ebox-runtime-index-count index))
|
||||
((hash-table-p index) (hash-table-count index))
|
||||
(t 0)))
|
||||
|
||||
(defun ebox-runtime-index-key-set-equal-p (left right)
|
||||
"Return non-nil when supported indexes LEFT and RIGHT have equal keys."
|
||||
(let ((persistent-shared-keys-p
|
||||
(and (ebox-runtime-index-p left)
|
||||
(ebox-runtime-index-p right)
|
||||
(= (ebox-runtime-index-count left)
|
||||
(ebox-runtime-index-count right))
|
||||
(eq (ebox-runtime-index-key-set-token left)
|
||||
(ebox-runtime-index-key-set-token right)))))
|
||||
(cond
|
||||
(persistent-shared-keys-p t)
|
||||
((and (ebox-runtime-index-like-p left)
|
||||
(ebox-runtime-index-like-p right)
|
||||
(= (ebox-runtime-index-size left) (ebox-runtime-index-size right)))
|
||||
(let ((equal-p t)
|
||||
(missing (make-symbol "ebox-runtime-index-missing")))
|
||||
(ebox-runtime-index-map
|
||||
(lambda (key _value)
|
||||
(when (eq (ebox-runtime-index-get key right missing) missing)
|
||||
(setq equal-p nil)))
|
||||
left)
|
||||
equal-p))
|
||||
(t nil))))
|
||||
|
||||
(defun ebox-runtime-index--map-root (function root)
|
||||
"Call FUNCTION for entries below ROOT, visiting only live trie nodes."
|
||||
(when root
|
||||
(if (vectorp root)
|
||||
(dotimes (branch 16)
|
||||
(let ((child (aref root (+ 2 branch))))
|
||||
(when child (ebox-runtime-index--map-root function child))))
|
||||
(dolist (pair (cdr root)) (funcall function (car pair) (cdr pair))))))
|
||||
|
||||
(defun ebox-runtime-index-map (function index)
|
||||
"Call FUNCTION with every key and value in INDEX."
|
||||
(cond
|
||||
((ebox-runtime-index-p index)
|
||||
(ebox-runtime-index--map-root
|
||||
function (ebox-runtime-index-root index)))
|
||||
((hash-table-p index) (maphash function index))
|
||||
(t (signal 'wrong-type-argument (list 'ebox-runtime-index-like-p index))))
|
||||
nil)
|
||||
|
||||
(defun ebox-runtime-index-keys (index)
|
||||
"Return a list of keys in INDEX."
|
||||
(let (keys)
|
||||
(ebox-runtime-index-map (lambda (key _value) (push key keys)) index)
|
||||
keys))
|
||||
|
||||
(provide 'ebox-runtime-index)
|
||||
|
||||
;;; ebox-runtime-index.el ends here
|
||||
@ -18,6 +18,7 @@
|
||||
(declare-function ebox--ensure-region-id "ebox" (box))
|
||||
(declare-function ebox--buffer-root-node "ebox-incremental" (buffer))
|
||||
(declare-function ebox--buffer-render-state "ebox-incremental" (buffer))
|
||||
(declare-function ebox--buffer-source-index "ebox-incremental" (buffer))
|
||||
(declare-function ebox--buffer-selector-id-table "ebox-incremental" (buffer))
|
||||
(declare-function ebox--buffer-selector-class-table
|
||||
"ebox-incremental" (buffer))
|
||||
@ -81,6 +82,7 @@
|
||||
(let* ((buffer (ebox-selector--region-handle-buffer handle))
|
||||
(surface (ebox-selector--region-handle-surface handle))
|
||||
(object (ebox-selector--region-handle-object handle))
|
||||
(logical-id (ebox-selector--region-handle-logical-id handle))
|
||||
(state (and (buffer-live-p buffer)
|
||||
(ebox--buffer-render-state buffer)))
|
||||
(region-id
|
||||
@ -88,6 +90,12 @@
|
||||
(eq surface (plist-get state :surface))
|
||||
(tp-surface-live-p surface)
|
||||
(tp-object-live-p object)
|
||||
;; A retained positional object can be reused for a different
|
||||
;; source node. A named handle must still name this object.
|
||||
(or (null logical-id)
|
||||
(assq object
|
||||
(gethash (ebox-tree-metadata-string logical-id)
|
||||
(plist-get state :logical-id-region-table))))
|
||||
(gethash object
|
||||
(plist-get state :surface-object-region-table)))))
|
||||
(unless region-id
|
||||
|
||||
230
ebox-size.el
Normal file
230
ebox-size.el
Normal file
@ -0,0 +1,230 @@
|
||||
;;; ebox-size.el --- Explicit CSS length values for Ebox -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Pure validation and pixel resolution for (UNIT NUMBER) lengths and
|
||||
;; calc/min/max/clamp expressions. Callers supply measured context and own
|
||||
;; property keywords, range constraints, and final display quantization.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
|
||||
(define-error 'ebox-size-error "Invalid Ebox size")
|
||||
|
||||
(defconst ebox-size-axis-units
|
||||
'((inline px ch vw %)
|
||||
(block lh vh %)
|
||||
(stroke px ch lh vw vh)
|
||||
(length px ch vw lh vh %))
|
||||
"Allowed units for each Ebox size domain.
|
||||
Inline and block lengths follow the renderer's horizontal and line axes.
|
||||
Stroke thickness is a paint measurement independent of edge orientation.
|
||||
Length is only for declarations whose parent-dependent axis is not known yet.")
|
||||
|
||||
(defun ebox-size--finite-number-p (value)
|
||||
"Return non-nil if VALUE is a finite real number."
|
||||
(and (numberp value)
|
||||
(or (integerp value) (= (- value value) 0.0))))
|
||||
|
||||
(defun ebox-size--fail (value reason)
|
||||
"Reject VALUE with a size error explaining REASON."
|
||||
(signal 'ebox-size-error (list reason value)))
|
||||
|
||||
(defun ebox-size--checked-number (value source)
|
||||
"Return finite VALUE, or reject SOURCE for arithmetic overflow."
|
||||
(unless (ebox-size--finite-number-p value)
|
||||
(ebox-size--fail source "Expected a finite number"))
|
||||
value)
|
||||
|
||||
(defun ebox-size--unit (value context)
|
||||
"Read a unit VALUE using pixel measurements in CONTEXT.
|
||||
Return (DIMENSION PIXELS DEPENDENCIES), with nil PIXELS when unresolved."
|
||||
(unless (and (= (length value) 2)
|
||||
(ebox-size--finite-number-p (cadr value)))
|
||||
(ebox-size--fail value "Use (unit finite-number)"))
|
||||
(let* ((unit (car value))
|
||||
(amount (cadr value))
|
||||
(dependency (pcase unit
|
||||
('% 'percent)
|
||||
('vw 'viewport-width)
|
||||
('vh 'viewport-height)
|
||||
('ch 'ch)
|
||||
('lh 'lh)))
|
||||
(base (pcase unit
|
||||
('px 1)
|
||||
('% (plist-get context :percent-base))
|
||||
('vw (plist-get context :viewport-width))
|
||||
('vh (plist-get context :viewport-height))
|
||||
('ch (plist-get context :ch))
|
||||
('lh (plist-get context :lh)))))
|
||||
(when (and base
|
||||
(not (and (ebox-size--finite-number-p base) (>= base 0))))
|
||||
(ebox-size--fail value "Context measurements must be finite and nonnegative"))
|
||||
(list 'length
|
||||
(when base
|
||||
(ebox-size--checked-number
|
||||
(if (memq unit '(% vw vh))
|
||||
(/ (* amount base) 100.0)
|
||||
(* amount base))
|
||||
value))
|
||||
(when dependency (list dependency)))))
|
||||
|
||||
(defun ebox-size--same-dimension (arguments source)
|
||||
"Return the shared dimension of ARGUMENTS, or reject SOURCE."
|
||||
(let ((dimension (caar arguments)))
|
||||
(unless (cl-every (lambda (argument) (eq (car argument) dimension))
|
||||
arguments)
|
||||
(ebox-size--fail source "Cannot add or compare numbers and lengths"))
|
||||
dimension))
|
||||
|
||||
(defun ebox-size--math (value context)
|
||||
"Read mathematical VALUE in CONTEXT without evaluating Lisp."
|
||||
(let* ((operator (car value))
|
||||
(count (length (cdr value))))
|
||||
(unless (pcase operator
|
||||
((or '* '/) (= count 2))
|
||||
('clamp (= count 3))
|
||||
('calc (= count 1))
|
||||
(_ (> count 0)))
|
||||
(ebox-size--fail value "Wrong number of size function arguments"))
|
||||
(let* ((arguments (mapcar (lambda (argument)
|
||||
(ebox-size--read argument context t))
|
||||
(cdr value)))
|
||||
(numbers (mapcar #'cadr arguments))
|
||||
(dimensions (mapcar #'car arguments))
|
||||
(dimension
|
||||
(pcase operator
|
||||
('*
|
||||
(when (> (cl-count 'length dimensions) 1)
|
||||
(ebox-size--fail value "Multiplication needs a scalar operand"))
|
||||
(if (memq 'length dimensions) 'length 'number))
|
||||
('/
|
||||
(unless (eq (cadr dimensions) 'number)
|
||||
(ebox-size--fail value "Division needs a scalar divisor"))
|
||||
(when (zerop (cadr numbers))
|
||||
(ebox-size--fail value "Division by zero"))
|
||||
(car dimensions))
|
||||
(_ (ebox-size--same-dimension arguments value))))
|
||||
(dependencies (delete-dups
|
||||
(apply #'append (mapcar #'caddr arguments)))))
|
||||
(list dimension
|
||||
(when (cl-every #'numberp numbers)
|
||||
(ebox-size--checked-number
|
||||
(pcase operator
|
||||
('calc (car numbers))
|
||||
('+ (apply #'+ numbers))
|
||||
('- (apply #'- numbers))
|
||||
('* (apply #'* numbers))
|
||||
('/ (/ (float (car numbers)) (cadr numbers)))
|
||||
('min (apply #'min numbers))
|
||||
('max (apply #'max numbers))
|
||||
('clamp (max (nth 0 numbers)
|
||||
(min (nth 1 numbers) (nth 2 numbers)))))
|
||||
value))
|
||||
dependencies))))
|
||||
|
||||
(defun ebox-size--read (value context in-math)
|
||||
"Read VALUE with CONTEXT and scalar permission IN-MATH.
|
||||
The result is (DIMENSION PIXELS DEPENDENCIES); nil PIXELS is unresolved."
|
||||
(cond
|
||||
((and in-math (ebox-size--finite-number-p value))
|
||||
(list 'number value nil))
|
||||
((not (and (consp value) (proper-list-p value)))
|
||||
(ebox-size--fail value "Use an explicit (unit number) length or size function"))
|
||||
((memq (car value) '(px % vw vh ch lh))
|
||||
(ebox-size--unit value context))
|
||||
((or (memq (car value) '(calc min max clamp))
|
||||
(and in-math (memq (car value) '(+ - * /))))
|
||||
(ebox-size--math value context))
|
||||
(t (ebox-size--fail value "Unknown size unit or function"))))
|
||||
|
||||
(defun ebox-size--parse (value context)
|
||||
"Read a length VALUE with CONTEXT, rejecting dimensionless results."
|
||||
(let ((result (ebox-size--read value context nil)))
|
||||
(unless (eq (car result) 'length)
|
||||
(ebox-size--fail value "A size expression must produce a length"))
|
||||
result))
|
||||
|
||||
(cl-defun ebox-size-value-p (value &optional allow-negative (allow-percent t))
|
||||
"Return non-nil when VALUE is a supported length or size expression.
|
||||
Supported units are px, %, vw, vh, ch, and lh, each written (UNIT NUMBER).
|
||||
Supported functions are calc, min, max, and clamp. Inside functions,
|
||||
+ and - combine like dimensions; * and / accept scalar operands.
|
||||
Bare numbers, old singleton pixel lists, and keywords are not lengths.
|
||||
|
||||
ALLOW-NEGATIVE permits negative literal lengths; arithmetic intermediates
|
||||
may always be negative. Callers enforce the resolved property range.
|
||||
ALLOW-PERCENT defaults to t; explicit nil forbids percentages anywhere
|
||||
in the expression, for properties such as border widths."
|
||||
(condition-case nil
|
||||
(let ((result (ebox-size--parse value nil)))
|
||||
(and (or allow-negative
|
||||
(not (memq (car value) '(px % vw vh ch lh)))
|
||||
(>= (cadr value) 0))
|
||||
(or allow-percent (not (memq 'percent (caddr result))))
|
||||
t))
|
||||
(error nil)))
|
||||
|
||||
(defun ebox-size-validate-for-axis (value property axis)
|
||||
"Return length VALUE after validating PROPERTY's size AXIS.
|
||||
AXIS is inline, block, stroke, or length as in `ebox-size-axis-units'.
|
||||
Every unit inside calc, min, max, and clamp must be allowed on that axis,
|
||||
including operands whose value would later cancel out. Invalid syntax,
|
||||
negative literals, and forbidden units signal `ebox-size-error' with the
|
||||
property name and allowed units. Keywords are validated by the property."
|
||||
(let ((allowed (or (cdr (assq axis ebox-size-axis-units))
|
||||
(error "Unknown Ebox size axis: %S" axis))))
|
||||
(cl-labels ((units-valid-p (expression)
|
||||
(or (atom expression)
|
||||
(if (memq (car expression) '(px ch vw lh vh %))
|
||||
(memq (car expression) allowed)
|
||||
(cl-every #'units-valid-p (cdr expression))))))
|
||||
(unless (and (ebox-size-value-p value nil (memq '% allowed))
|
||||
(units-valid-p value))
|
||||
(signal 'ebox-size-error
|
||||
(list (format "Invalid value for %S; allowed units: %S"
|
||||
property allowed)
|
||||
value)))))
|
||||
value)
|
||||
|
||||
(defun ebox-size-value-for-axis-p (value axis)
|
||||
"Return non-nil when length VALUE is valid for size AXIS."
|
||||
(condition-case nil
|
||||
(progn (ebox-size-validate-for-axis value 'size axis) t)
|
||||
(ebox-size-error nil)))
|
||||
|
||||
(defun ebox-size-resolve (value context)
|
||||
"Resolve length VALUE to pixels using CONTEXT, or nil if unresolved.
|
||||
CONTEXT is a plist of pixel measurements: :percent-base is the containing
|
||||
block reference, :viewport-width and :viewport-height are viewport sizes,
|
||||
and :ch and :lh are the effective font's zero advance and line height.
|
||||
Missing or nil measurements defer resolution; zero is a valid measurement.
|
||||
Fractional and negative results are preserved. The caller owns range
|
||||
clamping and display rounding. Invalid VALUE signals `ebox-size-error'."
|
||||
(cadr (ebox-size--parse value context)))
|
||||
|
||||
(defun ebox-size-dependencies (value)
|
||||
"Return the measurements required by length VALUE in first-use order.
|
||||
Possible symbols are percent, viewport-width, viewport-height, ch, and lh.
|
||||
Each appears at most once. Invalid VALUE signals `ebox-size-error'."
|
||||
(caddr (ebox-size--parse value nil)))
|
||||
|
||||
(defun ebox-size-quantize (values)
|
||||
"Quantize nonnegative VALUES at cumulative display boundaries.
|
||||
The returned whole extents preserve the floor of the combined extent instead
|
||||
of losing each separate fraction. A small tolerance absorbs floating-point
|
||||
error at integer boundaries. This is a display operation, not CSS arithmetic."
|
||||
(let ((position 0.0) (previous 0))
|
||||
(mapcar
|
||||
(lambda (value)
|
||||
(unless (and (ebox-size--finite-number-p value) (>= value 0))
|
||||
(ebox-size--fail value "Display extents must be finite and nonnegative"))
|
||||
(setq position (ebox-size--checked-number (+ position value) values))
|
||||
(let* ((boundary (floor (+ position 1e-9)))
|
||||
(extent (- boundary previous)))
|
||||
(setq previous boundary)
|
||||
extent))
|
||||
values)))
|
||||
|
||||
(provide 'ebox-size)
|
||||
;;; ebox-size.el ends here
|
||||
@ -9,11 +9,12 @@
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'subr-x)
|
||||
(require 'ebox-interaction)
|
||||
|
||||
(cl-defstruct (ebox-source-record
|
||||
(:constructor ebox-source--make-record))
|
||||
"Immutable facts supplied by one author source."
|
||||
key id classes attributes states declarations provenance)
|
||||
key id classes attributes states declarations provenance interactions)
|
||||
|
||||
(cl-defstruct (ebox-source-handle
|
||||
(:constructor ebox-source--make-handle)
|
||||
@ -45,7 +46,7 @@
|
||||
"Bounded persistent document-order handle delta."
|
||||
base replacements removed appended local-handles handles depth)
|
||||
|
||||
(defconst ebox-source--persistent-max-depth 32
|
||||
(defconst ebox-source--persistent-max-depth 4
|
||||
"Maximum SourceIndex overlay depth before exact compaction.")
|
||||
|
||||
(defconst ebox-source--memo-missing (make-symbol "ebox-source-memo-missing")
|
||||
@ -54,6 +55,18 @@
|
||||
(defconst ebox-source--memo-unset (make-symbol "ebox-source-memo-unset")
|
||||
"Private marker for a persistent-table key not present in its memo.")
|
||||
|
||||
(defconst ebox-source--lookup-missing
|
||||
(make-symbol "ebox-source-lookup-missing")
|
||||
"Private marker for an absent persistent-table lookup.")
|
||||
|
||||
(defconst ebox-source--local-missing
|
||||
(make-symbol "ebox-source-local-missing")
|
||||
"Private marker for an absent binding in one table overlay.")
|
||||
|
||||
(defconst ebox-source--order-missing
|
||||
(make-symbol "ebox-source-order-missing")
|
||||
"Private marker for a handle without an order replacement.")
|
||||
|
||||
(defun ebox-source--flat-table (hash test)
|
||||
"Seal HASH as a flat immutable source table using TEST."
|
||||
(ebox-source--make-table
|
||||
@ -72,9 +85,9 @@
|
||||
"Return KEY's value from persistent or flat TABLE, or nil."
|
||||
(cond
|
||||
((ebox-source-table-p table)
|
||||
(let ((missing (make-symbol "ebox-source-table-missing")))
|
||||
(let ((value (ebox-source--table-get table key missing)))
|
||||
(unless (eq value missing) value))))
|
||||
(let ((value
|
||||
(ebox-source--table-get table key ebox-source--lookup-missing)))
|
||||
(unless (eq value ebox-source--lookup-missing) value)))
|
||||
((hash-table-p table) (gethash key table))
|
||||
((null table) nil)
|
||||
(t (signal 'wrong-type-argument (list 'hash-table-p table)))))
|
||||
@ -94,12 +107,12 @@
|
||||
(and memo (gethash key memo ebox-source--memo-unset))))
|
||||
(if (and memo (not (eq memoized ebox-source--memo-unset)))
|
||||
(if (eq memoized ebox-source--memo-missing) missing memoized)
|
||||
(let* ((local-missing (make-symbol "ebox-source-local-missing"))
|
||||
(local
|
||||
(gethash key (ebox-source-table-added table) local-missing))
|
||||
(let* ((local
|
||||
(gethash key (ebox-source-table-added table)
|
||||
ebox-source--local-missing))
|
||||
(resolved
|
||||
(cond
|
||||
((not (eq local local-missing)) local)
|
||||
((not (eq local ebox-source--local-missing)) local)
|
||||
((gethash key (ebox-source-table-removed table))
|
||||
ebox-source--memo-missing)
|
||||
((ebox-source-table-base table)
|
||||
@ -126,8 +139,8 @@
|
||||
|
||||
(defun ebox-source--table-member-p (table key)
|
||||
"Return non-nil when persistent TABLE contains KEY."
|
||||
(let ((missing (make-symbol "ebox-source-missing")))
|
||||
(not (eq (ebox-source--table-get table key missing) missing))))
|
||||
(not (eq (ebox-source--table-get table key ebox-source--lookup-missing)
|
||||
ebox-source--lookup-missing)))
|
||||
|
||||
(defun ebox-source--table-overlay (base added removed count)
|
||||
"Return bounded persistent BASE overlaid by ADDED and REMOVED with COUNT."
|
||||
@ -146,11 +159,13 @@
|
||||
|
||||
(defun ebox-source--order-apply (handles replacements removed appended)
|
||||
"Apply order delta to HANDLES and return a fresh exact order."
|
||||
(let ((missing (make-symbol "ebox-source-order-missing")) result)
|
||||
(let (result)
|
||||
(dolist (handle handles)
|
||||
(let ((replacement (gethash handle replacements missing)))
|
||||
(let ((replacement
|
||||
(gethash handle replacements ebox-source--order-missing)))
|
||||
(cond
|
||||
((not (eq replacement missing)) (push replacement result))
|
||||
((not (eq replacement ebox-source--order-missing))
|
||||
(push replacement result))
|
||||
((gethash handle removed) nil)
|
||||
(t (push handle result)))))
|
||||
(append (nreverse result) (copy-sequence appended))))
|
||||
@ -216,6 +231,11 @@ Functions and records are opaque values and retain identity."
|
||||
copy))
|
||||
(t value)))
|
||||
|
||||
(defun ebox-source-index-interactions (index handle)
|
||||
"Return detached fixed interaction facts for HANDLE in INDEX."
|
||||
(when-let* ((record (ebox-source--index-record index handle)))
|
||||
(tp-property-value-copy (ebox-source-record-interactions record))))
|
||||
|
||||
(defun ebox-source-stable-key-p (value)
|
||||
"Return non-nil when VALUE is one public stable source key."
|
||||
(and value (or (symbolp value) (stringp value) (integerp value))))
|
||||
@ -312,7 +332,7 @@ Functions and records are opaque values and retain identity."
|
||||
|
||||
(cl-defun ebox-source-builder-bind
|
||||
(builder &key identity key id class selector-attributes selector-state
|
||||
declarations provenance)
|
||||
declarations provenance interactions)
|
||||
"Bind normalized source facts in BUILDER and return an opaque handle.
|
||||
The returned handle contains identity only; all facts are owned by BUILDER
|
||||
until `ebox-source-builder-finish' seals them into a source index."
|
||||
@ -337,7 +357,8 @@ until `ebox-source-builder-finish' seals them into a source index."
|
||||
:attributes (ebox-source--attributes selector-attributes)
|
||||
:states (ebox-source--metadata-tokens selector-state)
|
||||
:declarations (ebox-source--declarations declarations)
|
||||
:provenance (ebox-source--copy-data provenance))))
|
||||
:provenance (ebox-source--copy-data provenance)
|
||||
:interactions (ebox-interaction-normalize interactions))))
|
||||
(ebox-source--builder-add-record builder handle record)))
|
||||
|
||||
(defun ebox-source--builder-record (builder handle)
|
||||
@ -365,6 +386,7 @@ error because one canonical forest cannot contain the same source twice."
|
||||
(selector-attributes nil attributes-p)
|
||||
(selector-state nil states-p)
|
||||
(declarations nil declarations-p)
|
||||
(interactions nil interactions-p)
|
||||
(provenance nil provenance-p))
|
||||
"Return `(NEW-INDEX . NEW-HANDLE)' after rebinding HANDLE in INDEX.
|
||||
The semantic identity and all omitted immutable facts are retained. A fresh
|
||||
@ -389,6 +411,8 @@ facts on the canonical node."
|
||||
:declarations (if declarations-p
|
||||
declarations
|
||||
(ebox-source-record-declarations record))
|
||||
:interactions (if interactions-p interactions
|
||||
(ebox-source-record-interactions record))
|
||||
:provenance
|
||||
(if provenance-p provenance
|
||||
(ebox-source-record-provenance record))))
|
||||
@ -728,7 +752,9 @@ for nonstructural changes; structural changes supply FINAL-ORDER."
|
||||
:declarations (ebox-source--copy-data
|
||||
(ebox-source-record-declarations record))
|
||||
:provenance (ebox-source--copy-data
|
||||
(ebox-source-record-provenance record)))))
|
||||
(ebox-source-record-provenance record))
|
||||
:interactions (tp-property-value-copy
|
||||
(ebox-source-record-interactions record)))))
|
||||
|
||||
(defun ebox-source-index-record (index handle)
|
||||
"Return a detached source record for HANDLE in INDEX, or nil."
|
||||
|
||||
162
ebox-spi.el
162
ebox-spi.el
@ -4,10 +4,10 @@
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; Additive provider-side Ebox framework SPI v2. This module publishes an
|
||||
;; Provider-side Ebox framework SPI v2. This module publishes an
|
||||
;; immutable capability snapshot and delegates initial/update execution to the
|
||||
;; existing combined Ebox participant. It does not select a consumer port,
|
||||
;; probe a framework package, or remove the v1 callback surface.
|
||||
;; or probe a framework package.
|
||||
|
||||
;;; Code:
|
||||
|
||||
@ -16,6 +16,13 @@
|
||||
(require 'ebox-surface)
|
||||
(require 'tp-transaction)
|
||||
|
||||
(defconst ebox-framework-spi-required-tp-version "2.0.0"
|
||||
"Minimum TP package version required by Ebox framework SPI v2.")
|
||||
|
||||
(defconst ebox-framework-spi-supported-tp-protocols
|
||||
'(tp-transaction-protocol-v1+v2 tp-transaction-protocol-v2)
|
||||
"TP protocols that provide the structured participant API Ebox requires.")
|
||||
|
||||
(declare-function ebox--render-to-buffer-internal
|
||||
"ebox" (buffer-or-name input options &optional participant))
|
||||
(declare-function ebox-commit
|
||||
@ -140,6 +147,58 @@
|
||||
(signal 'wrong-type-argument (list 'functionp framework-rollback)))
|
||||
t)
|
||||
|
||||
(defun ebox-framework-spi--buffer-overlays (buffer)
|
||||
"Return every overlay currently attached to BUFFER."
|
||||
(with-current-buffer buffer
|
||||
(let ((lists (overlay-lists)))
|
||||
(delete-dups (append (car lists) (cdr lists))))))
|
||||
|
||||
(defun ebox-framework-spi--snapshot-editor-custody (buffer)
|
||||
"Capture BUFFER state that an unsuccessful initial mount must preserve."
|
||||
(with-current-buffer buffer
|
||||
(let ((restriction-start (point-min))
|
||||
(restriction-end (point-max)))
|
||||
(save-restriction
|
||||
(widen)
|
||||
(list
|
||||
:point (point)
|
||||
:mark-marker (mark-marker)
|
||||
:mark-position (mark t)
|
||||
:mark-insertion-type (marker-insertion-type (mark-marker))
|
||||
:mark-active mark-active
|
||||
:restriction-start restriction-start
|
||||
:restriction-end restriction-end
|
||||
:overlays
|
||||
(mapcar (lambda (overlay)
|
||||
(list overlay (overlay-start overlay) (overlay-end overlay)))
|
||||
(ebox-framework-spi--buffer-overlays buffer))
|
||||
:undo-list (copy-tree buffer-undo-list)
|
||||
:modified-p (buffer-modified-p))))))
|
||||
|
||||
(defun ebox-framework-spi--restore-editor-custody (buffer snapshot)
|
||||
"Restore BUFFER editor state from SNAPSHOT after mount rollback."
|
||||
(when (buffer-live-p buffer)
|
||||
(with-current-buffer buffer
|
||||
(widen)
|
||||
(let* ((overlay-snapshots (plist-get snapshot :overlays))
|
||||
(original-overlays (mapcar #'car overlay-snapshots)))
|
||||
(dolist (overlay (ebox-framework-spi--buffer-overlays buffer))
|
||||
(unless (memq overlay original-overlays)
|
||||
(delete-overlay overlay)))
|
||||
(dolist (entry overlay-snapshots)
|
||||
(when (overlayp (car entry))
|
||||
(move-overlay (car entry) (nth 1 entry) (nth 2 entry) buffer))))
|
||||
(let ((mark-marker (plist-get snapshot :mark-marker)))
|
||||
(set-marker mark-marker (plist-get snapshot :mark-position) buffer)
|
||||
(set-marker-insertion-type
|
||||
mark-marker (plist-get snapshot :mark-insertion-type)))
|
||||
(goto-char (plist-get snapshot :point))
|
||||
(setq mark-active (plist-get snapshot :mark-active)
|
||||
buffer-undo-list (copy-tree (plist-get snapshot :undo-list)))
|
||||
(narrow-to-region (plist-get snapshot :restriction-start)
|
||||
(plist-get snapshot :restriction-end))
|
||||
(set-buffer-modified-p (plist-get snapshot :modified-p)))))
|
||||
|
||||
(defun ebox-framework-spi-initial
|
||||
(buffer-or-name input framework-stage framework-rollback)
|
||||
"Initially mount INPUT and run the paired framework callbacks.
|
||||
@ -156,13 +215,46 @@ transaction failure. Return that same report object after completion."
|
||||
(let ((buffer (get-buffer-create buffer-or-name)))
|
||||
(when (ebox-surface-buffer-mounted-p buffer)
|
||||
(error "Ebox SPI initial operation requires an unmounted buffer"))
|
||||
(let ((participant
|
||||
(ebox-surface--make-framework-participant
|
||||
:publish framework-stage
|
||||
:rollback framework-rollback
|
||||
:state 'unpublished
|
||||
:diagnostics nil)))
|
||||
(ebox--render-to-buffer-internal buffer input nil participant))))
|
||||
(let* ((custody (ebox-framework-spi--snapshot-editor-custody buffer))
|
||||
(change-group (with-current-buffer buffer (prepare-change-group)))
|
||||
(existing-bridge
|
||||
(with-current-buffer buffer ebox-surface--tp-observer))
|
||||
(started (float-time))
|
||||
(participant
|
||||
(ebox-surface--make-framework-participant
|
||||
:publish
|
||||
(lambda (report)
|
||||
(plist-put report :framework-initial-observation-started
|
||||
started)
|
||||
(funcall framework-stage report))
|
||||
:rollback
|
||||
(lambda (report)
|
||||
(cl-remf report :framework-initial-observation-started)
|
||||
(funcall framework-rollback report))
|
||||
:state 'unpublished
|
||||
:diagnostics nil))
|
||||
activated success)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(unless existing-bridge
|
||||
(ebox-surface--ensure-tp-observer buffer))
|
||||
(with-current-buffer buffer (activate-change-group change-group))
|
||||
(setq activated t)
|
||||
(prog1 (with-current-buffer buffer
|
||||
(save-restriction
|
||||
(widen)
|
||||
(ebox--render-to-buffer-internal
|
||||
buffer input nil participant)))
|
||||
(with-current-buffer buffer (accept-change-group change-group))
|
||||
(setq success t)))
|
||||
(unwind-protect
|
||||
(unless success
|
||||
(when (and activated (buffer-live-p buffer))
|
||||
(with-current-buffer buffer
|
||||
(cancel-change-group change-group))
|
||||
(ebox-framework-spi--restore-editor-custody buffer custody)))
|
||||
(unless existing-bridge
|
||||
(ebox-surface-cleanup-buffer-observer buffer)))))))
|
||||
|
||||
(defun ebox-framework-spi-update
|
||||
(buffer-or-name input framework-stage framework-rollback)
|
||||
@ -173,7 +265,7 @@ Return the same completed legacy report object observed by FRAMEWORK-STAGE."
|
||||
(ebox-commit buffer-or-name input framework-stage framework-rollback))
|
||||
|
||||
(defun ebox-framework-spi-initial-observation-reports (report)
|
||||
"Return TP/Ebox provider reports reconstructed from completed initial REPORT.
|
||||
"Return TP/Ebox provider snapshots captured by completed initial REPORT.
|
||||
The reports are defensive observational snapshots; they expose no publication
|
||||
or promotion capability."
|
||||
(unless (and (proper-list-p report)
|
||||
@ -181,27 +273,31 @@ or promotion capability."
|
||||
(integerp (plist-get report :tp-transaction-id)))
|
||||
(signal 'ebox-framework-spi-provider-error
|
||||
(list :invalid-initial-report report)))
|
||||
(let* ((transaction-id (plist-get report :tp-transaction-id))
|
||||
(tp-report
|
||||
(list :provider 'tp
|
||||
:stage 'publication
|
||||
:status 'success
|
||||
:duration-ms 0.0
|
||||
:correlation-id transaction-id
|
||||
:transaction-id transaction-id
|
||||
:surface-revision (plist-get report :surface-revision)
|
||||
:text-operations (plist-get report :tp-text-operations)
|
||||
:property-operations
|
||||
(plist-get report :tp-property-operations)
|
||||
:operation-count (plist-get report :tp-operation-count)))
|
||||
(ebox-report (copy-tree report)))
|
||||
(dolist (entry `((:provider . ebox)
|
||||
(:stage . mount)
|
||||
(:status . success)
|
||||
(:duration-ms . 0.0)
|
||||
(:correlation-id . ,transaction-id)))
|
||||
(setq ebox-report (plist-put ebox-report (car entry) (cdr entry))))
|
||||
(list tp-report ebox-report)))
|
||||
(let ((reports
|
||||
(plist-get report :framework-initial-observation-reports)))
|
||||
(cond
|
||||
((and (proper-list-p reports)
|
||||
(= (length reports) 2)
|
||||
(equal (mapcar (lambda (item)
|
||||
(plist-get item :provider))
|
||||
reports)
|
||||
'(tp ebox))
|
||||
(cl-every
|
||||
(lambda (item)
|
||||
(and (numberp (plist-get item :duration-ms))
|
||||
(>= (plist-get item :duration-ms) 0.0)))
|
||||
reports))
|
||||
(copy-tree reports))
|
||||
((cl-find 'framework-report-finalization
|
||||
(plist-get report :framework-participant-diagnostics)
|
||||
:key (lambda (entry) (plist-get entry :phase)))
|
||||
;; The accepted publication remains authoritative. Its completed
|
||||
;; report carries the failure; observer replay is skipped, never
|
||||
;; reclassified as a rollback-capable provider error.
|
||||
nil)
|
||||
(t
|
||||
(signal 'ebox-framework-spi-provider-error
|
||||
(list :missing-initial-observation-reports report))))))
|
||||
|
||||
(defun ebox-framework-spi--operation
|
||||
(kind function argument-schema)
|
||||
@ -241,8 +337,8 @@ or promotion capability."
|
||||
ebox-framework-spi-schema-version)
|
||||
(equal (ebox-framework-spi--provider-capabilities provider)
|
||||
ebox-framework-spi--required-capabilities)
|
||||
(eq (ebox-framework-spi--provider-tp-protocol provider)
|
||||
tp-transaction-protocol)
|
||||
(memq (ebox-framework-spi--provider-tp-protocol provider)
|
||||
ebox-framework-spi-supported-tp-protocols)
|
||||
(equal (ebox-framework-spi--provider-stage-order provider)
|
||||
ebox-framework-spi--stage-order)
|
||||
(equal (ebox-framework-spi--provider-rollback-order provider)
|
||||
|
||||
@ -51,6 +51,7 @@
|
||||
(:id region-generation-indexes
|
||||
:storage (:region-id-set :region-node-table :region-box-count-table
|
||||
:region-box-table :layout-snapshots
|
||||
:fractional-pixel-output-p
|
||||
:render-owned-text-values ebox--render-owned-text-values)
|
||||
:current-contract candidate-state-plus-global-projection
|
||||
:target-contract immutable-generation-value
|
||||
@ -117,6 +118,18 @@
|
||||
:rollback cancel-candidate-work
|
||||
:rebuild-proof reschedule-from-committed-generation
|
||||
:cleanup cancel-timers-jobs-and-gc-lease)
|
||||
(:id viewport-event-authority
|
||||
:storage (ebox--window-size-change-pending-frames
|
||||
ebox--window-size-change-timer)
|
||||
:current-contract frame-intent-queue-and-single-event-loop-task
|
||||
:target-contract frame-intent-queue-and-single-event-loop-task
|
||||
:category generation-bound-mutable-authority
|
||||
:owner ebox-viewport
|
||||
:mutation-api window-notify-schedule-and-drain
|
||||
:generation-binding current-mounted-generation-sampled-at-delivery
|
||||
:rollback publication-owned-rollback-and-requeue-unstarted-frames
|
||||
:rebuild-proof subsequent-window-notification
|
||||
:cleanup consume-frame-intents-and-release-dispatched-timer)
|
||||
(:id incremental-batch-authority
|
||||
:storage (ebox-incremental--batch-table)
|
||||
:current-contract process-buffer-keyed-open-batch-state
|
||||
@ -209,6 +222,17 @@
|
||||
:rollback discard
|
||||
:rebuild-proof resolve-font-fact-again
|
||||
:cleanup ebox-font-clear-cache)
|
||||
(:id native-hover-face-cache
|
||||
:storage (ebox-interaction--hover-face-cache)
|
||||
:current-contract weakly-shared-native-hover-faces
|
||||
:target-contract discardable-derived-values
|
||||
:category disposable-cache
|
||||
:owner ebox-interaction
|
||||
:mutation-api weak-cache-fill
|
||||
:generation-binding hover-owner-and-computed-paint
|
||||
:rollback discard-unreferenced-candidates
|
||||
:rebuild-proof reproject-owner-native-hover-face
|
||||
:cleanup garbage-collect-unreferenced-faces)
|
||||
(:id derived-caches
|
||||
:storage (ebox--box-content-render-cache ebox--char-width-cache
|
||||
ebox--display-signature-cache ebox--face-height-width-cache
|
||||
@ -227,13 +251,15 @@
|
||||
ebox--render-root-cache-table-table
|
||||
ebox--render-string-max-pixel-width-cache
|
||||
ebox--render-string-pixel-width-cache
|
||||
ebox--rendered-uniform-width-table ebox--runtime-ancestor-cache
|
||||
ebox--rendered-uniform-width-table ebox--rendered-intrinsic-size-table
|
||||
ebox--runtime-ancestor-cache
|
||||
ebox--scroll-window-cached-state ebox--space-pixel-cache
|
||||
ebox--string-pixel-width-cache
|
||||
ebox--string-pixel-width-cache-ring
|
||||
ebox--viewport-dependent-node-ids-cache
|
||||
ebox--viewport-dependent-subtree-cache
|
||||
ebox--viewport-height-dependent-subtree-cache
|
||||
ebox--css-viewport-dependent-subtree-cache
|
||||
ebox--window-line-prewarmer-table
|
||||
ebox--window-line-renderer-table
|
||||
ebox-cache--buffer-report-table ebox-cache--registry
|
||||
@ -272,12 +298,27 @@ rather than relying on an implicit default.")
|
||||
'((:symbol ebox--defer-scroll-content-index
|
||||
:reason dynamic-boolean-or-index-mode
|
||||
:evidence value-is-never-a-container)
|
||||
(:symbol ebox--box-content-empty-width
|
||||
:reason dynamic-scalar-result
|
||||
:evidence numeric-inline-extent-returned-with-one-content-materialization)
|
||||
(:symbol ebox-layer--portals
|
||||
:reason dynamically-bound-render-collection
|
||||
:evidence let-bound-per-spatial-root-and-discarded-with-the-candidate-render)
|
||||
(:symbol ebox--string-pixel-width-cache-ring-index
|
||||
:reason numeric-eviction-cursor
|
||||
:evidence value-is-an-integer-ring-position)
|
||||
(:symbol ebox-native-reflow--compile-property-template-ids
|
||||
:reason dynamically-bound-compile-local-scratch
|
||||
:evidence let-bound-per-compile-and-never-committed)
|
||||
(:symbol ebox-native-reflow--compile-property-template-index
|
||||
:reason dynamically-bound-compile-local-scratch
|
||||
:evidence let-bound-per-compile-and-published-only-through-session-root)
|
||||
(:symbol ebox-native-reflow--compile-style-index
|
||||
:reason dynamically-bound-compile-local-scratch
|
||||
:evidence let-bound-per-compile-and-published-only-through-session-root)
|
||||
(:symbol ebox-native-reflow--compile-size-node
|
||||
:reason dynamically-bound-compile-local-scratch
|
||||
:evidence owning-node-font-context-for-one-native-size-projection)
|
||||
(:symbol ebox--render-source-generations
|
||||
:reason dynamically-bound-render-proof-input
|
||||
:evidence let-bound-to-candidate-and-prior-generations-for-one-plan)
|
||||
|
||||
415
ebox-style.el
415
ebox-style.el
@ -12,6 +12,7 @@
|
||||
(require 'tp-style)
|
||||
(require 'ebox-source)
|
||||
(require 'ebox-layout-config)
|
||||
(require 'ebox-size)
|
||||
|
||||
(defvar ebox--longhand)
|
||||
(declare-function ebox-selector-parse "ebox-selector" (selector))
|
||||
@ -21,16 +22,8 @@
|
||||
"ebox-flex" (props))
|
||||
(declare-function ebox-grid--normalize-config-props
|
||||
"ebox-grid" (props))
|
||||
(declare-function ebox--normalize-horizontal-size-value
|
||||
"ebox-flex" (value allowed-keywords))
|
||||
(declare-function ebox--nonnegative-horizontal-size-pixels
|
||||
"ebox-layout" (value &optional default))
|
||||
(declare-function ecss-stylesheet-create "ecss" ())
|
||||
|
||||
(defvar ebox--preferred-size-keywords)
|
||||
(defvar ebox--min-size-keywords)
|
||||
(defvar ebox--max-size-keywords)
|
||||
|
||||
(defun ebox-style--create-stylesheet ()
|
||||
"Return one private ECSS stylesheet for Ebox style state."
|
||||
(ecss-stylesheet-create))
|
||||
@ -77,132 +70,160 @@
|
||||
(:name :box-sizing :id ebox/box-sizing :initial border-box
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout)
|
||||
(:name :position :id ebox/position :initial static
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout :validator position
|
||||
:runtime-default t)
|
||||
(:name :left :id ebox/left :initial (px 0)
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout
|
||||
:size-axis inline :validator signed-size :runtime-default t)
|
||||
(:name :top :id ebox/top :initial (lh 0)
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout
|
||||
:size-axis block :validator signed-size :runtime-default t)
|
||||
(:name :z-index :id ebox/z-index :initial 0
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout :validator integer
|
||||
:runtime-default t)
|
||||
(:name :layer :id ebox/layer :initial local
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout :validator layer
|
||||
:runtime-default t)
|
||||
(:name :anchor :id ebox/anchor :initial nil
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout :validator anchor
|
||||
:runtime-default t)
|
||||
(:name :placement :id ebox/placement :initial bottom-start
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout :validator placement
|
||||
:runtime-default t)
|
||||
(:name :width :id ebox/width :initial auto
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator horizontal-size
|
||||
:size-axis inline :validator horizontal-size
|
||||
:engine-projection (:to :width :conv preferred-size))
|
||||
(:name :min-width :id ebox/min-width :initial 0
|
||||
(:name :min-width :id ebox/min-width :initial auto
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator min-horizontal-size
|
||||
:size-axis inline :validator min-horizontal-size
|
||||
:engine-projection (:to :min-width :conv min-size))
|
||||
(:name :max-width :id ebox/max-width :initial none
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator max-horizontal-size
|
||||
:size-axis inline :validator max-horizontal-size
|
||||
:engine-projection (:to :max-width :conv max-size))
|
||||
(:name :height :id ebox/height :initial auto
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator vertical-size)
|
||||
(:name :min-height :id ebox/min-height :initial 0
|
||||
:size-axis block :validator vertical-size)
|
||||
(:name :min-height :id ebox/min-height :initial auto
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator min-vertical-size)
|
||||
:size-axis block :validator min-vertical-size)
|
||||
(:name :max-height :id ebox/max-height :initial none
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator max-vertical-size)
|
||||
(:name :padding-top :id ebox/padding-top :initial 0
|
||||
:size-axis block :validator max-vertical-size)
|
||||
(:name :padding-top :id ebox/padding-top :initial (lh 0)
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis block :validator nonnegative-size
|
||||
:engine-projection (:to :padding-top-height :conv line))
|
||||
(:name :padding-right :id ebox/padding-right :initial 0
|
||||
(:name :padding-right :id ebox/padding-right :initial (px 0)
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis inline :validator nonnegative-size
|
||||
:engine-projection (:to :padding-right-pixel :conv pixel))
|
||||
(:name :padding-bottom :id ebox/padding-bottom :initial 0
|
||||
(:name :padding-bottom :id ebox/padding-bottom :initial (lh 0)
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis block :validator nonnegative-size
|
||||
:engine-projection (:to :padding-bottom-height :conv line))
|
||||
(:name :padding-left :id ebox/padding-left :initial 0
|
||||
(:name :padding-left :id ebox/padding-left :initial (px 0)
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis inline :validator nonnegative-size
|
||||
:engine-projection (:to :padding-left-pixel :conv pixel))
|
||||
(:name :padding-block-start :id ebox/padding-block-start :initial 0
|
||||
(:name :padding-block-start :id ebox/padding-block-start :initial (lh 0)
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis block :validator nonnegative-size
|
||||
:engine-projection (:to :padding-top-height :conv line))
|
||||
(:name :padding-inline-end :id ebox/padding-inline-end :initial 0
|
||||
(:name :padding-inline-end :id ebox/padding-inline-end :initial (px 0)
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis inline :validator nonnegative-size
|
||||
:engine-projection (:to :padding-right-pixel :conv pixel))
|
||||
(:name :padding-block-end :id ebox/padding-block-end :initial 0
|
||||
(:name :padding-block-end :id ebox/padding-block-end :initial (lh 0)
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis block :validator nonnegative-size
|
||||
:engine-projection (:to :padding-bottom-height :conv line))
|
||||
(:name :padding-inline-start :id ebox/padding-inline-start :initial 0
|
||||
(:name :padding-inline-start :id ebox/padding-inline-start :initial (px 0)
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis inline :validator nonnegative-size
|
||||
:engine-projection (:to :padding-left-pixel :conv pixel))
|
||||
(:name :margin-top :id ebox/margin-top :initial 0
|
||||
(:name :margin-top :id ebox/margin-top :initial (lh 0)
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis block :validator nonnegative-size
|
||||
:engine-projection (:to :margin-top-height :conv line))
|
||||
(:name :margin-right :id ebox/margin-right :initial 0
|
||||
(:name :margin-right :id ebox/margin-right :initial (px 0)
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis inline :validator nonnegative-size
|
||||
:engine-projection (:to :margin-right-pixel :conv pixel))
|
||||
(:name :margin-bottom :id ebox/margin-bottom :initial 0
|
||||
(:name :margin-bottom :id ebox/margin-bottom :initial (lh 0)
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis block :validator nonnegative-size
|
||||
:engine-projection (:to :margin-bottom-height :conv line))
|
||||
(:name :margin-left :id ebox/margin-left :initial 0
|
||||
(:name :margin-left :id ebox/margin-left :initial (px 0)
|
||||
:contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis inline :validator nonnegative-size
|
||||
:engine-projection (:to :margin-left-pixel :conv pixel))
|
||||
(:name :margin-block-start :id ebox/margin-block-start :initial 0
|
||||
(:name :margin-block-start :id ebox/margin-block-start :initial (lh 0)
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis block :validator nonnegative-size
|
||||
:engine-projection (:to :margin-top-height :conv line))
|
||||
(:name :margin-inline-end :id ebox/margin-inline-end :initial 0
|
||||
(:name :margin-inline-end :id ebox/margin-inline-end :initial (px 0)
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis inline :validator nonnegative-size
|
||||
:engine-projection (:to :margin-right-pixel :conv pixel))
|
||||
(:name :margin-block-end :id ebox/margin-block-end :initial 0
|
||||
(:name :margin-block-end :id ebox/margin-block-end :initial (lh 0)
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis block :validator nonnegative-size
|
||||
:engine-projection (:to :margin-bottom-height :conv line))
|
||||
(:name :margin-inline-start :id ebox/margin-inline-start :initial 0
|
||||
(:name :margin-inline-start :id ebox/margin-inline-start :initial (px 0)
|
||||
:contexts (box row column flex grid) :group geometry
|
||||
:dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size
|
||||
:size-axis inline :validator nonnegative-size
|
||||
:engine-projection (:to :margin-left-pixel :conv pixel))
|
||||
(:name :border-top-width :id ebox/border-top-width
|
||||
:initial 0 :contexts (box row column flex grid)
|
||||
:initial (px 0) :contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator border-width
|
||||
:size-axis stroke :validator border-width
|
||||
:engine-projection (:to :border-top-pixel :conv border-pixel))
|
||||
(:name :border-right-width :id ebox/border-right-width
|
||||
:initial 0 :contexts (box row column flex grid)
|
||||
:initial (px 0) :contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator border-width
|
||||
:size-axis stroke :validator border-width
|
||||
:engine-projection (:to :border-right-pixel :conv border-pixel))
|
||||
(:name :border-bottom-width :id ebox/border-bottom-width
|
||||
:initial 0 :contexts (box row column flex grid)
|
||||
:initial (px 0) :contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator border-width
|
||||
:size-axis stroke :validator border-width
|
||||
:engine-projection (:to :border-bottom-pixel :conv border-pixel))
|
||||
(:name :border-left-width :id ebox/border-left-width
|
||||
:initial 0 :contexts (box row column flex grid)
|
||||
:initial (px 0) :contexts (box row column flex grid)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator border-width
|
||||
:size-axis stroke :validator border-width
|
||||
:engine-projection (:to :border-left-pixel :conv border-pixel))
|
||||
(:name :border-top-style :id ebox/border-top-style :initial none
|
||||
:contexts (box row column flex grid) :group structure
|
||||
@ -257,9 +278,9 @@
|
||||
:validator outer :impacts (structure geometry)
|
||||
:projections (structure layout)
|
||||
:engine-projection (:to nil))
|
||||
(:name :item-gap :id ebox/item-gap :initial 0 :contexts (row column)
|
||||
(:name :item-gap :id ebox/item-gap :initial (px 0) :contexts (row column)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator nonnegative-integer)
|
||||
:size-axis main :validator nonnegative-size)
|
||||
(:name :cross-align :id ebox/cross-align :initial stretch
|
||||
:contexts (row column) :group geometry :dirty-kind geometry
|
||||
:signature layout :validator cross-align)
|
||||
@ -281,12 +302,12 @@
|
||||
(:name :justify-items :id ebox/justify-items :initial stretch
|
||||
:contexts (grid) :group geometry :dirty-kind geometry :signature layout
|
||||
:validator justify-items)
|
||||
(:name :row-gap :id ebox/row-gap :initial 0
|
||||
(:name :row-gap :id ebox/row-gap :initial (lh 0)
|
||||
:contexts (flex grid) :group geometry :dirty-kind geometry :signature layout
|
||||
:validator nonnegative-size)
|
||||
:size-axis block :validator nonnegative-size)
|
||||
(:name :column-gap :id ebox/column-gap
|
||||
:initial 0 :contexts (flex grid) :group geometry :dirty-kind geometry
|
||||
:signature layout :validator nonnegative-size)
|
||||
:initial (px 0) :contexts (flex grid) :group geometry :dirty-kind geometry
|
||||
:signature layout :size-axis inline :validator nonnegative-size)
|
||||
(:name :order :id ebox/order :initial 0 :contexts (item)
|
||||
:group geometry :dirty-kind geometry :signature layout :validator order)
|
||||
(:name :flex-grow :id ebox/flex-grow :initial 0 :contexts (item)
|
||||
@ -297,7 +318,7 @@
|
||||
:validator flex-factor)
|
||||
(:name :flex-basis :id ebox/flex-basis :initial auto :contexts (item)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator horizontal-size)
|
||||
:size-axis length :validator preferred-size)
|
||||
(:name :align-self :id ebox/align-self :initial auto :contexts (item)
|
||||
:group geometry :dirty-kind geometry :signature layout
|
||||
:validator align-self)
|
||||
@ -306,16 +327,16 @@
|
||||
:validator justify-self)
|
||||
(:name :grid-template-columns :id ebox/grid-template-columns :initial nil
|
||||
:contexts (grid) :group geometry :dirty-kind geometry :signature layout
|
||||
:validator grid-track-list)
|
||||
:size-grammar tracks :size-axis inline :validator grid-track-list)
|
||||
(:name :grid-template-rows :id ebox/grid-template-rows :initial nil
|
||||
:contexts (grid) :group geometry :dirty-kind geometry :signature layout
|
||||
:validator grid-track-list)
|
||||
:size-grammar tracks :size-axis block :validator grid-track-list)
|
||||
(:name :grid-auto-columns :id ebox/grid-auto-columns :initial auto
|
||||
:contexts (grid) :group geometry :dirty-kind geometry :signature layout
|
||||
:validator grid-track)
|
||||
:size-grammar tracks :size-axis inline :validator grid-track)
|
||||
(:name :grid-auto-rows :id ebox/grid-auto-rows :initial auto
|
||||
:contexts (grid) :group geometry :dirty-kind geometry :signature layout
|
||||
:validator grid-track)
|
||||
:size-grammar tracks :size-axis block :validator grid-track)
|
||||
(:name :grid-auto-flow :id ebox/grid-auto-flow :initial row
|
||||
:contexts (grid) :group structure :dirty-kind structure :signature layout
|
||||
:validator grid-auto-flow)
|
||||
@ -575,10 +596,18 @@
|
||||
"Return PLIST with KEY set to VALUE."
|
||||
(plist-put plist key value))
|
||||
|
||||
(defun ebox-style--atomic-size-form-p (value)
|
||||
"Return non-nil when VALUE denotes one size expression before validation.
|
||||
Keep malformed unit and function forms intact so their target property reports
|
||||
the invalid value and its allowed units instead of a shorthand arity error."
|
||||
(and (consp value)
|
||||
(memq (car value) '(px ch vw lh vh % calc min max clamp))))
|
||||
|
||||
(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))
|
||||
((ebox-style--atomic-size-form-p value) (make-list 4 value))
|
||||
((atom value) (list value value value value))
|
||||
((and (listp value) (keywordp (car value)))
|
||||
(list (plist-get value :top) (plist-get value :right)
|
||||
@ -588,23 +617,28 @@
|
||||
(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)))))
|
||||
(4 (list (nth 0 value) (nth 1 value) (nth 2 value) (nth 3 value)))
|
||||
(_ (error "Ebox four-side shorthand requires one to four values: %S"
|
||||
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))
|
||||
((ebox-style--atomic-size-form-p value) (list value value))
|
||||
((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)))
|
||||
((listp value)
|
||||
(pcase (length value)
|
||||
(1 (list (nth 0 value) (nth 0 value)))
|
||||
(2 (list (nth 0 value) (nth 1 value)))
|
||||
(_ (error "Ebox pair shorthand requires one or two values: %S" 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))
|
||||
((ebox-style--atomic-size-form-p item) (cons 'width item))
|
||||
((memq item ebox-style--border-styles) (cons 'style item))
|
||||
((or (stringp item) (tp-paint-slot-p item)) (cons 'color item))
|
||||
(t (error "Invalid Ebox border component: %S" item))))
|
||||
@ -613,12 +647,13 @@
|
||||
"Split VALUE into border width, style, and color."
|
||||
(cond
|
||||
((null value) (list nil nil nil))
|
||||
((ebox-style--atomic-size-form-p value) (list value 'solid nil))
|
||||
((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))))))
|
||||
('style (list '(px 1) (cdr classified) nil))
|
||||
('color (list '(px 1) 'solid (cdr classified))))))
|
||||
((proper-list-p value)
|
||||
(let (width style color width-p style-p color-p)
|
||||
(dolist (item value)
|
||||
@ -633,7 +668,7 @@
|
||||
('color
|
||||
(when color-p (error "Duplicate Ebox border color in %S" value))
|
||||
(setq color-p t color (cdr classified))))))
|
||||
(list (or width 1) (or style 'solid) color)))
|
||||
(list (or width '(px 1)) (or style 'solid) color)))
|
||||
(t (error "Ebox border must be a color or proper component list: %S"
|
||||
value))))
|
||||
|
||||
@ -730,7 +765,8 @@
|
||||
"Return canonical boolean border declarations for SIDE and VALUE."
|
||||
(unless (memq value '(nil t))
|
||||
(error "Ebox border-%s-p must be boolean: %S" side value))
|
||||
(list (intern (format "ebox/border-%s-width" side)) (if value 1 0)
|
||||
(list (intern (format "ebox/border-%s-width" side))
|
||||
(list 'px (if value 1 0))
|
||||
(intern (format "ebox/border-%s-style" side))
|
||||
(if value 'solid 'none)))
|
||||
|
||||
@ -797,7 +833,7 @@
|
||||
(pcase value
|
||||
((and (pred numberp) (guard (>= value 0)))
|
||||
(list 'ebox/flex-grow value 'ebox/flex-shrink 1
|
||||
'ebox/flex-basis 0))
|
||||
'ebox/flex-basis '(% 0)))
|
||||
('none '(ebox/flex-grow 0 ebox/flex-shrink 0 ebox/flex-basis auto))
|
||||
('auto '(ebox/flex-grow 1 ebox/flex-shrink 1 ebox/flex-basis auto))
|
||||
('initial '(ebox/flex-grow 0 ebox/flex-shrink 1 ebox/flex-basis auto))
|
||||
@ -821,55 +857,50 @@
|
||||
(numberp (car value))
|
||||
(< (car value) 0))))
|
||||
|
||||
(defun ebox-style--nonnegative-real-p (value)
|
||||
"Return non-nil when VALUE is a nonnegative real number."
|
||||
(and (numberp value) (>= value 0)))
|
||||
|
||||
(defun ebox-style--pixel-size-p (value)
|
||||
"Return non-nil when VALUE is a one-element nonnegative pixel size."
|
||||
(and (proper-list-p value) (= (length value) 1)
|
||||
(ebox-style--nonnegative-real-p (car value))))
|
||||
|
||||
(defun ebox-style--nonnegative-size-p (value)
|
||||
"Return non-nil when VALUE is one canonical edge size."
|
||||
(or (ebox-style--nonnegative-real-p value)
|
||||
(ebox-style--pixel-size-p value)))
|
||||
"Return non-nil when VALUE is a canonical nonnegative length-percentage."
|
||||
(ebox-size-value-p value nil t))
|
||||
|
||||
(defconst ebox-style--preferred-size-keywords
|
||||
'(auto min-content max-content fit-content stretch)
|
||||
"CSS preferred and minimum size keywords in the supported subset.")
|
||||
|
||||
(defconst ebox-style--maximum-size-keywords
|
||||
'(none min-content max-content fit-content stretch)
|
||||
"CSS maximum size keywords in the supported subset.")
|
||||
|
||||
(defun ebox-style--horizontal-size-p (value)
|
||||
"Return non-nil when VALUE is a canonical horizontal size."
|
||||
(or (ebox-style--nonnegative-real-p value)
|
||||
(ebox-style--pixel-size-p value)
|
||||
(memq value '(viewport auto min-content max-content
|
||||
fit-content stretch contain))
|
||||
(equal value '(viewport))
|
||||
(and (proper-list-p value) (= (length value) 2)
|
||||
(eq (car value) 'fit-content)
|
||||
(ebox-style--horizontal-size-p (cadr value)))))
|
||||
(or (memq value ebox-style--preferred-size-keywords)
|
||||
(ebox-size-value-for-axis-p value 'inline)))
|
||||
|
||||
(defun ebox-style--preferred-size-p (value)
|
||||
"Return non-nil when VALUE is a size whose parent axis is not known yet."
|
||||
(or (memq value ebox-style--preferred-size-keywords)
|
||||
(ebox-style--nonnegative-size-p value)))
|
||||
|
||||
(defun ebox-style--min-horizontal-size-p (value)
|
||||
"Return non-nil when VALUE is a canonical minimum horizontal size."
|
||||
(and (not (eq value 'none)) (ebox-style--horizontal-size-p value)))
|
||||
(ebox-style--horizontal-size-p value))
|
||||
|
||||
(defun ebox-style--max-horizontal-size-p (value)
|
||||
"Return non-nil when VALUE is a canonical maximum horizontal size."
|
||||
(or (eq value 'none) (ebox-style--horizontal-size-p value)))
|
||||
(or (memq value ebox-style--maximum-size-keywords)
|
||||
(ebox-size-value-for-axis-p value 'inline)))
|
||||
|
||||
(defun ebox-style--vertical-size-p (value)
|
||||
"Return non-nil when VALUE is a canonical vertical size."
|
||||
(or (ebox-style--nonnegative-real-p value)
|
||||
(memq value '(viewport-height auto))
|
||||
(equal value '(viewport-height))))
|
||||
(or (memq value ebox-style--preferred-size-keywords)
|
||||
(ebox-size-value-for-axis-p value 'block)))
|
||||
|
||||
(defun ebox-style--min-vertical-size-p (value)
|
||||
"Return non-nil when VALUE is a canonical minimum vertical size."
|
||||
(ebox-style--nonnegative-real-p value))
|
||||
(ebox-style--vertical-size-p value))
|
||||
|
||||
(defun ebox-style--max-vertical-size-p (value)
|
||||
"Return non-nil when VALUE is a canonical maximum vertical size."
|
||||
(or (ebox-style--nonnegative-real-p value)
|
||||
(eq value 'none)
|
||||
(eq value 'viewport-height)
|
||||
(equal value '(viewport-height))))
|
||||
(or (memq value ebox-style--maximum-size-keywords)
|
||||
(ebox-size-value-for-axis-p value 'block)))
|
||||
|
||||
(defun ebox-style--nonnegative-number-p (value)
|
||||
"Return non-nil when VALUE is nil or a non-negative number."
|
||||
@ -901,6 +932,34 @@
|
||||
"Return non-nil when VALUE is an Ebox outer participation value."
|
||||
(memq value '(inline block)))
|
||||
|
||||
(defun ebox-style--signed-size-p (value &optional axis)
|
||||
"Return non-nil when VALUE is a signed typed length in size AXIS.
|
||||
Leave fractional and contextual lengths unresolved until used placement."
|
||||
(and (ebox-size-value-p value t)
|
||||
(let ((units (cdr (assq (or axis 'length) ebox-size-axis-units))))
|
||||
(cl-labels ((valid-p (expression)
|
||||
(or (atom expression)
|
||||
(if (memq (car expression) '(px ch vw lh vh %))
|
||||
(memq (car expression) units)
|
||||
(cl-every #'valid-p (cdr expression))))))
|
||||
(valid-p value)))))
|
||||
|
||||
(defun ebox-style--position-p (value)
|
||||
"Return non-nil when VALUE is a supported positioning mode."
|
||||
(memq value '(static relative absolute)))
|
||||
|
||||
(defun ebox-style--layer-p (value)
|
||||
"Return non-nil when VALUE is a supported layer paint target."
|
||||
(memq value '(local root)))
|
||||
|
||||
(defun ebox-style--anchor-p (value)
|
||||
"Return non-nil when VALUE is nil or a semantic symbol or string id."
|
||||
(or (symbolp value) (stringp value)))
|
||||
|
||||
(defun ebox-style--placement-p (value)
|
||||
"Return non-nil when VALUE is a supported anchor placement."
|
||||
(memq value '(bottom-start bottom-end top-start top-end)))
|
||||
|
||||
(defun ebox-style--font-family-p (value)
|
||||
"Return non-nil when VALUE is an Ebox font family."
|
||||
(or (null value)
|
||||
@ -927,8 +986,8 @@
|
||||
(or (null value) (memq value '(normal italic oblique))))
|
||||
|
||||
(defun ebox-style--border-width-p (value)
|
||||
"Return non-nil when VALUE is an exact nonnegative pixel width."
|
||||
(and (integerp value) (>= value 0)))
|
||||
"Return non-nil when VALUE is a nonnegative length without percentages."
|
||||
(ebox-size-value-p value nil nil))
|
||||
|
||||
(defun ebox-style--border-style-p (value)
|
||||
"Return non-nil when VALUE is a supported canonical border style."
|
||||
@ -993,10 +1052,8 @@
|
||||
(memq value '(row column)))
|
||||
|
||||
(defun ebox-style--grid-fixed-track-p (value)
|
||||
"Return non-nil when VALUE is one nonnegative fixed Grid track."
|
||||
(or (ebox-style--nonnegative-real-p value)
|
||||
(and (proper-list-p value) (= (length value) 1)
|
||||
(ebox-style--nonnegative-real-p (car value)))))
|
||||
"Return non-nil when VALUE is a nonnegative Grid track size expression."
|
||||
(ebox-style--nonnegative-size-p value))
|
||||
|
||||
(defun ebox-style--grid-fr-track-p (value)
|
||||
"Return non-nil when VALUE is one positive fractional Grid track."
|
||||
@ -1072,6 +1129,7 @@ generated wrappers that already contain exact line breaks."
|
||||
(defun ebox-style--validator (name)
|
||||
"Return schema validator named NAME."
|
||||
(pcase name
|
||||
('preferred-size #'ebox-style--preferred-size-p)
|
||||
('horizontal-size #'ebox-style--horizontal-size-p)
|
||||
('min-horizontal-size #'ebox-style--min-horizontal-size-p)
|
||||
('max-horizontal-size #'ebox-style--max-horizontal-size-p)
|
||||
@ -1083,6 +1141,12 @@ generated wrappers that already contain exact line breaks."
|
||||
('positive-integer #'ebox-style--positive-integer-p)
|
||||
('grid-placement #'ebox-style--grid-placement-p)
|
||||
('outer #'ebox-style--outer-p)
|
||||
('signed-size #'ebox-style--signed-size-p)
|
||||
('position #'ebox-style--position-p)
|
||||
('integer #'integerp)
|
||||
('layer #'ebox-style--layer-p)
|
||||
('anchor #'ebox-style--anchor-p)
|
||||
('placement #'ebox-style--placement-p)
|
||||
('font-family #'ebox-style--font-family-p)
|
||||
('color #'ebox-style--color-p)
|
||||
('font-size #'ebox-style--font-size-p)
|
||||
@ -1136,14 +1200,51 @@ generated wrappers that already contain exact line breaks."
|
||||
(flex-flow . ebox-style--flex-flow-shorthand)
|
||||
(flex . ebox-style--flex-shorthand))))
|
||||
|
||||
(defun ebox-style--size-axis (property &optional tag)
|
||||
"Return PROPERTY's size domain for author TAG when known."
|
||||
(let ((axis (plist-get property :size-axis)))
|
||||
(if (eq axis 'main)
|
||||
(pcase tag ('row 'inline) ('column 'block) (_ 'length))
|
||||
axis)))
|
||||
|
||||
(defun ebox-style--size-value-valid-p (property value &optional tag)
|
||||
"Return non-nil when PROPERTY's VALUE respects TAG's size domain.
|
||||
Grid track wrappers are traversed after their structural validator succeeds."
|
||||
(let ((axis (ebox-style--size-axis property tag)))
|
||||
(or (null axis)
|
||||
(if (eq (plist-get property :size-grammar) 'tracks)
|
||||
(cl-labels
|
||||
((valid-p (entry)
|
||||
(cond
|
||||
((atom entry) t)
|
||||
((eq (car entry) 'fr) t)
|
||||
((memq (car entry) '(minmax repeat))
|
||||
(cl-every #'valid-p (cdr entry)))
|
||||
((ebox-size-value-p entry)
|
||||
(ebox-size-value-for-axis-p entry axis))
|
||||
(t (cl-every #'valid-p entry)))))
|
||||
(valid-p value))
|
||||
(if (eq (plist-get property :validator) 'signed-size)
|
||||
(ebox-style--signed-size-p value axis)
|
||||
(or (symbolp value)
|
||||
(ebox-size-value-for-axis-p value axis)))))))
|
||||
|
||||
(defun ebox-style--schema-validator (property)
|
||||
"Return PROPERTY's grammar and unit-domain validator."
|
||||
(let ((validator (ebox-style--validator (plist-get property :validator))))
|
||||
(if (plist-get property :size-axis)
|
||||
(lambda (value)
|
||||
(and (funcall validator value)
|
||||
(ebox-style--size-value-valid-p property value)))
|
||||
validator)))
|
||||
|
||||
(defun ebox-style--schema-options (property)
|
||||
"Return ECSS registration options for PROPERTY metadata."
|
||||
(let ((options (list :initial (plist-get property :initial)
|
||||
:inherits (plist-get property :inherits)
|
||||
:normalizer (ebox-style--normalizer
|
||||
(plist-get property :normalizer))
|
||||
:validator (ebox-style--validator
|
||||
(plist-get property :validator))
|
||||
:validator (ebox-style--schema-validator property)
|
||||
:equality #'equal)))
|
||||
(when-let* ((shorthand (ebox-style--shorthand
|
||||
(plist-get property :shorthand))))
|
||||
@ -1206,7 +1307,7 @@ generated wrappers that already contain exact line breaks."
|
||||
(if (memq (plist-get property :name)
|
||||
'(:wrap-mode :item-gap :cross-align
|
||||
:border-top-p :grid-column-span
|
||||
:grid-row-span))
|
||||
:grid-row-span :layer :anchor :placement))
|
||||
'ebox
|
||||
'css)))
|
||||
(setq metadata
|
||||
@ -1392,6 +1493,7 @@ not affect declaration compilation and are intentionally excluded."
|
||||
(defun ebox-style--static-invalid-declaration-p (validator value)
|
||||
"Return non-nil when VALIDATOR rejects static VALUE."
|
||||
(pcase validator
|
||||
('preferred-size (not (ebox-style--preferred-size-p value)))
|
||||
('nonnegative-number
|
||||
(and (numberp value) (< value 0)))
|
||||
('horizontal-size (not (ebox-style--horizontal-size-p value)))
|
||||
@ -1401,6 +1503,12 @@ not affect declaration compilation and are intentionally excluded."
|
||||
('min-vertical-size (not (ebox-style--min-vertical-size-p value)))
|
||||
('max-vertical-size (not (ebox-style--max-vertical-size-p value)))
|
||||
('nonnegative-size (not (ebox-style--nonnegative-size-p value)))
|
||||
('signed-size (not (ebox-style--signed-size-p value)))
|
||||
('position (not (ebox-style--position-p value)))
|
||||
('integer (not (integerp value)))
|
||||
('layer (not (ebox-style--layer-p value)))
|
||||
('anchor (not (ebox-style--anchor-p value)))
|
||||
('placement (not (ebox-style--placement-p value)))
|
||||
('positive-integer
|
||||
(and (numberp value) (not (ebox-style--positive-integer-p value))))
|
||||
('grid-placement
|
||||
@ -1437,14 +1545,23 @@ not affect declaration compilation and are intentionally excluded."
|
||||
('wrap-mode
|
||||
(not (ebox-style--wrap-mode-p value)))))
|
||||
|
||||
(defun ebox-style--validate-declaration-values (declarations)
|
||||
"Return DECLARATIONS after static Ebox-owned value validation."
|
||||
(defun ebox-style--validate-declaration-values (declarations &optional tag)
|
||||
"Return DECLARATIONS after static value and author TAG axis validation."
|
||||
(cl-loop for (property value) on declarations by #'cddr
|
||||
for schema = (ebox-style--property property)
|
||||
for validator = (and schema (plist-get schema :validator))
|
||||
when (ebox-style--static-invalid-declaration-p validator value)
|
||||
do (error "ebox: invalid value for %S: %S"
|
||||
(plist-get schema :name) value))
|
||||
for axis = (ebox-style--size-axis schema tag)
|
||||
when (or (ebox-style--static-invalid-declaration-p validator value)
|
||||
(not (ebox-style--size-value-valid-p schema value tag)))
|
||||
do (if axis
|
||||
(signal 'ebox-size-error
|
||||
(list
|
||||
(format "Invalid value for %S; allowed units: %S"
|
||||
(plist-get schema :name)
|
||||
(cdr (assq axis ebox-size-axis-units)))
|
||||
value))
|
||||
(error "ebox: invalid value for %S: %S"
|
||||
(plist-get schema :name) value)))
|
||||
declarations)
|
||||
|
||||
(defconst ebox-style--edge-projection-groups
|
||||
@ -1539,20 +1656,21 @@ When STRICT is non-nil, reject properties outside the Ebox style domain."
|
||||
unless (ebox-style--form-property-allowed-p tag property)
|
||||
do (error "Ebox %S does not accept %S"
|
||||
tag (plist-get property :name))))
|
||||
(ebox-style--validate-declaration-values declarations)
|
||||
(ebox-style--validate-declaration-values declarations tag)
|
||||
declarations)
|
||||
|
||||
(defun ebox-style--validate-author-values (plist)
|
||||
"Reject explicit author values outside the public property grammars.
|
||||
"Reject values in author PLIST outside the public property grammars.
|
||||
An ECSS schema may use nil as an internal initial value, but nil is not an
|
||||
author value unless the public grammar is boolean."
|
||||
author value unless the public grammar is boolean or an optional anchor."
|
||||
(unless (ebox-style--valid-plist-p plist)
|
||||
(user-error "Ebox style declarations must be an even property list"))
|
||||
(cl-loop for (name value) on plist by #'cddr
|
||||
for property = (ebox-style--property name)
|
||||
when (and property
|
||||
(null value)
|
||||
(not (eq (plist-get property :validator) 'boolean)))
|
||||
(not (memq (plist-get property :validator)
|
||||
'(boolean anchor))))
|
||||
do (error "ebox: invalid value for %S: nil"
|
||||
(plist-get property :name)))
|
||||
plist)
|
||||
@ -1965,11 +2083,16 @@ explicit generation SOURCE-INDEX; facts are never recovered from its handle."
|
||||
(ebox-style--custom-property-p property))
|
||||
append (list property value)))
|
||||
|
||||
(defvar ebox-style--box-runtime-defaults)
|
||||
|
||||
(defun ebox-style--reset-box-engine-style (box)
|
||||
"Reset BOX engine style fields to their declared defaults."
|
||||
(cl-loop for (property value) on ebox--longhand by #'cddr
|
||||
unless (memq property '(:content :scroll-offset))
|
||||
do (plist-put box property (copy-tree value)))
|
||||
(unless (eq (plist-get box :ebox-kind) 'text)
|
||||
(cl-loop for (property value) on ebox-style--box-runtime-defaults by #'cddr
|
||||
do (plist-put box property (copy-tree value))))
|
||||
box)
|
||||
|
||||
(defun ebox-style--apply-engine-values (box values)
|
||||
@ -2245,6 +2368,9 @@ owned by NEW; only fields owned by the unchanged computed style are shared."
|
||||
(cl-loop for (property _value) on ebox--longhand by #'cddr
|
||||
unless (memq property '(:content :scroll-offset))
|
||||
do (ebox-style--transfer-node-property old new property))
|
||||
(when (eq (plist-get new :ebox-kind) 'box)
|
||||
(cl-loop for (property _value) on ebox-style--box-runtime-defaults by #'cddr
|
||||
do (ebox-style--transfer-node-property old new property)))
|
||||
(dolist (property ebox-style--item-projection-properties)
|
||||
(ebox-style--transfer-node-property old new property))
|
||||
new)
|
||||
@ -2327,31 +2453,12 @@ owned by NEW; only fields owned by the unchanged computed style are shared."
|
||||
entries))))
|
||||
|
||||
(defun ebox-style--convert (value converter)
|
||||
"Convert VALUE using Ebox engine CONVERTER."
|
||||
"Project VALUE with CONVERTER without resolving contextual size units.
|
||||
Layout owns used pixel/line values. Computed style and its cached engine
|
||||
projection retain expressions so viewport, container, and font changes can
|
||||
resolve them again. Numeric zero remains valid for private generated edges."
|
||||
(pcase converter
|
||||
('pixel (or (ebox--nonnegative-horizontal-size-pixels value nil) 0))
|
||||
('size-pixel (or (ebox--nonnegative-horizontal-size-pixels value nil) 0))
|
||||
('border-pixel
|
||||
(unless (ebox-style--border-width-p value)
|
||||
(error "ebox: border width must be a nonnegative integer pixel value: %S"
|
||||
value))
|
||||
value)
|
||||
('preferred-size
|
||||
(ebox--normalize-horizontal-size-value
|
||||
value ebox--preferred-size-keywords))
|
||||
('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))
|
||||
((or 'pixel 'size-pixel 'border-pixel 'line) (or value 0))
|
||||
('color (ebox-style--parse-color value))
|
||||
(_ value)))
|
||||
|
||||
@ -2442,6 +2549,16 @@ used geometry and become zero when the corresponding style is not `solid'."
|
||||
(ebox-style--apply-border-used-widths
|
||||
(ebox-style--validate-edge-longhands expanded))))
|
||||
|
||||
(defconst ebox-style--box-runtime-defaults
|
||||
(ebox-style--expand-engine-plist
|
||||
(cl-loop for property in ebox-style--property-definitions
|
||||
when (plist-get property :runtime-default)
|
||||
append (list (plist-get property :name)
|
||||
(copy-tree (plist-get property :initial)))))
|
||||
"Schema-owned defaults for additional Box runtime style fields.
|
||||
Reset and source-stable transfer share this registration. Canonical Text
|
||||
does not materialize these box-only placement facts.")
|
||||
|
||||
(defun ebox-style--border-delta-value
|
||||
(declarations full-declarations style name)
|
||||
"Return border NAME from delta, complete declarations, STYLE, or initial."
|
||||
|
||||
2254
ebox-surface.el
2254
ebox-surface.el
File diff suppressed because it is too large
Load Diff
37
ebox-tree.el
37
ebox-tree.el
@ -15,6 +15,7 @@
|
||||
(require 'ebox-style)
|
||||
(require 'ebox-node-factory)
|
||||
(require 'ebox-child-range)
|
||||
(require 'ebox-runtime-index)
|
||||
|
||||
(defvar ebox-tree--incoming-source-indexes nil
|
||||
"Transaction-local handle to incoming source-index map for one delta.")
|
||||
@ -26,6 +27,7 @@
|
||||
(declare-function ebox--next-runtime-node-id "ebox" ())
|
||||
(declare-function ebox--string-region-ids "ebox" (string))
|
||||
(declare-function ebox--box-visible-overflow-p "ebox" (box))
|
||||
(declare-function ebox--flex-validate-item-bases "ebox-flex" (props children))
|
||||
|
||||
(defconst ebox--default-display '(block flow)
|
||||
"Default CSS-like display for a plain Ebox box.")
|
||||
@ -734,21 +736,33 @@ handles, reused node objects, and cycles without consulting node metadata."
|
||||
keys)))
|
||||
(_ (error "Ebox participation requires a Flex or Grid parent: %S"
|
||||
keys)))))
|
||||
(when (and (or (eq source 'computed) (not (ebox-style-cascade-active-p)))
|
||||
(eq (ebox-tree-layout-kind node) 'flex))
|
||||
(ebox--flex-validate-item-bases
|
||||
(ebox-tree-layout-props node) (ebox-tree-layout-children node)))
|
||||
node)
|
||||
|
||||
(defun ebox-tree--indexed-parent-context
|
||||
(node-id node-table parent-table)
|
||||
"Return the direct parent layout kind for NODE-ID in indexed tree."
|
||||
(let* ((parent-id (gethash node-id parent-table))
|
||||
(parent (and parent-id (gethash parent-id node-table))))
|
||||
(let* ((parent-id (ebox-runtime-index-get node-id parent-table))
|
||||
(parent (and parent-id (ebox-runtime-index-get parent-id node-table))))
|
||||
(and parent (ebox-tree--child-layout-kind parent nil))))
|
||||
|
||||
(defun ebox-tree-validate-indexed-participation
|
||||
(node-table parent-table node-ids &optional source source-index)
|
||||
"Validate NODE-IDS in indexed final tree using AUTHOR or COMPUTED SOURCE."
|
||||
(setq source (or source 'author))
|
||||
(dolist (node-id (delete-dups (copy-sequence node-ids)))
|
||||
(when-let* ((node (gethash node-id node-table)))
|
||||
(dolist (node-id
|
||||
(delete-dups
|
||||
(append (copy-sequence node-ids)
|
||||
(when (or (eq source 'computed)
|
||||
(not (ebox-style-cascade-active-p)))
|
||||
(delq nil (mapcar
|
||||
(lambda (id)
|
||||
(ebox-runtime-index-get id parent-table))
|
||||
node-ids))))))
|
||||
(when-let* ((node (ebox-runtime-index-get node-id node-table)))
|
||||
(ebox-tree--validate-node-parent-participation
|
||||
node
|
||||
(ebox-tree--indexed-parent-context node-id node-table parent-table)
|
||||
@ -898,7 +912,10 @@ mark every ancestor dirty."
|
||||
(while (and equal-p old new)
|
||||
(setq equal-p
|
||||
(and (eq (car old) (car new))
|
||||
(equal-including-properties (cadr old) (cadr new))))
|
||||
(funcall (if (eq (car old) :surface-properties)
|
||||
#'ebox-interaction-surface-equal-p
|
||||
#'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))))
|
||||
@ -920,8 +937,10 @@ OLD and NEW are source nodes with the same retained runtime identity."
|
||||
(new-entry (plist-member new-signature key)))
|
||||
(unless (and (eq (not (null old-entry))
|
||||
(not (null new-entry)))
|
||||
(equal-including-properties
|
||||
(cadr old-entry) (cadr new-entry)))
|
||||
(funcall (if (eq key :surface-properties)
|
||||
#'ebox-interaction-surface-equal-p
|
||||
#'equal-including-properties)
|
||||
(cadr old-entry) (cadr new-entry)))
|
||||
(push (if (eq key :ebox-layout-props) :props key)
|
||||
changed))))))))
|
||||
(nreverse changed)))
|
||||
@ -1379,12 +1398,12 @@ stale until a query or full projection materializes them."
|
||||
(parent-id (nth 2 entry))
|
||||
parent-subject)
|
||||
(while (and parent-id (null parent-subject))
|
||||
(when-let* ((parent (gethash parent-id node-table)))
|
||||
(when-let* ((parent (ebox-runtime-index-get parent-id node-table)))
|
||||
(when-let* ((parent-handle
|
||||
(ebox-tree-node-source-handle parent)))
|
||||
(setq parent-subject (node-subject parent-handle))))
|
||||
(unless parent-subject
|
||||
(setq parent-id (gethash parent-id parent-table))))
|
||||
(setq parent-id (ebox-runtime-index-get parent-id parent-table))))
|
||||
(setf (ecss-subject-parent subject) parent-subject)
|
||||
(puthash (ebox-source--handle-id-view handle)
|
||||
subject added-subjects)
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
;;; ebox-viewport.el --- Immediate Emacs viewport delivery -*- lexical-binding: t; -*-
|
||||
;;; ebox-viewport.el --- Event-loop viewport delivery -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Emacs owns window events; Ebox owns the mounted viewport. This module is
|
||||
;; the single bridge between them. Size hooks only sample the latest visible
|
||||
;; dimensions and synchronously publish one exact incremental viewport update.
|
||||
;; Emacs serializes the hook; Ebox's retained renderer bounds each transaction.
|
||||
;; the single bridge between them. Size hooks record frame intents; one task
|
||||
;; samples current visible dimensions and publishes after the redisplay hook.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'ebox-surface)
|
||||
|
||||
(declare-function ebox--buffer-render-state "ebox-incremental" (buffer))
|
||||
@ -15,23 +15,24 @@
|
||||
"ebox" (buffer viewport-width &optional viewport-height))
|
||||
|
||||
(defvar ebox--window-size-change-in-progress nil
|
||||
"Non-nil while Ebox is synchronizing visible window viewports.
|
||||
External GUI events cannot interleave in Emacs's single thread. A nested call
|
||||
can only originate from the current publication and therefore has no newer
|
||||
window geometry to publish.")
|
||||
"Non-nil while Ebox is draining one batch of viewport notifications.")
|
||||
|
||||
(defvar ebox--window-size-change-pending-frames nil
|
||||
"Frames awaiting viewport synchronization, deduplicated by EQ.
|
||||
The queue holds no buffers, runtime roots or sampled dimensions.")
|
||||
|
||||
(defvar ebox--window-size-change-timer nil
|
||||
"The single owned task scheduled to drain viewport notifications.")
|
||||
|
||||
(defun ebox-viewport-window-width (window)
|
||||
"Return Ebox's display-safe viewport width for live WINDOW."
|
||||
(and (window-live-p window)
|
||||
(ebox-surface--window-content-width window)))
|
||||
|
||||
(defun ebox--window-size-change (frame)
|
||||
"Synchronize mounted Ebox buffers visible in FRAME immediately."
|
||||
(when (and (frame-live-p frame)
|
||||
(not noninteractive)
|
||||
(not ebox--window-size-change-in-progress))
|
||||
(let ((ebox--window-size-change-in-progress t)
|
||||
(seen (make-hash-table :test #'eq)))
|
||||
(defun ebox--window-size-change-sync-frame (frame)
|
||||
"Synchronize current mounted Ebox viewports whose canonical window is in FRAME."
|
||||
(when (frame-live-p frame)
|
||||
(let ((seen (make-hash-table :test #'eq)))
|
||||
(dolist (window (window-list frame 'no-minibuf))
|
||||
(when (window-live-p window)
|
||||
(let ((buffer (window-buffer window)))
|
||||
@ -58,6 +59,51 @@ window geometry to publish.")
|
||||
(ebox-rerender-buffer-with-context
|
||||
buffer width height))))))))))))
|
||||
|
||||
(defun ebox--window-size-change-schedule ()
|
||||
"Schedule one task for live pending frames outside the current batch."
|
||||
(setq ebox--window-size-change-pending-frames
|
||||
(cl-delete-if-not #'frame-live-p
|
||||
ebox--window-size-change-pending-frames))
|
||||
(when (and ebox--window-size-change-pending-frames
|
||||
(not ebox--window-size-change-in-progress)
|
||||
(not ebox--window-size-change-timer))
|
||||
(setq ebox--window-size-change-timer
|
||||
(run-at-time 0 nil #'ebox--window-size-change-drain))))
|
||||
|
||||
(defun ebox--window-size-change-drain ()
|
||||
"Consume one frame batch, leaving nested notifications for a later task."
|
||||
(unless ebox--window-size-change-in-progress
|
||||
(let ((ebox--window-size-change-in-progress t)
|
||||
(frames (nreverse ebox--window-size-change-pending-frames))
|
||||
first-error)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(setq ebox--window-size-change-pending-frames nil
|
||||
ebox--window-size-change-timer nil)
|
||||
;; Timer dispatch inhibits quit by default. Enable C-g only after
|
||||
;; cleanup is installed; TP retains its own final-accept protection.
|
||||
(let ((inhibit-quit nil))
|
||||
(while frames
|
||||
(let ((frame (pop frames)))
|
||||
(condition-case failure
|
||||
(ebox--window-size-change-sync-frame frame)
|
||||
(error (unless first-error (setq first-error failure))))))))
|
||||
;; A quit may leave unstarted frames. Keep those intents, but do not
|
||||
;; retry the frame whose publication already started and failed.
|
||||
(dolist (frame frames)
|
||||
(unless (memq frame ebox--window-size-change-pending-frames)
|
||||
(push frame ebox--window-size-change-pending-frames)))
|
||||
(let ((ebox--window-size-change-in-progress nil))
|
||||
(ebox--window-size-change-schedule)))
|
||||
(when first-error (signal (car first-error) (cdr first-error))))))
|
||||
|
||||
(defun ebox--window-size-change (frame)
|
||||
"Record FRAME's latest viewport intent without rendering inside the hook."
|
||||
(when (and (frame-live-p frame) (not noninteractive))
|
||||
(unless (memq frame ebox--window-size-change-pending-frames)
|
||||
(push frame ebox--window-size-change-pending-frames))
|
||||
(ebox--window-size-change-schedule)))
|
||||
|
||||
(when (boundp 'window-size-change-functions)
|
||||
(add-hook 'window-size-change-functions #'ebox--window-size-change))
|
||||
|
||||
|
||||
450
ebox.el
450
ebox.el
@ -1,10 +1,16 @@
|
||||
;;; ebox.el --- Pixel-perfect box model renderer -*- lexical-binding: t -*-
|
||||
|
||||
;; SPDX-License-Identifier: GPL-3.0-or-later
|
||||
;; Author: ebox contributors
|
||||
;; Version: 2.0.0
|
||||
;; Package-Requires: ((emacs "29.1") (ecss "0.1.0") (tp "1.0.0"))
|
||||
;; Version: 3.0.0
|
||||
;; Package-Requires: ((emacs "29.1") (ecss "0.1.0") (tp "2.0.0"))
|
||||
;; Keywords: ui, graphics, convenience
|
||||
;; URL: https://github.com/ginqi7/ebox
|
||||
;; URL: https://gitea.gklazycat.heiyu.space/geekinney/ebox
|
||||
|
||||
;; This program 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. See LICENSE for the full text.
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
@ -23,12 +29,14 @@
|
||||
"Directory containing the active Ebox Lisp sources.")
|
||||
|
||||
(defconst ebox--compile-sources
|
||||
'("ebox-cache.el" "ebox-source.el" "ebox-state-contract.el"
|
||||
"ebox-font.el" "ebox-style.el"
|
||||
'("ebox-cache.el" "ebox-interaction.el" "ebox-source.el" "ebox-runtime-index.el"
|
||||
"ebox-state-contract.el"
|
||||
"ebox-font.el" "ebox-size.el" "ebox-style.el"
|
||||
"ebox-layout-config.el"
|
||||
"ebox-node-factory.el"
|
||||
"ebox-child-range.el" "ebox-tree.el" "ebox-measure.el"
|
||||
"ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el"
|
||||
"ebox-fragment.el" "ebox-render-context.el" "ebox-composite.el"
|
||||
"ebox-layer.el" "ebox-layout.el"
|
||||
"ebox-flex.el" "ebox-grid.el" "ebox-canonical.el"
|
||||
"ebox-buffer-backend.el" "ebox-patch-plan.el" "ebox-incremental.el"
|
||||
"ebox-native-commit.el" "ebox-surface.el" "ebox-viewport.el"
|
||||
@ -44,6 +52,7 @@
|
||||
(require 'subr-x)
|
||||
(require 'ebox-cache)
|
||||
(require 'ebox-source)
|
||||
(require 'ebox-runtime-index)
|
||||
(require 'ebox-state-contract)
|
||||
(require 'ebox-font)
|
||||
(require 'ebox-style)
|
||||
@ -76,6 +85,12 @@
|
||||
|
||||
(setq ebox--layout-context-port ebox--default-layout-context-port)
|
||||
|
||||
(defun ebox-incremental-flush (buffer)
|
||||
"Flush BUFFER's explicit incremental batch through Surface observation."
|
||||
(ebox-surface-call-with-observation
|
||||
buffer 'batch
|
||||
(lambda () (ebox-incremental--flush buffer))))
|
||||
|
||||
(declare-function ebox--buffer-update-report "ebox-incremental" (buffer))
|
||||
(declare-function ebox--set-buffer-update-report
|
||||
"ebox-incremental" (buffer report))
|
||||
@ -376,13 +391,13 @@ Set it to nil to inherit the surrounding `gc-cons-percentage'."
|
||||
|
||||
(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
|
||||
DSL sizes may use `(vw N)' for N percent of this width 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.")
|
||||
The render context converts this value to pixels with the frame line metric.
|
||||
Block-axis DSL sizes use `(vh N)' for N percent of that pixel height.")
|
||||
|
||||
(defvar ebox--region-box-table)
|
||||
(defconst ebox-region-types
|
||||
@ -425,8 +440,18 @@ caller, such as the Playground preview, renders against a concrete window.")
|
||||
(cl-incf ebox--region-id-counter))
|
||||
|
||||
(defsubst ebox-get (box property)
|
||||
"Retrieve PROPERTY from BOX."
|
||||
(plist-get box property))
|
||||
"Retrieve PROPERTY from BOX, resolving deferred engine edge lengths."
|
||||
(let ((value (plist-get box property)))
|
||||
(if (and (consp value)
|
||||
(memq property
|
||||
'(:padding-left-pixel :padding-right-pixel
|
||||
:margin-left-pixel :margin-right-pixel
|
||||
:border-left-pixel :border-right-pixel
|
||||
:border-top-pixel :border-bottom-pixel
|
||||
:padding-top-height :padding-bottom-height
|
||||
:margin-top-height :margin-bottom-height)))
|
||||
(ebox--edge-used-value box property value)
|
||||
value)))
|
||||
|
||||
(defun ebox-put (box property value)
|
||||
"Set private runtime PROPERTY to VALUE in BOX and return BOX.
|
||||
@ -484,8 +509,8 @@ completed report. Nil removes observation without affecting rendering."
|
||||
(plist-get node :node-id))
|
||||
|
||||
(defsubst ebox-string-lines (string)
|
||||
"Split STRING into a list of lines, preserving text properties."
|
||||
(when string
|
||||
"Split STRING into lines, preserving properties; empty input has no lines."
|
||||
(when (and string (not (string-empty-p string)))
|
||||
(let ((start 0)
|
||||
(lines nil))
|
||||
(while (string-match "\n" string start)
|
||||
@ -496,12 +521,18 @@ completed report. Nil removes observation without affecting rendering."
|
||||
|
||||
(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)))
|
||||
Empty input has zero lines. Count newlines without allocating a line list."
|
||||
(if (or (null string) (string-empty-p string))
|
||||
0
|
||||
(1+ (cl-count ?\n string))))
|
||||
|
||||
(defsubst ebox-lines-join (lines)
|
||||
"Join a list of LINES into a single string."
|
||||
(string-join lines "\n"))
|
||||
"Join LINES into a string, preserving an explicit empty line's height.
|
||||
A single empty line needs a zero-width display carrier to distinguish it from
|
||||
no lines. Multiline strings already preserve their blank lines as newlines."
|
||||
(if (and lines (null (cdr lines)) (equal (car lines) ""))
|
||||
(propertize " " 'display '(space :width (0)))
|
||||
(string-join lines "\n")))
|
||||
|
||||
(defun ebox--string-repeat-lines (string count)
|
||||
"Repeat a single line STRING for COUNT times."
|
||||
@ -518,9 +549,8 @@ 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."
|
||||
text and ownership metadata stay intact. Native interaction properties are
|
||||
explicitly cleared so invisible content cannot retain an active hit surface."
|
||||
(ebox--maplines
|
||||
(lambda (line)
|
||||
(if (string-empty-p line)
|
||||
@ -536,7 +566,11 @@ and incremental diffs byte-compatible with the visible rendering."
|
||||
(base (/ width length))
|
||||
(remainder (- width (* base length))))
|
||||
(remove-text-properties
|
||||
0 length '(face nil mouse-face nil) line)
|
||||
0 length '(face nil) line)
|
||||
(add-text-properties
|
||||
0 length '(mouse-face nil ebox--hover-style nil
|
||||
help-echo nil pointer nil keymap nil) line)
|
||||
(ebox-interaction--fill-property line 0 length 'keymap nil)
|
||||
(dotimes (index length)
|
||||
(put-text-property
|
||||
index (1+ index) 'display
|
||||
@ -583,35 +617,53 @@ segments that incremental publication still needs."
|
||||
'(ebox-content ebox-content-idx
|
||||
ebox-content-owner ebox-content-owners)))))
|
||||
|
||||
(defun ebox--pad-lines-vertical (lines height &optional offset padding-string)
|
||||
"Pad or clip LINES to HEIGHT without changing their strings or list spine.
|
||||
OFFSET shifts content toward the bottom when positive and toward the top when
|
||||
negative. PADDING-STRING defaults to a blank matching the first line's width.
|
||||
The result may borrow LINES or their strings. Treat it as read-only.
|
||||
Empty input has zero lines before padding. Height zero clips to nil."
|
||||
(let ((count (length lines)))
|
||||
(if (> height count)
|
||||
(let* ((fill (or padding-string
|
||||
(ebox--pixel-blank
|
||||
(ebox--string-pixel-width (car lines)) 1)
|
||||
""))
|
||||
(rest (- height count))
|
||||
(offset (or offset 0))
|
||||
(top (if (>= offset 0) (min offset rest)
|
||||
(max 0 (+ offset rest))))
|
||||
(bottom (- rest top)))
|
||||
(append (make-list top fill)
|
||||
(if (> bottom 0)
|
||||
(append lines (make-list bottom fill))
|
||||
lines)))
|
||||
(if (= height count) lines (seq-take lines height)))))
|
||||
|
||||
(defun ebox--vertical-alignment-offset (line-count height align)
|
||||
"Return the offset for LINE-COUNT lines in HEIGHT using ALIGN."
|
||||
(pcase align
|
||||
('bottom (- height line-count))
|
||||
('center (/ (- height line-count) 2))
|
||||
(_ 0)))
|
||||
|
||||
(defun ebox--lines-pad-vertical (string height &optional offset padding-string)
|
||||
"Extend STRING to HEIGHT lines.
|
||||
"Pad or clip 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))))
|
||||
PADDING-STRING fills added lines; its default matches STRING's first-line width."
|
||||
(if (and (stringp string) (not (string-empty-p string))
|
||||
(integerp height) (= height 1)
|
||||
(not (string-match-p "\n" string)))
|
||||
(copy-sequence string)
|
||||
(ebox-lines-join
|
||||
(ebox--pad-lines-vertical
|
||||
(ebox-string-lines string) height offset padding-string))))
|
||||
|
||||
(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))))
|
||||
(let ((offset (ebox--vertical-alignment-offset
|
||||
(ebox-string-height string) height align)))
|
||||
(ebox--lines-pad-vertical string height offset)))
|
||||
|
||||
(defun ebox--string-lines-pad-bottom (string height)
|
||||
@ -639,8 +691,10 @@ ALIGN can be `top', `center', or `bottom'."
|
||||
(apply #'cl-mapcar #'concat line-lists)))))
|
||||
|
||||
(defun ebox--lines-stack-vertical (&rest strings)
|
||||
"Stack STRINGS vertically."
|
||||
(setq strings (delq nil strings))
|
||||
"Stack nonempty STRINGS vertically, without adding zero-height separators."
|
||||
(setq strings (seq-filter (lambda (string)
|
||||
(and string (not (string-empty-p string))))
|
||||
strings))
|
||||
(ebox-lines-join strings))
|
||||
|
||||
(defsubst ebox-default-foreground ()
|
||||
@ -1135,7 +1189,7 @@ remain transaction-local."
|
||||
(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))
|
||||
(or (and node-table node-id (ebox-runtime-index-get node-id node-table))
|
||||
(plist-get (ebox--scroll-get-state region-id) :box)
|
||||
(gethash region-id ebox--region-box-table))))
|
||||
|
||||
@ -1158,7 +1212,7 @@ remain transaction-local."
|
||||
(or (ebox-get effective-box :scroll-offset) 0))
|
||||
(or portable
|
||||
(null node-table)
|
||||
(eq box (gethash node-id node-table)))
|
||||
(eq box (ebox-runtime-index-get 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))
|
||||
@ -1462,9 +1516,16 @@ coordinator."
|
||||
(when-let* ((start (ignore-errors (event-start event)))
|
||||
(window (posn-window start))
|
||||
((window-live-p window))
|
||||
(buffer (window-buffer window))
|
||||
(position (posn-point start)))
|
||||
(ebox--native-buffer-scroll-at-position buffer position delta)))
|
||||
((null (posn-area start))))
|
||||
(with-current-buffer (window-buffer window)
|
||||
(let* ((position (posn-point start))
|
||||
(pos (if (markerp position)
|
||||
(and (eq (marker-buffer position) (current-buffer))
|
||||
(marker-position position))
|
||||
position)))
|
||||
(when (and (integerp pos) (<= (point-min) pos) (< pos (point-max)))
|
||||
(ebox--native-buffer-scroll-at-position
|
||||
(current-buffer) pos delta))))))
|
||||
|
||||
(defun ebox--scroll-box-contains-grid-p (box)
|
||||
"Return non-nil when BOX's retained scroll source contains a Grid node."
|
||||
@ -1717,14 +1778,10 @@ object. Unrelated ownerless and foreign-buffer states remain isolated."
|
||||
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))
|
||||
"Return the next possible region boundary in STRING after POS, up to LIMIT.
|
||||
Finer non-region property boundaries are safe: each caller merges equal owner
|
||||
ranges. Using the native interval cursor avoids one search per role."
|
||||
(or (next-property-change pos string limit) limit))
|
||||
|
||||
(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."
|
||||
@ -1886,14 +1943,34 @@ uses their rendered descendants."
|
||||
(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))))
|
||||
(let ((spans (make-hash-table :test 'equal))
|
||||
(pos 0)
|
||||
(limit (length line)))
|
||||
;; Aggregate all owners during the same property-run scan.
|
||||
;; A foreign direct content owner invalidates that region's
|
||||
;; entire hull, exactly as the single-region query does.
|
||||
(while (< pos limit)
|
||||
(let ((next (ebox--string-next-region-property-change
|
||||
line pos limit))
|
||||
(roles (ebox--string-region-role-ids-at line pos)))
|
||||
(dolist (id (ebox--string-region-ids-at line pos))
|
||||
(let ((span (gethash id spans)))
|
||||
(unless (eq span 'incompatible)
|
||||
(cond
|
||||
((cl-some (lambda (role)
|
||||
(and (eq (car role) 'content)
|
||||
(not (equal (cdr role) id))))
|
||||
roles)
|
||||
(puthash id 'incompatible spans))
|
||||
(span (setcdr span next))
|
||||
(t (puthash id (cons pos next) spans))))))
|
||||
(setq pos (max next (1+ pos)))))
|
||||
(maphash
|
||||
(lambda (id span)
|
||||
(unless (eq span 'incompatible)
|
||||
(puthash id (cons (cons line-index span)
|
||||
(gethash id index)) index)))
|
||||
spans)))
|
||||
(maphash (lambda (region-id spans)
|
||||
(puthash region-id (nreverse spans) index))
|
||||
index)
|
||||
@ -2330,12 +2407,14 @@ published or mutated. Return non-nil when the isolated render ran."
|
||||
(plist-get owner :box)))))
|
||||
|
||||
(defun ebox--literal-root-pixel-width (box)
|
||||
"Return BOX's demo-compatible literal pixel width, or nil."
|
||||
"Return BOX's fixed pixel-only width, or nil.
|
||||
Relative and font-dependent widths cannot prove a predicted pixel edit."
|
||||
(let ((width (and box (ebox-get box :width))))
|
||||
(cond
|
||||
((numberp width) width)
|
||||
((and (consp width) (null (cdr width)) (numberp (car width)))
|
||||
(car width)))))
|
||||
((and (consp width) (ebox-size-value-p width)
|
||||
(null (ebox-size-dependencies width)))
|
||||
(ebox-size-resolve width nil)))))
|
||||
|
||||
(defun ebox--invalidate-reflow-prewarm-scratch-signatures
|
||||
(scratch region-id)
|
||||
@ -2455,7 +2534,8 @@ publishes the prepared rendered-body entry plus its derived root templates."
|
||||
(puthash
|
||||
buffer
|
||||
(run-with-idle-timer
|
||||
(max 0 ebox-runtime-idle-reflow-cache-prewarm-delay)
|
||||
(ebox--idle-continuation-delay
|
||||
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)))))
|
||||
@ -2497,7 +2577,8 @@ predictions are skipped."
|
||||
(puthash
|
||||
buffer
|
||||
(run-with-idle-timer
|
||||
(max 0 ebox-runtime-idle-reflow-cache-prewarm-delay)
|
||||
(ebox--idle-continuation-delay
|
||||
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))))))
|
||||
@ -2903,13 +2984,16 @@ Return non-nil while more work remains."
|
||||
(let (candidates)
|
||||
(maphash
|
||||
(lambda (region-id _state)
|
||||
(when-let* ((bounds
|
||||
(ebox-surface-region-bounds
|
||||
(current-buffer) region-id)))
|
||||
(when (and (<= (car bounds) pos) (< pos (cdr bounds)))
|
||||
(push (cons region-id
|
||||
(ebox--scroll-region-semantic-depth region-id))
|
||||
candidates))))
|
||||
;; Rows can interleave this box's mounts with unrelated columns.
|
||||
;; Their enclosing interval is not the box's mouse hit area.
|
||||
(when (cl-some
|
||||
(lambda (mount)
|
||||
(and (<= (plist-get mount :start) pos)
|
||||
(< pos (plist-get mount :end))))
|
||||
(ebox-surface-region-mounts (current-buffer) region-id))
|
||||
(push (cons region-id
|
||||
(ebox--scroll-region-semantic-depth region-id))
|
||||
candidates)))
|
||||
ebox--scroll-global-state)
|
||||
(mapcar #'car
|
||||
(sort candidates
|
||||
@ -2925,29 +3009,11 @@ Scroll routing uses semantic ancestry rather than mount span length because
|
||||
nested owners may render equal-sized clipped spans."
|
||||
(let* ((state (ebox--buffer-render-state (current-buffer)))
|
||||
(region-nodes (plist-get state :region-node-table))
|
||||
(node-id (and region-nodes (gethash region-id region-nodes))))
|
||||
(node-id
|
||||
(and region-nodes
|
||||
(ebox-runtime-index-get region-id region-nodes))))
|
||||
(if node-id (ebox-surface--node-depth state node-id) -1)))
|
||||
|
||||
(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* ((bounds
|
||||
(ebox-surface-region-bounds
|
||||
(current-buffer) region-id)))
|
||||
(push (cons region-id
|
||||
(ebox--scroll-region-semantic-depth region-id))
|
||||
candidates)))
|
||||
ebox--scroll-global-state)
|
||||
(mapcar #'car
|
||||
(sort candidates
|
||||
(lambda (a b)
|
||||
(if (= (cdr a) (cdr b))
|
||||
(string< (format "%S" (car a))
|
||||
(format "%S" (car 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)
|
||||
@ -3177,7 +3243,9 @@ inside the old prefix, so a line-slide never mixes two layout versions."
|
||||
(setq value
|
||||
(plist-put
|
||||
value :retained-scroll-content-p
|
||||
(and scroll-patch-fast-p root-id owner-id
|
||||
(and (eq (plist-get commit-input :projection-kind)
|
||||
'scroll-patch)
|
||||
scroll-patch-fast-p root-id owner-id
|
||||
(= root-id owner-id))))
|
||||
(if (plist-get transition :native-materialize-p)
|
||||
(plist-put value :native-scroll-materialize-p t)
|
||||
@ -3592,20 +3660,16 @@ line count so scroll distance is neither duplicated nor dropped."
|
||||
"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)))
|
||||
((window-live-p window))
|
||||
((null (posn-area start))))
|
||||
(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))))))
|
||||
(pos (if (markerp position)
|
||||
(and (eq (marker-buffer position) (current-buffer))
|
||||
(marker-position position))
|
||||
position)))
|
||||
(when (and (integerp pos) (<= (point-min) pos) (< pos (point-max)))
|
||||
(ebox--scroll-region-ids-at-pos pos))))))
|
||||
|
||||
(defun ebox--wheel-region-id (event)
|
||||
"Return the innermost ebox region id under mouse wheel EVENT, or nil."
|
||||
@ -3790,11 +3854,13 @@ Defaults to the current buffer."
|
||||
:text-align :vertical-align
|
||||
:overflow :wrap-mode
|
||||
:scroll-offset :visibility)
|
||||
(cl-loop for (property _value) on ebox-style--box-runtime-defaults by #'cddr
|
||||
collect property)
|
||||
ebox-style--item-projection-properties)
|
||||
"Longhand box properties accepted by `ebox-region-update'.")
|
||||
|
||||
(defconst ebox--region-update-control-properties
|
||||
'(:content :scroll-offset)
|
||||
(append '(:content :scroll-offset) ebox-interaction-properties)
|
||||
"Surface controls accepted by `ebox-region-update' outside style schemas.")
|
||||
|
||||
(defun ebox--region-update-style-properties (props)
|
||||
@ -3818,13 +3884,15 @@ Defaults to the current buffer."
|
||||
(:background-color :bgcolor)
|
||||
(_ property)))
|
||||
|
||||
(defun ebox--region-update-direct-text-node (box)
|
||||
"Return BOX's sole direct canonical Text child, or nil."
|
||||
(let ((children (and (ebox-box-node-p box)
|
||||
(ebox-box-node-children box))))
|
||||
(and (= (length children) 1)
|
||||
(ebox-text-node-p (car children))
|
||||
(car children))))
|
||||
(defun ebox--region-update-direct-text-node (node)
|
||||
"Return canonical Text NODE itself or NODE's sole direct Text child."
|
||||
(if (ebox-text-node-p node)
|
||||
node
|
||||
(let ((children (and (ebox-box-node-p node)
|
||||
(ebox-box-node-children node))))
|
||||
(and (= (length children) 1)
|
||||
(ebox-text-node-p (car children))
|
||||
(car children)))))
|
||||
|
||||
(defun ebox--region-update-content-value (box)
|
||||
"Return the mutable region content represented by canonical BOX."
|
||||
@ -3845,6 +3913,10 @@ Defaults to the current buffer."
|
||||
((eq target-key :content)
|
||||
(setq content-seen t
|
||||
content-value value))
|
||||
((eq target-key :surface-properties)
|
||||
(unless (ebox-surface--retained-property-value-equal-p
|
||||
(ebox-get box target-key) value)
|
||||
(setq style-changed-p t)))
|
||||
((memq target-key ebox--region-update-longhand-props)
|
||||
(when (not (equal (ebox-get box target-key) value))
|
||||
(setq style-changed-p t)))
|
||||
@ -3856,7 +3928,7 @@ Defaults to the current buffer."
|
||||
(user-error "Ebox content update requires a string"))
|
||||
(when (and content-seen
|
||||
(not (ebox--region-update-direct-text-node box)))
|
||||
(user-error "Ebox content update requires one direct Text child"))
|
||||
(user-error "Ebox content update requires Text or a Box with one direct Text child"))
|
||||
(list :changed-p
|
||||
(or (not (equal declarations
|
||||
(ebox-style-node-declarations source-index box)))
|
||||
@ -3922,7 +3994,8 @@ cannot be changed through the unpublished batch root."
|
||||
(nreverse changed))))
|
||||
|
||||
(defun ebox--surface-region-apply-props
|
||||
(root source-index region-id expanded declarations style-declarations)
|
||||
(root source-index region-id expanded declarations style-declarations
|
||||
&optional interactions)
|
||||
"Apply DECLARATIONS then EXPANDED fields to REGION-ID in ROOT."
|
||||
(let ((box (ebox--root-region-box root region-id))
|
||||
(candidate-source-index source-index)
|
||||
@ -3933,11 +4006,15 @@ cannot be changed through the unpublished batch root."
|
||||
(not (ebox-style-declarations-equal-p
|
||||
declarations
|
||||
(ebox-style-node-declarations source-index box)))))
|
||||
(when declarations-changed
|
||||
(when (or declarations-changed (plist-member expanded :surface-properties))
|
||||
(let* ((old-handle (ebox-tree-node-source-handle box))
|
||||
(binding
|
||||
(ebox-source-index-rebind
|
||||
source-index old-handle :declarations declarations)))
|
||||
source-index old-handle :declarations declarations
|
||||
:interactions (if (plist-member expanded :surface-properties)
|
||||
interactions
|
||||
(ebox-source-index-interactions
|
||||
source-index old-handle)))))
|
||||
(setq candidate-source-index (car binding))
|
||||
(plist-put box :ebox-source-handle (cdr binding))))
|
||||
(when (plist-member style-declarations 'ebox/outer)
|
||||
@ -3954,8 +4031,13 @@ cannot be changed through the unpublished batch root."
|
||||
(cl-loop for (key value) on expanded by #'cddr
|
||||
for target-key = (ebox--region-update-normalize-property key)
|
||||
when (or (eq target-key :content)
|
||||
(eq target-key :surface-properties)
|
||||
(memq target-key ebox--region-update-longhand-props))
|
||||
when (or (not (equal (if (eq target-key :content)
|
||||
when (or (not (funcall
|
||||
(if (eq target-key :surface-properties)
|
||||
#'ebox-surface--retained-property-value-equal-p
|
||||
#'equal)
|
||||
(if (eq target-key :content)
|
||||
(ebox--region-update-content-value box)
|
||||
(ebox-get box target-key))
|
||||
value))
|
||||
@ -3964,9 +4046,14 @@ cannot be changed through the unpublished batch root."
|
||||
do (if (eq target-key :content)
|
||||
(if-let* ((text
|
||||
(ebox--region-update-direct-text-node box)))
|
||||
(plist-put text :ebox-text-value value)
|
||||
(progn
|
||||
(plist-put text :ebox-text-value value)
|
||||
;; Incremental source signatures and retained owner
|
||||
;; proofs read the engine content projection. Keep
|
||||
;; it in step with the canonical Text payload.
|
||||
(plist-put text :content value))
|
||||
(user-error
|
||||
"Ebox content update requires one direct Text child"))
|
||||
"Ebox content update requires Text or a Box with one direct Text child"))
|
||||
(plist-put box target-key value))
|
||||
and do (push target-key changed-keys)))
|
||||
(list :changed-keys (nreverse changed-keys)
|
||||
@ -4104,7 +4191,7 @@ cannot be changed through the unpublished batch root."
|
||||
entries)
|
||||
(let ((parents (ebox--buffer-parent-table buffer)) ancestors)
|
||||
(dolist (node-id node-ids)
|
||||
(while (setq node-id (gethash node-id parents))
|
||||
(while (setq node-id (ebox-runtime-index-get node-id parents))
|
||||
(push node-id ancestors)))
|
||||
(delete-dups ancestors))))
|
||||
|
||||
@ -4116,7 +4203,7 @@ cannot be changed through the unpublished batch root."
|
||||
(let ((parents (ebox--buffer-parent-table buffer)))
|
||||
(delete-dups
|
||||
(delq nil
|
||||
(mapcar (lambda (node-id) (gethash node-id parents))
|
||||
(mapcar (lambda (node-id) (ebox-runtime-index-get node-id parents))
|
||||
node-ids))))))
|
||||
|
||||
(defun ebox--publish-scoped-region-candidate
|
||||
@ -4195,7 +4282,8 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch."
|
||||
(source-index
|
||||
(or (ebox-incremental-surface-batch-source-index buffer)
|
||||
(plist-get state :source-index)))
|
||||
(published-root (plist-get state :root-node))
|
||||
(published-root (or (ebox-incremental-surface-batch-root buffer)
|
||||
(plist-get state :root-node)))
|
||||
(candidate-box
|
||||
(or (ebox--root-region-box published-root region-id)
|
||||
(user-error "Ebox region handle no longer resolves to a box")))
|
||||
@ -4208,11 +4296,27 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch."
|
||||
(ebox-style-node-declarations
|
||||
source-index candidate-box)
|
||||
style-declarations))
|
||||
(interaction-delta
|
||||
(ebox-interaction-normalize
|
||||
(cl-loop for (property value) on props by #'cddr
|
||||
when (memq property ebox-interaction-properties)
|
||||
append (list property value))))
|
||||
(interactions
|
||||
(when interaction-delta
|
||||
(let ((merged (ebox-source-index-interactions
|
||||
source-index
|
||||
(ebox-tree-node-source-handle candidate-box))))
|
||||
(cl-loop for (property value) on interaction-delta by #'cddr
|
||||
do (setq merged (plist-put merged property value)))
|
||||
merged)))
|
||||
(expanded
|
||||
(append
|
||||
(cl-loop for (property value) on props by #'cddr
|
||||
when (memq property ebox--region-update-control-properties)
|
||||
when (memq property '(:content :scroll-offset))
|
||||
append (list property value))
|
||||
(when interaction-delta
|
||||
(list :surface-properties
|
||||
(ebox-interaction-surface-properties interactions)))
|
||||
(ebox-style--expand-engine-delta
|
||||
style-declarations declarations
|
||||
(plist-get candidate-box :ebox-computed-style))))
|
||||
@ -4233,24 +4337,26 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch."
|
||||
(eq (ebox--region-update-normalize-property (car expanded))
|
||||
:scroll-offset))
|
||||
(ebox--surface-scroll-to-offset buffer region-id (cadr expanded))
|
||||
(let* ((content-only-p
|
||||
(let* (;; Source-only text changes leave inline declarations and
|
||||
;; selector subjects unchanged. Copy the owner path first;
|
||||
;; the existing slot/style proof decides whether it is safe
|
||||
;; to retain it. A widened projection takes a private copy
|
||||
;; in `ebox-incremental-prepare-scoped-commit' before layout.
|
||||
(content-only-p
|
||||
(and (= (length expanded) 2)
|
||||
(eq (ebox--region-update-normalize-property
|
||||
(car expanded))
|
||||
:content)
|
||||
(stringp (cadr expanded))
|
||||
(numberp (ebox-get candidate-box :width))
|
||||
(ebox--region-update-direct-text-node candidate-box)
|
||||
(not (ebox-style-cascade-active-p))
|
||||
(not (plist-get state :cascade-required-p))
|
||||
(null (plist-get state :scroll-region-ids))))
|
||||
(not (ebox-style-cascade-active-p))))
|
||||
(candidate-root
|
||||
(ebox--surface-region-candidate-root
|
||||
buffer state region-id content-only-p))
|
||||
(applied
|
||||
(ebox--surface-region-apply-props
|
||||
candidate-root source-index
|
||||
region-id expanded declarations style-declarations))
|
||||
region-id expanded declarations style-declarations interactions))
|
||||
(changed-keys (plist-get applied :changed-keys))
|
||||
(candidate-source-index (plist-get applied :source-index))
|
||||
(dirty-kind
|
||||
@ -4277,19 +4383,24 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch."
|
||||
|
||||
;;;###autoload
|
||||
(defun ebox-region-update (handle &rest props)
|
||||
"Update the box identified by surface-scoped HANDLE with PROPS.
|
||||
HANDLE must come from ebox-region-resolve or an Ebox selector result. PROPS
|
||||
accepts mutable content, style, and scroll keywords supported by the target;
|
||||
it does not add children or mutate selector metadata. The update builds an
|
||||
"Update the node identified by HANDLE with PROPS.
|
||||
HANDLE is an opaque handle from `ebox-region-resolve' or an Ebox selector,
|
||||
or a semantic ID (string, non-nil symbol or integer) in the current buffer.
|
||||
PROPS accepts mutable content, style, interaction and scroll keywords;
|
||||
`:content' replaces a Text node's string or a Box's sole direct Text child.
|
||||
It does not add children or mutate selector metadata. The update builds an
|
||||
isolated candidate, runs the Ebox owner planner, and publishes once through the
|
||||
mounted TP surface. Return nil while queued in an explicit batch; otherwise
|
||||
return the committed update report.
|
||||
|
||||
Examples:
|
||||
(ebox-region-update \"status\" :content \"Ready\")
|
||||
(ebox-region-update (ebox-region-resolve buffer \"status\")
|
||||
:content \"Ready\")
|
||||
(ebox-region-update handle :padding (list 1 2)
|
||||
(ebox-region-update handle :padding \='((lh 1) (ch 2))
|
||||
:border-color \"red\")"
|
||||
(when (ebox-source-semantic-id-p handle)
|
||||
(setq handle (ebox-region-resolve (current-buffer) handle)))
|
||||
(unless (ebox-region-handle-p handle)
|
||||
(signal (quote wrong-type-argument)
|
||||
(list (quote ebox-region-handle-p) handle)))
|
||||
@ -4298,8 +4409,11 @@ Examples:
|
||||
(let ((execute
|
||||
(lambda ()
|
||||
(ebox--with-render-gc
|
||||
(ebox--surface-region-update-target
|
||||
buffer region-id handle props)))))
|
||||
(with-current-buffer buffer
|
||||
(save-restriction
|
||||
(widen)
|
||||
(ebox--surface-region-update-target
|
||||
buffer region-id handle props)))))))
|
||||
(if (ebox-incremental-batching-p buffer)
|
||||
(funcall execute)
|
||||
(ebox-surface-call-with-observation buffer 'region execute)))))
|
||||
@ -4622,8 +4736,10 @@ replacement."
|
||||
|
||||
;;;###autoload
|
||||
(defun ebox-candidate-replace-range-ref
|
||||
(candidate range-ref input &optional reuse-map)
|
||||
"Replace RANGE-REF from canonical INPUT, retaining proven REUSE-MAP slots."
|
||||
(candidate range-ref input &optional reuse-map retain-item-identities-p)
|
||||
"Replace RANGE-REF from canonical INPUT, retaining proven REUSE-MAP slots.
|
||||
When RETAIN-ITEM-IDENTITIES-P is non-nil, Ebox retains candidate-owned items
|
||||
only after its strict ownership proof; otherwise it uses the normal copy path."
|
||||
(unless (ebox-canonical-input-p input)
|
||||
(signal 'wrong-type-argument (list 'ebox-canonical-input-p input)))
|
||||
(let ((ebox-incremental--source-base-index
|
||||
@ -4631,7 +4747,7 @@ replacement."
|
||||
(ebox-incremental-candidate-replace-range-ref
|
||||
candidate range-ref
|
||||
(copy-sequence (ebox-canonical-input--nodes input))
|
||||
reuse-map)))
|
||||
reuse-map retain-item-identities-p)))
|
||||
|
||||
;;;###autoload
|
||||
(defun ebox-candidate-replace-root (candidate input)
|
||||
@ -4703,10 +4819,18 @@ Return the successful publication report stored by `ebox-buffer-update-report'."
|
||||
(commit-input
|
||||
(let ((ebox-incremental--source-base-index
|
||||
source-base-index))
|
||||
(if (ebox-candidate-p next-root)
|
||||
(ebox-incremental-consume-candidate buffer next-root)
|
||||
(ebox-incremental-prepare-root-commit
|
||||
buffer next-root))))
|
||||
;; Slot proofs use absolute mounted coordinates.
|
||||
;; Capture them in the same complete buffer view the
|
||||
;; surface publisher uses, even when the caller has
|
||||
;; narrowed its editor view.
|
||||
(with-current-buffer buffer
|
||||
(save-restriction
|
||||
(widen)
|
||||
(if (ebox-candidate-p next-root)
|
||||
(ebox-incremental-consume-candidate
|
||||
buffer next-root)
|
||||
(ebox-incremental-prepare-root-commit
|
||||
buffer next-root))))))
|
||||
(source (plist-get commit-input :root))
|
||||
(callback
|
||||
(or framework-publish
|
||||
@ -4756,8 +4880,8 @@ Return the successful publication report stored by `ebox-buffer-update-report'."
|
||||
(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.
|
||||
VIEWPORT-WIDTH is pixels; optional VIEWPORT-HEIGHT is frame lines.
|
||||
Viewport units and composed size expressions resolve against this context.
|
||||
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 ((execute
|
||||
@ -4793,11 +4917,17 @@ through dirty-set and patch-set execution before falling back to root rerender."
|
||||
(plist-get state :viewport-height))))
|
||||
|
||||
;;;###autoload
|
||||
(defun ebox-display-buffer (buffer-or-name input)
|
||||
"Render canonical INPUT to BUFFER-OR-NAME through TP and display it."
|
||||
(defun ebox-display-buffer (buffer-or-name input &optional action)
|
||||
"Render canonical INPUT to BUFFER-OR-NAME, display it, and return its buffer.
|
||||
Render before requesting a window, so invalid input does not disturb the
|
||||
window layout. ACTION is passed to `display-buffer'; ordinary display rules
|
||||
apply and this function does not select the displayed window."
|
||||
(declare (indent 1))
|
||||
(delete-other-windows)
|
||||
(switch-to-buffer (ebox-render-to-buffer buffer-or-name input)))
|
||||
(let ((buffer (ebox-render-to-buffer buffer-or-name input)))
|
||||
(let ((window (display-buffer buffer action)))
|
||||
(when (window-live-p window)
|
||||
(ebox--window-size-change (window-frame window))))
|
||||
buffer))
|
||||
|
||||
(defconst ebox-public-api
|
||||
'(ebox-buffer-mode
|
||||
@ -4826,6 +4956,10 @@ through dirty-set and patch-set execution before falling back to root rerender."
|
||||
ebox-grid-layout-create
|
||||
ebox-host-ref-bounds
|
||||
ebox-host-ref-position
|
||||
ebox-help-create
|
||||
ebox-keymap-create
|
||||
ebox-next-interaction
|
||||
ebox-previous-interaction
|
||||
ebox-native-build
|
||||
ebox-native-status
|
||||
ebox-region-ids
|
||||
|
||||
@ -3,6 +3,7 @@ name = "ebox-native-reflow"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "GPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
|
||||
@ -38,6 +38,8 @@ extern void *ebox_native_session_create(uint32_t workers,
|
||||
uint32_t max_results,
|
||||
uint64_t max_result_bytes,
|
||||
ebox_native_bytes *error);
|
||||
extern void *ebox_native_session_fork_confirmed(void *source,
|
||||
ebox_native_bytes *error);
|
||||
extern int64_t ebox_native_session_submit(void *session,
|
||||
uint64_t generation,
|
||||
const unsigned char *payload,
|
||||
@ -162,7 +164,7 @@ ebox_module_version(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
|
||||
(void) nargs;
|
||||
(void) args;
|
||||
(void) data;
|
||||
static const char version[] = "9:7:12";
|
||||
static const char version[] = "14:7:12";
|
||||
return env->make_string(env, version, (ptrdiff_t) (sizeof version - 1));
|
||||
}
|
||||
|
||||
@ -241,6 +243,24 @@ ebox_module_create_session(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
|
||||
return env->make_user_ptr(env, ebox_session_finalizer, session);
|
||||
}
|
||||
|
||||
static emacs_value
|
||||
ebox_module_fork_confirmed(emacs_env *env, ptrdiff_t nargs,
|
||||
emacs_value *args, void *data)
|
||||
{
|
||||
(void) nargs;
|
||||
(void) data;
|
||||
void *source = ebox_session_pointer(env, args[0]);
|
||||
if (source == NULL ||
|
||||
env->non_local_exit_check(env) != emacs_funcall_exit_return)
|
||||
return ebox_nil(env);
|
||||
ebox_native_bytes error = {0};
|
||||
void *session = ebox_native_session_fork_confirmed(source, &error);
|
||||
if (session == NULL)
|
||||
return ebox_signal_native_error(env, error,
|
||||
"Native confirmed session fork 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)
|
||||
@ -922,6 +942,9 @@ ebox_module_init_impl(struct emacs_runtime *runtime)
|
||||
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-fork-confirmed", 1, 1,
|
||||
ebox_module_fork_confirmed,
|
||||
"Fork one confirmed private native session.") ||
|
||||
ebox_bind_function(env, "ebox-native--module-submit", 3, 3,
|
||||
ebox_module_submit,
|
||||
"Submit one private native control batch.") ||
|
||||
|
||||
@ -3,6 +3,7 @@ name = "etaf-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "GPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
|
||||
786
native/src/atom_plan.rs
Normal file
786
native/src/atom_plan.rs
Normal file
@ -0,0 +1,786 @@
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::sequence::{Entry, Measure, Node, NodeView, Sequence, Work};
|
||||
|
||||
use super::{Atom, AtomProperties, RegionRole, RegionRoleEntry};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) struct AtomPlanWork {
|
||||
pub(crate) sequence_nodes_created: u64,
|
||||
pub(crate) sequence_entries_written: u64,
|
||||
pub(crate) materialized_atoms: u64,
|
||||
pub(crate) property_applications: u64,
|
||||
pub(crate) atom_clones: u64,
|
||||
pub(crate) operation_block_writes: u64,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static WORK: Cell<AtomPlanWork> = Cell::new(AtomPlanWork::default());
|
||||
}
|
||||
|
||||
pub(crate) fn reset_work() {
|
||||
WORK.set(AtomPlanWork::default());
|
||||
}
|
||||
|
||||
pub(crate) fn work() -> AtomPlanWork {
|
||||
WORK.get()
|
||||
}
|
||||
|
||||
fn record_sequence(work: Work) {
|
||||
WORK.set(WORK.get().checked_add(AtomPlanWork {
|
||||
sequence_nodes_created: work.nodes_created,
|
||||
sequence_entries_written: work.entries_written,
|
||||
materialized_atoms: 0,
|
||||
property_applications: 0,
|
||||
atom_clones: 0,
|
||||
operation_block_writes: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
fn record_materialized(count: usize) {
|
||||
let count = u64::try_from(count).expect("validated native atom count fits u64");
|
||||
WORK.set(WORK.get().checked_add(AtomPlanWork {
|
||||
materialized_atoms: count,
|
||||
atom_clones: count,
|
||||
..AtomPlanWork::default()
|
||||
}));
|
||||
}
|
||||
|
||||
fn record_atom_clones(count: usize) {
|
||||
let count = u64::try_from(count).expect("validated native atom count fits u64");
|
||||
WORK.set(WORK.get().checked_add(AtomPlanWork {
|
||||
atom_clones: count,
|
||||
..AtomPlanWork::default()
|
||||
}));
|
||||
}
|
||||
|
||||
fn record_operation_block_writes(count: usize) {
|
||||
let count = u64::try_from(count).expect("native atom decoration block fits u64");
|
||||
WORK.set(WORK.get().checked_add(AtomPlanWork {
|
||||
operation_block_writes: count,
|
||||
..AtomPlanWork::default()
|
||||
}));
|
||||
}
|
||||
|
||||
fn record_property_application() {
|
||||
WORK.set(WORK.get().checked_add(AtomPlanWork {
|
||||
property_applications: 1,
|
||||
..AtomPlanWork::default()
|
||||
}));
|
||||
}
|
||||
|
||||
impl AtomPlanWork {
|
||||
pub(crate) fn accumulate(&mut self, other: Self) {
|
||||
let combined = Self {
|
||||
sequence_nodes_created: self
|
||||
.sequence_nodes_created
|
||||
.checked_add(other.sequence_nodes_created)
|
||||
.expect("native atom plan node work counter overflow"),
|
||||
sequence_entries_written: self
|
||||
.sequence_entries_written
|
||||
.checked_add(other.sequence_entries_written)
|
||||
.expect("native atom plan entry work counter overflow"),
|
||||
materialized_atoms: self
|
||||
.materialized_atoms
|
||||
.checked_add(other.materialized_atoms)
|
||||
.expect("native atom plan materialization counter overflow"),
|
||||
property_applications: self
|
||||
.property_applications
|
||||
.checked_add(other.property_applications)
|
||||
.expect("native atom plan property work counter overflow"),
|
||||
atom_clones: self
|
||||
.atom_clones
|
||||
.checked_add(other.atom_clones)
|
||||
.expect("native atom plan clone work counter overflow"),
|
||||
operation_block_writes: self
|
||||
.operation_block_writes
|
||||
.checked_add(other.operation_block_writes)
|
||||
.expect("native atom plan operation work counter overflow"),
|
||||
};
|
||||
*self = combined;
|
||||
}
|
||||
|
||||
fn checked_add(mut self, other: Self) -> Self {
|
||||
self.accumulate(other);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct RunSummary {
|
||||
prefix: i64,
|
||||
suffix: i64,
|
||||
maximum: i64,
|
||||
all_run: bool,
|
||||
}
|
||||
|
||||
impl RunSummary {
|
||||
fn atom(atom: &Atom) -> Self {
|
||||
if atom.wrap_space() {
|
||||
Self::default()
|
||||
} else if atom.wrap_cjk() {
|
||||
Self {
|
||||
maximum: atom.width(),
|
||||
..Self::default()
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
prefix: atom.width(),
|
||||
suffix: atom.width(),
|
||||
maximum: atom.width(),
|
||||
all_run: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn combine(self, other: Self) -> Self {
|
||||
let cross = self
|
||||
.suffix
|
||||
.checked_add(other.prefix)
|
||||
.expect("validated native atom widths fit i64");
|
||||
Self {
|
||||
prefix: if self.all_run {
|
||||
self.prefix
|
||||
.checked_add(other.prefix)
|
||||
.expect("validated native atom widths fit i64")
|
||||
} else {
|
||||
self.prefix
|
||||
},
|
||||
suffix: if other.all_run {
|
||||
self.suffix
|
||||
.checked_add(other.suffix)
|
||||
.expect("validated native atom widths fit i64")
|
||||
} else {
|
||||
other.suffix
|
||||
},
|
||||
maximum: self.maximum.max(other.maximum).max(cross),
|
||||
all_run: self.all_run && other.all_run,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Summary {
|
||||
atom_count: usize,
|
||||
any_owner: bool,
|
||||
any_content: bool,
|
||||
noncontent: bool,
|
||||
whitespace: bool,
|
||||
first_properties: Option<AtomProperties>,
|
||||
runs: RunSummary,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
fn atom(atom: &Atom) -> Self {
|
||||
let properties = atom.properties();
|
||||
Self {
|
||||
atom_count: 1,
|
||||
any_owner: properties.owner.is_some() || !properties.owners.is_empty(),
|
||||
any_content: properties.content.is_some(),
|
||||
noncontent: !properties.style_ids.is_empty()
|
||||
|| !properties.roles.is_empty()
|
||||
|| !properties.property_template_ids.is_empty(),
|
||||
whitespace: match atom {
|
||||
Atom::Text { text, .. } => text.trim().is_empty(),
|
||||
Atom::Space { .. } => true,
|
||||
},
|
||||
first_properties: Some(properties.clone()),
|
||||
runs: RunSummary::atom(atom),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine(self, other: Self) -> Self {
|
||||
Self {
|
||||
atom_count: self
|
||||
.atom_count
|
||||
.checked_add(other.atom_count)
|
||||
.expect("validated native atom count fits usize"),
|
||||
any_owner: self.any_owner || other.any_owner,
|
||||
any_content: self.any_content || other.any_content,
|
||||
noncontent: self.noncontent || other.noncontent,
|
||||
whitespace: self.whitespace && other.whitespace,
|
||||
first_properties: self.first_properties.or(other.first_properties),
|
||||
runs: self.runs.combine(other.runs),
|
||||
}
|
||||
}
|
||||
|
||||
fn atoms(atoms: &[Atom]) -> Self {
|
||||
let first = atoms.first().expect("nonempty native atom block");
|
||||
let mut summary = Self {
|
||||
atom_count: 0,
|
||||
any_owner: false,
|
||||
any_content: false,
|
||||
noncontent: false,
|
||||
whitespace: true,
|
||||
first_properties: Some(first.properties().clone()),
|
||||
runs: RunSummary {
|
||||
all_run: true,
|
||||
..RunSummary::default()
|
||||
},
|
||||
};
|
||||
for atom in atoms {
|
||||
let properties = atom.properties();
|
||||
summary.atom_count = summary
|
||||
.atom_count
|
||||
.checked_add(1)
|
||||
.expect("validated native atom count fits usize");
|
||||
summary.any_owner |= properties.owner.is_some() || !properties.owners.is_empty();
|
||||
summary.any_content |= properties.content.is_some();
|
||||
summary.noncontent |= !properties.style_ids.is_empty()
|
||||
|| !properties.roles.is_empty()
|
||||
|| !properties.property_template_ids.is_empty();
|
||||
summary.whitespace &= match atom {
|
||||
Atom::Text { text, .. } => text.trim().is_empty(),
|
||||
Atom::Space { .. } => true,
|
||||
};
|
||||
summary.runs = summary.runs.combine(RunSummary::atom(atom));
|
||||
}
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Decoration {
|
||||
OwnContent {
|
||||
region: i64,
|
||||
index: i64,
|
||||
had_owner: bool,
|
||||
had_content: bool,
|
||||
},
|
||||
Style(u32),
|
||||
Template(u32),
|
||||
Role(RegionRole, i64),
|
||||
ScrollWindow(i64),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Part {
|
||||
Atoms(Box<[Atom]>),
|
||||
Decorated {
|
||||
input: Arc<AtomPlan>,
|
||||
decorations: Box<[Decoration]>,
|
||||
},
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct AtomPlan {
|
||||
parts: Sequence<Part>,
|
||||
summary: Summary,
|
||||
}
|
||||
|
||||
impl Default for AtomPlan {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
parts: Sequence::empty(),
|
||||
summary: Summary {
|
||||
whitespace: true,
|
||||
..Summary::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AtomPlan {
|
||||
pub(super) fn from_atoms(atoms: &[Atom]) -> Self {
|
||||
record_atom_clones(atoms.len());
|
||||
Self::from_owned_atoms(atoms.to_vec())
|
||||
}
|
||||
|
||||
pub(super) fn from_owned_atoms(atoms: Vec<Atom>) -> Self {
|
||||
if atoms.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
let summary = Summary::atoms(&atoms);
|
||||
let mut sequence_work = Work::default();
|
||||
let mut entries = Vec::with_capacity(atoms.len().div_ceil(16));
|
||||
let mut atoms = atoms.into_iter();
|
||||
loop {
|
||||
let chunk = atoms.by_ref().take(16).collect::<Vec<_>>();
|
||||
if chunk.is_empty() {
|
||||
break;
|
||||
}
|
||||
let measure = atoms_measure(&chunk);
|
||||
entries.push(
|
||||
Entry::new(Arc::new(Part::Atoms(chunk.into_boxed_slice())), measure)
|
||||
.expect("validated native atom measure is nonnegative"),
|
||||
);
|
||||
}
|
||||
let parts = Sequence::from_entries(entries, &mut sequence_work)
|
||||
.expect("validated native atom sequence aggregates fit limits");
|
||||
record_sequence(sequence_work);
|
||||
Self { parts, summary }
|
||||
}
|
||||
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.parts.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn char_count(&self) -> u64 {
|
||||
self.parts.measure().chars
|
||||
}
|
||||
|
||||
pub(super) fn append(&self, other: &Self) -> Self {
|
||||
if self.is_empty() {
|
||||
return other.clone();
|
||||
}
|
||||
if other.is_empty() {
|
||||
return self.clone();
|
||||
}
|
||||
let mut sequence_work = Work::default();
|
||||
let parts = self
|
||||
.parts
|
||||
.concat(&other.parts, &mut sequence_work)
|
||||
.expect("validated native atom sequence aggregates fit limits");
|
||||
record_sequence(sequence_work);
|
||||
Self {
|
||||
parts,
|
||||
summary: self.summary.clone().combine(other.summary.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push(&self, atom: Atom) -> Self {
|
||||
self.append(&Self::from_single(atom))
|
||||
}
|
||||
|
||||
pub(super) fn prepend(&self, atom: Atom) -> Self {
|
||||
Self::from_single(atom).append(self)
|
||||
}
|
||||
|
||||
pub(super) fn to_vec(&self) -> Vec<Atom> {
|
||||
let mut output = Vec::with_capacity(self.summary.atom_count);
|
||||
if let Some(root) = self.parts.root() {
|
||||
materialize_node(root, &mut output);
|
||||
}
|
||||
record_materialized(output.len());
|
||||
output
|
||||
}
|
||||
|
||||
pub(super) fn first(&self) -> Option<Atom> {
|
||||
first_node(self.parts.root()?)
|
||||
}
|
||||
|
||||
pub(super) fn whitespace_only(&self) -> bool {
|
||||
self.summary.whitespace
|
||||
}
|
||||
|
||||
pub(super) fn has_noncontent_properties(&self) -> bool {
|
||||
self.summary.noncontent
|
||||
}
|
||||
|
||||
pub(super) fn own_content(&self, region: i64, index: i64) -> Self {
|
||||
if self.is_empty() {
|
||||
return self.clone();
|
||||
}
|
||||
self.decorate(Decoration::OwnContent {
|
||||
region,
|
||||
index,
|
||||
had_owner: self.summary.any_owner,
|
||||
had_content: self.summary.any_content,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn apply_style(&self, style: u32) -> Self {
|
||||
self.decorate(Decoration::Style(style))
|
||||
}
|
||||
|
||||
pub(super) fn apply_template(&self, template: u32) -> Self {
|
||||
self.decorate(Decoration::Template(template))
|
||||
}
|
||||
|
||||
pub(super) fn apply_role(&self, role: RegionRole, region: i64) -> Self {
|
||||
self.decorate(Decoration::Role(role, region))
|
||||
}
|
||||
|
||||
pub(super) fn apply_scroll_window(&self, region: i64) -> Self {
|
||||
self.decorate(Decoration::ScrollWindow(region))
|
||||
}
|
||||
|
||||
pub(super) fn min_content_width(&self) -> i64 {
|
||||
self.summary.runs.maximum
|
||||
}
|
||||
|
||||
pub(super) fn width(&self) -> i64 {
|
||||
self.parts.measure().width_sum
|
||||
}
|
||||
|
||||
fn from_single(atom: Atom) -> Self {
|
||||
let summary = Summary::atom(&atom);
|
||||
let measure = atom_measure(&atom);
|
||||
let mut sequence_work = Work::default();
|
||||
let parts = Sequence::from_entries(
|
||||
[Entry::new(
|
||||
Arc::new(Part::Atoms(vec![atom].into_boxed_slice())),
|
||||
measure,
|
||||
)
|
||||
.expect("validated native atom measure is nonnegative")],
|
||||
&mut sequence_work,
|
||||
)
|
||||
.expect("single native atom sequence fits limits");
|
||||
record_sequence(sequence_work);
|
||||
Self { parts, summary }
|
||||
}
|
||||
|
||||
fn decorate(&self, decoration: Decoration) -> Self {
|
||||
if self.is_empty() {
|
||||
return self.clone();
|
||||
}
|
||||
let mut summary = self.summary.clone();
|
||||
if let Some(properties) = &mut summary.first_properties {
|
||||
apply_decoration(properties, &decoration);
|
||||
}
|
||||
match &decoration {
|
||||
Decoration::OwnContent { .. } => {
|
||||
summary.any_owner = true;
|
||||
summary.any_content = true;
|
||||
}
|
||||
Decoration::Style(_) | Decoration::Template(_) | Decoration::Role(_, _) => {
|
||||
summary.noncontent = true;
|
||||
}
|
||||
Decoration::ScrollWindow(_) => {}
|
||||
}
|
||||
let (input, decorations) = self
|
||||
.parts
|
||||
.get(0)
|
||||
.filter(|_| self.parts.len() == 1)
|
||||
.and_then(|entry| match entry.value().as_ref() {
|
||||
Part::Decorated { input, decorations } if decorations.len() < 16 => {
|
||||
let mut combined = decorations.to_vec();
|
||||
combined.push(decoration.clone());
|
||||
Some((input.clone(), combined.into_boxed_slice()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| (Arc::new(self.clone()), vec![decoration].into_boxed_slice()));
|
||||
record_operation_block_writes(decorations.len());
|
||||
let measure = self.parts.measure();
|
||||
let mut sequence_work = Work::default();
|
||||
let parts = Sequence::from_entries(
|
||||
[
|
||||
Entry::new(Arc::new(Part::Decorated { input, decorations }), measure)
|
||||
.expect("validated native atom plan measure is nonnegative"),
|
||||
],
|
||||
&mut sequence_work,
|
||||
)
|
||||
.expect("decorated native atom sequence fits limits");
|
||||
record_sequence(sequence_work);
|
||||
Self { parts, summary }
|
||||
}
|
||||
}
|
||||
|
||||
fn atom_measure(atom: &Atom) -> Measure {
|
||||
let chars = match atom {
|
||||
Atom::Text { text, .. } => text.chars().count() as u64,
|
||||
Atom::Space { .. } => 1,
|
||||
};
|
||||
Measure::new(chars, 0, atom.width(), atom.width())
|
||||
.expect("validated native atom width is nonnegative")
|
||||
}
|
||||
|
||||
fn atoms_measure(atoms: &[Atom]) -> Measure {
|
||||
atoms.iter().fold(Measure::default(), |measure, atom| {
|
||||
measure
|
||||
.checked_combine(atom_measure(atom))
|
||||
.expect("validated native atom measures fit limits")
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_decoration(properties: &mut AtomProperties, decoration: &Decoration) {
|
||||
record_property_application();
|
||||
match decoration {
|
||||
Decoration::OwnContent {
|
||||
region,
|
||||
index,
|
||||
had_owner,
|
||||
had_content,
|
||||
} => {
|
||||
if *had_owner {
|
||||
let mut owners = properties.owners.clone();
|
||||
if let Some(owner) = properties.owner {
|
||||
if !owners.contains(&owner) {
|
||||
owners.push(owner);
|
||||
}
|
||||
}
|
||||
if !owners.contains(region) {
|
||||
owners.push(*region);
|
||||
}
|
||||
properties.owner = Some(*region);
|
||||
properties.owners = owners;
|
||||
} else {
|
||||
properties.owner = Some(*region);
|
||||
properties.owners = vec![*region];
|
||||
}
|
||||
if !had_content {
|
||||
properties.content = Some(*region);
|
||||
properties.content_idx = Some(*index);
|
||||
}
|
||||
}
|
||||
Decoration::Style(style) => properties.style_ids.push(*style),
|
||||
Decoration::Template(template) => properties.property_template_ids.push(*template),
|
||||
Decoration::Role(role, region) => properties.roles.push(RegionRoleEntry {
|
||||
role: *role,
|
||||
region_id: *region,
|
||||
}),
|
||||
Decoration::ScrollWindow(region) => properties.scroll_window = Some(*region),
|
||||
}
|
||||
}
|
||||
|
||||
fn decorate_atom(mut atom: Atom, decoration: &Decoration) -> Atom {
|
||||
apply_decoration(atom.properties_mut(), decoration);
|
||||
atom
|
||||
}
|
||||
|
||||
fn materialize_node(node: &Arc<Node<Part>>, output: &mut Vec<Atom>) {
|
||||
match node.view() {
|
||||
NodeView::Leaf { entries, .. } => {
|
||||
for entry in entries {
|
||||
match entry.value().as_ref() {
|
||||
Part::Atoms(atoms) => output.extend(atoms.iter().cloned()),
|
||||
Part::Decorated { input, decorations } => {
|
||||
let start = output.len();
|
||||
if let Some(root) = input.parts.root() {
|
||||
materialize_node(root, output);
|
||||
}
|
||||
for atom in &mut output[start..] {
|
||||
for decoration in decorations {
|
||||
apply_decoration(atom.properties_mut(), decoration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeView::Branch { left, right, .. } => {
|
||||
materialize_node(left, output);
|
||||
materialize_node(right, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn first_node(node: &Arc<Node<Part>>) -> Option<Atom> {
|
||||
match node.view() {
|
||||
NodeView::Leaf { entries, .. } => match entries.first()?.value().as_ref() {
|
||||
Part::Atoms(atoms) => {
|
||||
record_materialized(1);
|
||||
Some(atoms.first()?.clone())
|
||||
}
|
||||
Part::Decorated { input, decorations } => input
|
||||
.first()
|
||||
.map(|atom| decorations.iter().fold(atom, decorate_atom)),
|
||||
},
|
||||
NodeView::Branch { left, .. } => first_node(left),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text(value: &str, width: i64, cjk: bool, space: bool) -> Atom {
|
||||
Atom::Text {
|
||||
text: value.to_owned(),
|
||||
width,
|
||||
cjk,
|
||||
space,
|
||||
properties: AtomProperties::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoration_is_lazy_ordered_and_new_padding_is_not_retroactive() {
|
||||
reset_work();
|
||||
let content = AtomPlan::from_atoms(&[text("x", 1, false, false)])
|
||||
.apply_style(3)
|
||||
.own_content(7, 2);
|
||||
let plan = content.prepend(Atom::Space {
|
||||
width: 4,
|
||||
properties: AtomProperties::default(),
|
||||
});
|
||||
assert_eq!(work().materialized_atoms, 0);
|
||||
let atoms = plan.to_vec();
|
||||
assert!(atoms[0].properties().style_ids.is_empty());
|
||||
assert_eq!(atoms[1].properties().style_ids, vec![3]);
|
||||
assert_eq!(atoms[1].properties().content, Some(7));
|
||||
assert_eq!(atoms[1].properties().content_idx, Some(2));
|
||||
assert_eq!(work().materialized_atoms, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_content_uses_whole_line_owner_and_content_branches() {
|
||||
let mut owned = text("a", 1, false, false);
|
||||
owned.properties_mut().owner = Some(5);
|
||||
owned.properties_mut().owners = vec![5];
|
||||
owned.properties_mut().content = Some(4);
|
||||
let plan = AtomPlan::from_atoms(&[owned, text("b", 1, false, false)]).own_content(9, 3);
|
||||
let atoms = plan.to_vec();
|
||||
assert_eq!(atoms[0].properties().content, Some(4));
|
||||
assert_eq!(atoms[1].properties().content, None);
|
||||
assert_eq!(atoms[0].properties().owner, Some(9));
|
||||
assert_eq!(atoms[0].properties().owners, vec![5, 9]);
|
||||
assert_eq!(atoms[1].properties().owner, Some(9));
|
||||
assert_eq!(atoms[1].properties().owners, vec![9]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_content_summary_matches_space_and_cjk_breaks() {
|
||||
let plan = AtomPlan::from_atoms(&[
|
||||
text("ab", 2, false, false),
|
||||
text(" ", 1, false, true),
|
||||
text("界", 4, true, false),
|
||||
text("cd", 3, false, false),
|
||||
]);
|
||||
assert_eq!(plan.min_content_width(), 4);
|
||||
assert!(!plan.whitespace_only());
|
||||
assert!(!plan.has_noncontent_properties());
|
||||
assert!(!plan.is_empty());
|
||||
assert!(AtomPlan::default().first().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_shares_parts_and_materializes_once() {
|
||||
reset_work();
|
||||
let left = AtomPlan::from_atoms(&[text("a", 1, false, false)]);
|
||||
let right = AtomPlan::from_owned_atoms(vec![text("b", 2, false, false)]);
|
||||
let plan = left.append(&right).apply_template(2).apply_scroll_window(8);
|
||||
assert_eq!(plan.width(), 3);
|
||||
assert_eq!(work().materialized_atoms, 0);
|
||||
let atoms = plan.to_vec();
|
||||
assert_eq!(atoms.len(), 2);
|
||||
assert_eq!(atoms[0].properties().property_template_ids, vec![2]);
|
||||
assert_eq!(atoms[1].properties().scroll_window, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_append_and_decorations_do_no_atom_work_until_materialized() {
|
||||
for size in [32, 128, 512, 8192] {
|
||||
let atoms = (0..size)
|
||||
.map(|_| text("x", 1, false, false))
|
||||
.collect::<Vec<_>>();
|
||||
let plan = AtomPlan::from_atoms(&atoms);
|
||||
reset_work();
|
||||
let combined = plan
|
||||
.append(&plan)
|
||||
.own_content(7, 0)
|
||||
.apply_style(1)
|
||||
.apply_template(2)
|
||||
.apply_role(RegionRole::PaddingLeft, 7)
|
||||
.apply_scroll_window(7);
|
||||
let lazy = work();
|
||||
assert_eq!(lazy.materialized_atoms, 0);
|
||||
assert_eq!(lazy.sequence_nodes_created, 6);
|
||||
assert!(lazy.sequence_entries_written <= 21, "{lazy:?}");
|
||||
assert_eq!(lazy.property_applications, 5);
|
||||
assert_eq!(lazy.atom_clones, 0);
|
||||
assert_eq!(lazy.operation_block_writes, 15);
|
||||
|
||||
let materialized = combined.to_vec();
|
||||
assert_eq!(materialized.len(), size * 2);
|
||||
let eager = work();
|
||||
assert_eq!(eager.materialized_atoms, (size * 2) as u64);
|
||||
assert_eq!(eager.property_applications, 5 + (size * 2 * 5) as u64);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_content_randomized_summary_matches_vec_oracle() {
|
||||
fn oracle(atoms: &[Atom]) -> i64 {
|
||||
let mut maximum = 0;
|
||||
let mut run = 0_i64;
|
||||
for atom in 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)
|
||||
}
|
||||
|
||||
let mut seed = 0x5e91_7ac3_u64;
|
||||
for size in 0..257 {
|
||||
let mut atoms = Vec::with_capacity(size);
|
||||
for _ in 0..size {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let width = ((seed >> 32) % 8 + 1) as i64;
|
||||
atoms.push(match seed % 5 {
|
||||
0 => text(" ", width, false, true),
|
||||
1 => text("界", width, true, false),
|
||||
_ => text("x", width, false, false),
|
||||
});
|
||||
}
|
||||
let split = size / 3;
|
||||
let plan = AtomPlan::default()
|
||||
.append(&AtomPlan::from_atoms(&atoms[..split]))
|
||||
.append(&AtomPlan::default())
|
||||
.append(&AtomPlan::from_atoms(&atoms[split..]));
|
||||
assert_eq!(plan.min_content_width(), oracle(&atoms));
|
||||
assert_eq!(plan.is_empty(), atoms.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_counts_one_atom_and_each_lazy_property_application() {
|
||||
let plan = AtomPlan::from_atoms(&[text("a", 1, false, false), text("b", 1, false, false)])
|
||||
.apply_style(3)
|
||||
.apply_template(4);
|
||||
reset_work();
|
||||
let first = plan.first().unwrap();
|
||||
assert_eq!(first.properties().style_ids, vec![3]);
|
||||
assert_eq!(first.properties().property_template_ids, vec![4]);
|
||||
assert_eq!(work().materialized_atoms, 1);
|
||||
assert_eq!(work().property_applications, 2);
|
||||
assert_eq!(work().atom_clones, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoration_blocks_are_bounded_ordered_and_keep_old_fork() {
|
||||
let base = AtomPlan::from_owned_atoms(vec![text("x", 1, false, false)]);
|
||||
let fork = base.clone();
|
||||
let old_root = Arc::clone(base.parts.root().unwrap());
|
||||
reset_work();
|
||||
let decorated = (0..33).fold(base, |plan, style| plan.apply_style(style));
|
||||
assert!(Arc::ptr_eq(fork.parts.root().unwrap(), &old_root));
|
||||
assert_eq!(work().materialized_atoms, 0);
|
||||
assert_eq!(work().operation_block_writes, 273);
|
||||
|
||||
let mut block_lengths = Vec::new();
|
||||
let mut current = &decorated;
|
||||
loop {
|
||||
let part = current.parts.get(0).unwrap().value().as_ref();
|
||||
match part {
|
||||
Part::Decorated { input, decorations } => {
|
||||
block_lengths.push(decorations.len());
|
||||
current = input;
|
||||
}
|
||||
Part::Atoms(_) => break,
|
||||
}
|
||||
}
|
||||
assert_eq!(block_lengths, vec![1, 16, 16]);
|
||||
assert_eq!(
|
||||
decorated.to_vec()[0].properties().style_ids,
|
||||
(0..33).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(fork.to_vec()[0].properties().style_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_owned_bootstrap_writes_one_sequence_entry_per_atom_block() {
|
||||
let atoms = (0..8192)
|
||||
.map(|_| text("x", 1, false, false))
|
||||
.collect::<Vec<_>>();
|
||||
reset_work();
|
||||
let plan = AtomPlan::from_owned_atoms(atoms);
|
||||
let bootstrap = work();
|
||||
assert_eq!(bootstrap.atom_clones, 0);
|
||||
assert_eq!(bootstrap.sequence_entries_written, 512);
|
||||
assert_eq!(bootstrap.sequence_nodes_created, 63);
|
||||
assert_eq!(plan.width(), 8192);
|
||||
assert_eq!(plan.summary.atom_count, 8192);
|
||||
}
|
||||
}
|
||||
627
native/src/composition.rs
Normal file
627
native/src/composition.rs
Normal file
@ -0,0 +1,627 @@
|
||||
//! Current Column slots and their persistent composition. Shape contains only
|
||||
//! original structural paths; target nodes are read from the current document.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::evaluation::{record, EvalRecord, EvalWork, RenderScope, RenderedChange};
|
||||
use super::line_plan::{ChangeKind, LineOp, LinePlan, LineSplice, PlanChange, ProjectionState};
|
||||
use super::{LayoutContext, LayoutNode, LocalStep, Rendered};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ShapeRoute {
|
||||
slot: Option<usize>,
|
||||
next: Box<[(LocalStep, ShapeRoute)]>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ColumnShape {
|
||||
paths: Box<[Arc<[LocalStep]>]>,
|
||||
routes: ShapeRoute,
|
||||
}
|
||||
|
||||
impl ColumnShape {
|
||||
fn new(paths: Vec<Arc<[LocalStep]>>) -> Arc<Self> {
|
||||
// column_leaves supplies paths in original lexicographic order. Freeze
|
||||
// each prefix group once; updates only binary-search these shared arrays.
|
||||
fn build(paths: &[Arc<[LocalStep]>], first: usize, depth: usize) -> ShapeRoute {
|
||||
if paths.first().is_some_and(|path| path.len() == depth) {
|
||||
assert_eq!(paths.len(), 1, "one leaf at each original Column path");
|
||||
return ShapeRoute {
|
||||
slot: Some(first),
|
||||
next: Box::new([]),
|
||||
};
|
||||
}
|
||||
let mut next = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < paths.len() {
|
||||
let step = paths[start][depth];
|
||||
let mut end = start + 1;
|
||||
while end < paths.len() && paths[end][depth] == step {
|
||||
end += 1;
|
||||
}
|
||||
record(EvalWork {
|
||||
column_shape_steps_visited: (end - start) as u64,
|
||||
..EvalWork::default()
|
||||
});
|
||||
next.push((step, build(&paths[start..end], first + start, depth + 1)));
|
||||
start = end;
|
||||
}
|
||||
ShapeRoute {
|
||||
slot: None,
|
||||
next: next.into_boxed_slice(),
|
||||
}
|
||||
}
|
||||
let routes = build(&paths, 0, 0);
|
||||
Arc::new(Self {
|
||||
paths: paths.into_boxed_slice(),
|
||||
routes,
|
||||
})
|
||||
}
|
||||
|
||||
fn dirty_slots(&self, scope: &RenderScope<'_>) -> Vec<usize> {
|
||||
fn collect(
|
||||
route: &ShapeRoute,
|
||||
scope: &RenderScope<'_>,
|
||||
path: &mut Vec<LocalStep>,
|
||||
all: bool,
|
||||
slots: &mut Vec<usize>,
|
||||
) {
|
||||
record(EvalWork {
|
||||
column_shape_steps_visited: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
if let Some(slot) = route.slot {
|
||||
slots.push(slot);
|
||||
return;
|
||||
}
|
||||
let dirty = scope.dirty_route(path);
|
||||
if all || dirty.local {
|
||||
for (step, child) in &route.next {
|
||||
path.push(*step);
|
||||
collect(child, scope, path, true, slots);
|
||||
path.pop();
|
||||
}
|
||||
} else {
|
||||
for step in dirty.next {
|
||||
if let Ok(index) = route.next.binary_search_by_key(&step, |(key, _)| *key) {
|
||||
path.push(step);
|
||||
collect(&route.next[index].1, scope, path, false, slots);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut slots = Vec::new();
|
||||
collect(&self.routes, scope, &mut Vec::new(), false, &mut slots);
|
||||
slots
|
||||
}
|
||||
|
||||
fn scope<'a>(&self, scope: &RenderScope<'a>, index: usize) -> Result<RenderScope<'a>, String> {
|
||||
let mut result = scope.clone();
|
||||
for step in self.paths[index].iter() {
|
||||
record(EvalWork {
|
||||
column_shape_steps_visited: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
let (LayoutNode::Column { children, .. }, LocalStep::ColumnChild(index)) =
|
||||
(result.view.node, step)
|
||||
else {
|
||||
return Err("Native retained Column shape lost its original path".to_owned());
|
||||
};
|
||||
let child = children
|
||||
.get(*index)
|
||||
.ok_or_else(|| "Native retained Column shape lost its child slot".to_owned())?;
|
||||
result = result.child(*step, child);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ColumnSlot {
|
||||
record: Option<Arc<EvalRecord>>,
|
||||
raw: LinePlan,
|
||||
empty_width: i64,
|
||||
normalized: LinePlan,
|
||||
projection: Option<Arc<ProjectionState>>,
|
||||
}
|
||||
|
||||
impl ColumnSlot {
|
||||
fn normalize(
|
||||
raw: LinePlan,
|
||||
empty_width: i64,
|
||||
child: Option<Arc<EvalRecord>>,
|
||||
proof: Option<&PlanChange>,
|
||||
target: i64,
|
||||
previous: Option<&Self>,
|
||||
) -> (Arc<Self>, Option<PlanChange>) {
|
||||
let extra = (target - raw.first_width()).max(0);
|
||||
let (normalized, projection, change) = if extra == 0 {
|
||||
let change = previous.map(|old| {
|
||||
proof
|
||||
.filter(|proof| proof.applies_to(&old.normalized, &raw))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| PlanChange::replace_all(&old.normalized, &raw))
|
||||
});
|
||||
(raw.clone(), None, change)
|
||||
} else {
|
||||
let ops = Arc::from([LineOp::AppendSpace(extra), LineOp::ClearBreaks]);
|
||||
if let Some(old) = previous.and_then(|old| old.projection.as_ref()) {
|
||||
let update = old.update(raw.clone(), proof, ops);
|
||||
(
|
||||
update.state.plan().clone(),
|
||||
Some(update.state),
|
||||
Some(update.change),
|
||||
)
|
||||
} else {
|
||||
let projection = ProjectionState::new(raw.clone(), ops);
|
||||
let normalized = projection.plan().clone();
|
||||
let change =
|
||||
previous.map(|old| PlanChange::replace_all(&old.normalized, &normalized));
|
||||
(normalized, Some(projection), change)
|
||||
}
|
||||
};
|
||||
(
|
||||
Arc::new(Self {
|
||||
record: child,
|
||||
raw,
|
||||
empty_width,
|
||||
normalized,
|
||||
projection,
|
||||
}),
|
||||
change,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct ColumnMeasure {
|
||||
slots: usize,
|
||||
lines: usize,
|
||||
max_first: i64,
|
||||
max_width: i64,
|
||||
max_width_minus_first: Option<i64>,
|
||||
first_nonempty: Option<i64>,
|
||||
}
|
||||
|
||||
impl ColumnMeasure {
|
||||
fn leaf(slot: &ColumnSlot) -> Self {
|
||||
let first = if slot.raw.is_empty() {
|
||||
slot.empty_width
|
||||
} else {
|
||||
slot.raw.first_width()
|
||||
};
|
||||
let width = slot.raw.max_width();
|
||||
Self {
|
||||
slots: 1,
|
||||
lines: slot.raw.len(),
|
||||
max_first: first,
|
||||
max_width: width,
|
||||
max_width_minus_first: (!slot.raw.is_empty()).then_some(width - first),
|
||||
first_nonempty: (!slot.raw.is_empty()).then_some(first),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine(self, other: Self) -> Self {
|
||||
Self {
|
||||
slots: self.slots + other.slots,
|
||||
lines: self.lines + other.lines,
|
||||
max_first: self.max_first.max(other.max_first),
|
||||
max_width: self.max_width.max(other.max_width),
|
||||
max_width_minus_first: self.max_width_minus_first.max(other.max_width_minus_first),
|
||||
first_nonempty: self.first_nonempty.or(other.first_nonempty),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_max(self, target: i64) -> i64 {
|
||||
self.max_width_minus_first
|
||||
.map_or(0, |delta| self.max_width.max(target + delta))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ColumnNode {
|
||||
Leaf {
|
||||
slot: Arc<ColumnSlot>,
|
||||
measure: ColumnMeasure,
|
||||
},
|
||||
Branch {
|
||||
left: Arc<Self>,
|
||||
right: Arc<Self>,
|
||||
measure: ColumnMeasure,
|
||||
lines: LinePlan,
|
||||
},
|
||||
}
|
||||
|
||||
impl ColumnNode {
|
||||
fn leaf(slot: Arc<ColumnSlot>) -> Arc<Self> {
|
||||
record(EvalWork {
|
||||
column_tree_nodes_created: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
Arc::new(Self::Leaf {
|
||||
measure: ColumnMeasure::leaf(&slot),
|
||||
slot,
|
||||
})
|
||||
}
|
||||
|
||||
fn branch(left: Arc<Self>, right: Arc<Self>, previous: Option<&Self>) -> Arc<Self> {
|
||||
record(EvalWork {
|
||||
column_tree_nodes_created: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
let lines = match previous {
|
||||
Some(Self::Branch {
|
||||
left: old_left,
|
||||
right: old_right,
|
||||
lines,
|
||||
..
|
||||
}) if left.lines().ptr_eq(old_left.lines())
|
||||
&& right.lines().ptr_eq(old_right.lines()) =>
|
||||
{
|
||||
lines.clone()
|
||||
}
|
||||
_ => left.lines().concat(right.lines()),
|
||||
};
|
||||
Arc::new(Self::Branch {
|
||||
measure: left.measure().combine(right.measure()),
|
||||
left,
|
||||
right,
|
||||
lines,
|
||||
})
|
||||
}
|
||||
|
||||
fn build(slots: &[Arc<ColumnSlot>]) -> Option<Arc<Self>> {
|
||||
match slots.len() {
|
||||
0 => None,
|
||||
1 => Some(Self::leaf(Arc::clone(&slots[0]))),
|
||||
count => {
|
||||
let mid = count / 2;
|
||||
Some(Self::branch(
|
||||
Self::build(&slots[..mid]).unwrap(),
|
||||
Self::build(&slots[mid..]).unwrap(),
|
||||
None,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn measure(&self) -> ColumnMeasure {
|
||||
match self {
|
||||
Self::Leaf { measure, .. } | Self::Branch { measure, .. } => *measure,
|
||||
}
|
||||
}
|
||||
|
||||
fn lines(&self) -> &LinePlan {
|
||||
match self {
|
||||
Self::Leaf { slot, .. } => &slot.normalized,
|
||||
Self::Branch { lines, .. } => lines,
|
||||
}
|
||||
}
|
||||
|
||||
fn slot(&self, index: usize) -> &Arc<ColumnSlot> {
|
||||
record(EvalWork {
|
||||
column_tree_nodes_visited: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
match self {
|
||||
Self::Leaf { slot, .. } => {
|
||||
assert_eq!(index, 0);
|
||||
slot
|
||||
}
|
||||
Self::Branch { left, right, .. } => {
|
||||
let left_len = left.measure().slots;
|
||||
if index < left_len {
|
||||
left.slot(index)
|
||||
} else {
|
||||
right.slot(index - left_len)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_lines(&self, end: usize) -> usize {
|
||||
record(EvalWork {
|
||||
column_tree_nodes_visited: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
if end == 0 {
|
||||
return 0;
|
||||
}
|
||||
if end == self.measure().slots {
|
||||
return self.measure().lines;
|
||||
}
|
||||
match self {
|
||||
Self::Leaf { .. } => unreachable!("validated Column prefix"),
|
||||
Self::Branch { left, right, .. } => {
|
||||
let left_len = left.measure().slots;
|
||||
if end <= left_len {
|
||||
left.prefix_lines(end)
|
||||
} else {
|
||||
left.measure().lines + right.prefix_lines(end - left_len)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(&self, index: usize, slot: Arc<ColumnSlot>) -> Arc<Self> {
|
||||
record(EvalWork {
|
||||
column_tree_nodes_visited: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
match self {
|
||||
Self::Leaf { .. } => {
|
||||
assert_eq!(index, 0);
|
||||
Self::leaf(slot)
|
||||
}
|
||||
Self::Branch { left, right, .. } => {
|
||||
let left_len = left.measure().slots;
|
||||
if index < left_len {
|
||||
Self::branch(left.replace(index, slot), Arc::clone(right), Some(self))
|
||||
} else {
|
||||
Self::branch(
|
||||
Arc::clone(left),
|
||||
right.replace(index - left_len, slot),
|
||||
Some(self),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retarget(&self, target: i64) -> Arc<Self> {
|
||||
record(EvalWork {
|
||||
column_tree_nodes_visited: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
match self {
|
||||
Self::Leaf { slot, .. } => {
|
||||
record(EvalWork {
|
||||
column_slots_updated: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
let same = PlanChange::same(&slot.raw);
|
||||
Self::leaf(
|
||||
ColumnSlot::normalize(
|
||||
slot.raw.clone(),
|
||||
slot.empty_width,
|
||||
slot.record.clone(),
|
||||
Some(&same),
|
||||
target,
|
||||
Some(slot),
|
||||
)
|
||||
.0,
|
||||
)
|
||||
}
|
||||
Self::Branch { left, right, .. } => {
|
||||
Self::branch(left.retarget(target), right.retarget(target), Some(self))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ColumnState {
|
||||
shape: Arc<ColumnShape>,
|
||||
root: Option<Arc<ColumnNode>>,
|
||||
target: i64,
|
||||
lines: LinePlan,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl ColumnState {
|
||||
pub(super) fn shares_slot(&self, other: &Self, index: usize) -> bool {
|
||||
Arc::ptr_eq(
|
||||
self.root.as_ref().unwrap().slot(index),
|
||||
other.root.as_ref().unwrap().slot(index),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn shares_shape(&self, other: &Self) -> bool {
|
||||
Arc::ptr_eq(&self.shape, &other.shape)
|
||||
}
|
||||
}
|
||||
|
||||
fn target_width(context: LayoutContext, intrinsic: bool, maximum: i64) -> i64 {
|
||||
if intrinsic || context.inline_auto_width_intrinsic || !context.viewport_width_known {
|
||||
maximum
|
||||
} else {
|
||||
context.viewport_width.max(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn output(root: Option<&Arc<ColumnNode>>, previous: Option<&ColumnState>) -> LinePlan {
|
||||
if let Some(root) = root.filter(|root| root.measure().lines > 0) {
|
||||
return root.lines().clone();
|
||||
}
|
||||
if let Some(previous) = previous.filter(|previous| {
|
||||
previous
|
||||
.root
|
||||
.as_ref()
|
||||
.is_none_or(|root| root.measure().lines == 0)
|
||||
}) {
|
||||
return previous.lines.clone();
|
||||
}
|
||||
LinePlan::default()
|
||||
}
|
||||
|
||||
// A concatenation owns its joining break, including the former last line when
|
||||
// an empty boundary moves. Include that predecessor in each changed span.
|
||||
fn include_join_boundaries(splices: Vec<LineSplice>) -> Vec<LineSplice> {
|
||||
let mut result: Vec<LineSplice> = Vec::with_capacity(splices.len());
|
||||
for mut splice in splices {
|
||||
if splice.old.start == 0 || splice.new.start == 0 {
|
||||
splice.old.start = 0;
|
||||
splice.new.start = 0;
|
||||
} else {
|
||||
splice.old.start -= 1;
|
||||
splice.new.start -= 1;
|
||||
}
|
||||
if let Some(previous) = result.last_mut().filter(|previous| {
|
||||
splice.old.start <= previous.old.end || splice.new.start <= previous.new.end
|
||||
}) {
|
||||
previous.old.end = previous.old.end.max(splice.old.end);
|
||||
previous.new.end = previous.new.end.max(splice.new.end);
|
||||
} else {
|
||||
result.push(splice);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn render_column<'a>(
|
||||
scope: &RenderScope<'a>,
|
||||
children: &'a [LayoutNode],
|
||||
context: LayoutContext,
|
||||
intrinsic: bool,
|
||||
) -> Result<Rendered, String> {
|
||||
let state = if let Some(previous) = scope.previous_column() {
|
||||
let dirty = previous.shape.dirty_slots(scope);
|
||||
scope.inherit_column_uses();
|
||||
let mut root = previous.root.clone();
|
||||
let mut changes = Vec::with_capacity(dirty.len());
|
||||
for index in dirty {
|
||||
let child_scope = previous.shape.scope(scope, index)?;
|
||||
let result = child_scope.render_with_change(context, intrinsic, None)?;
|
||||
let old = previous
|
||||
.root
|
||||
.as_ref()
|
||||
.expect("dirty Column has slots")
|
||||
.slot(index);
|
||||
let (slot, change) = ColumnSlot::normalize(
|
||||
result.rendered.lines,
|
||||
result.rendered.empty_width,
|
||||
result.record,
|
||||
result.change.as_ref(),
|
||||
previous.target,
|
||||
Some(old),
|
||||
);
|
||||
record(EvalWork {
|
||||
column_slots_updated: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
root = Some(
|
||||
root.as_ref()
|
||||
.expect("dirty Column root")
|
||||
.replace(index, slot),
|
||||
);
|
||||
changes.push((index, change.expect("previous normalized child")));
|
||||
}
|
||||
let target = target_width(
|
||||
context,
|
||||
intrinsic,
|
||||
root.as_ref().map_or(0, |root| root.measure().max_first),
|
||||
);
|
||||
if target != previous.target {
|
||||
record(EvalWork {
|
||||
column_full_rebuilds: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
root = root.map(|root| root.retarget(target));
|
||||
}
|
||||
let lines = output(root.as_ref(), Some(&previous));
|
||||
let change = if lines.ptr_eq(&previous.lines) {
|
||||
PlanChange::same(&lines)
|
||||
} else if target != previous.target
|
||||
|| previous
|
||||
.root
|
||||
.as_ref()
|
||||
.is_none_or(|root| root.measure().lines == 0)
|
||||
|| root.as_ref().is_none_or(|root| root.measure().lines == 0)
|
||||
{
|
||||
PlanChange::replace_all(&previous.lines, &lines)
|
||||
} else {
|
||||
let old_root = previous.root.as_ref().unwrap();
|
||||
let new_root = root.as_ref().unwrap();
|
||||
let mut splices = Vec::new();
|
||||
for (index, change) in changes {
|
||||
let old_offset = old_root.prefix_lines(index);
|
||||
let new_offset = new_root.prefix_lines(index);
|
||||
match change.kind() {
|
||||
ChangeKind::Same => {}
|
||||
ChangeKind::Splices(parts) => {
|
||||
splices.extend(parts.iter().map(|part| LineSplice {
|
||||
old: old_offset + part.old.start..old_offset + part.old.end,
|
||||
new: new_offset + part.new.start..new_offset + part.new.end,
|
||||
}));
|
||||
}
|
||||
ChangeKind::ReplaceAll => splices.push(LineSplice {
|
||||
old: old_offset..old_offset + old_root.slot(index).raw.len(),
|
||||
new: new_offset..new_offset + new_root.slot(index).raw.len(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
PlanChange::splices(&previous.lines, &lines, include_join_boundaries(splices))
|
||||
};
|
||||
scope.publish_change(change);
|
||||
Arc::new(ColumnState {
|
||||
shape: Arc::clone(&previous.shape),
|
||||
root,
|
||||
target,
|
||||
lines,
|
||||
})
|
||||
} else {
|
||||
record(EvalWork {
|
||||
column_full_rebuilds: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
let mut paths = Vec::new();
|
||||
let mut results = Vec::new();
|
||||
for child in super::column_leaves(scope, children) {
|
||||
let path = &child.view.address.path[scope.view.address.path.len()..];
|
||||
record(EvalWork {
|
||||
source_path_steps_copied: path.len() as u64,
|
||||
column_slots_built: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
paths.push(Arc::from(path));
|
||||
results.push(child.render_with_change(context, intrinsic, None)?);
|
||||
}
|
||||
let maximum = results
|
||||
.iter()
|
||||
.map(|result| result.rendered.first_width())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let target = target_width(context, intrinsic, maximum);
|
||||
let slots = results
|
||||
.into_iter()
|
||||
.map(
|
||||
|RenderedChange {
|
||||
rendered, record, ..
|
||||
}| {
|
||||
ColumnSlot::normalize(
|
||||
rendered.lines,
|
||||
rendered.empty_width,
|
||||
record,
|
||||
None,
|
||||
target,
|
||||
None,
|
||||
)
|
||||
.0
|
||||
},
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
let root = ColumnNode::build(&slots);
|
||||
let lines = output(root.as_ref(), None);
|
||||
Arc::new(ColumnState {
|
||||
shape: ColumnShape::new(paths),
|
||||
root,
|
||||
target,
|
||||
lines,
|
||||
})
|
||||
};
|
||||
debug_assert_eq!(
|
||||
state
|
||||
.root
|
||||
.as_ref()
|
||||
.map_or(0, |root| root.measure().normalized_max(state.target)),
|
||||
state.lines.max_width()
|
||||
);
|
||||
let mut rendered = Rendered::from_line_plan(state.lines.clone());
|
||||
rendered.empty_width = state
|
||||
.root
|
||||
.as_ref()
|
||||
.map_or(0, |root| root.measure().max_first);
|
||||
scope.store_column(state);
|
||||
Ok(rendered)
|
||||
}
|
||||
212
native/src/composition_tests.rs
Normal file
212
native/src/composition_tests.rs
Normal file
@ -0,0 +1,212 @@
|
||||
mod composition_regressions {
|
||||
use super::*;
|
||||
|
||||
fn expected_text_line(
|
||||
text: &str,
|
||||
properties: &AtomProperties,
|
||||
break_after: Option<AtomProperties>,
|
||||
) -> TapeLine {
|
||||
TapeLine {
|
||||
width: text.chars().count() as i64,
|
||||
atoms: text
|
||||
.chars()
|
||||
.map(|character| TapeAtom::Text {
|
||||
text: character.to_string(),
|
||||
width: 1,
|
||||
properties: properties.clone(),
|
||||
})
|
||||
.collect(),
|
||||
break_after,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_before_wrap_and_slice_keeps_its_source_index_anchor() {
|
||||
let mut line = Line::from_clusters(&[
|
||||
cluster("a", 1, Some(3)),
|
||||
cluster("b", 1, Some(3)),
|
||||
cluster("c", 1, Some(3)),
|
||||
cluster("d", 1, Some(3)),
|
||||
]);
|
||||
line.own_content(11, 7);
|
||||
line.apply_style(Some(2));
|
||||
line.apply_property_template(Some(4));
|
||||
let source = Rendered::from_lines(vec![line]);
|
||||
let anchored = AtomProperties {
|
||||
style_ids: vec![2],
|
||||
content: Some(11),
|
||||
content_idx: Some(7),
|
||||
owner: Some(11),
|
||||
owners: vec![11],
|
||||
property_template_ids: vec![3, 4],
|
||||
..AtomProperties::default()
|
||||
};
|
||||
assert_eq!(
|
||||
source.clone().into_tape(6),
|
||||
LayoutTape {
|
||||
style_count: 6,
|
||||
lines: vec![expected_text_line("abcd", &anchored, None)],
|
||||
}
|
||||
);
|
||||
|
||||
let wrapped = wrap_rendered(source, 2, WrapMode::Char);
|
||||
let expected_wrapped = LayoutTape {
|
||||
style_count: 6,
|
||||
lines: vec![
|
||||
expected_text_line("ab", &anchored, Some(AtomProperties::default())),
|
||||
expected_text_line("cd", &anchored, None),
|
||||
],
|
||||
};
|
||||
assert_eq!(wrapped.clone().into_tape(6), expected_wrapped);
|
||||
|
||||
let mut selected = slice_rendered(wrapped.clone(), 1, 1);
|
||||
assert_eq!(
|
||||
selected.clone().into_tape(6),
|
||||
LayoutTape {
|
||||
style_count: 6,
|
||||
lines: vec![expected_text_line("cd", &anchored, None)],
|
||||
}
|
||||
);
|
||||
selected.lines = selected.lines.map_lines(|index, mut line| {
|
||||
line.own_content(22, 99 + index as i64);
|
||||
line.apply_style(Some(5));
|
||||
line.apply_property_template(Some(8));
|
||||
line
|
||||
});
|
||||
let outer = AtomProperties {
|
||||
style_ids: vec![2, 5],
|
||||
content: Some(11),
|
||||
content_idx: Some(7),
|
||||
owner: Some(22),
|
||||
owners: vec![11, 22],
|
||||
property_template_ids: vec![3, 4, 8],
|
||||
..AtomProperties::default()
|
||||
};
|
||||
assert_eq!(
|
||||
selected.into_tape(6),
|
||||
LayoutTape {
|
||||
style_count: 6,
|
||||
lines: vec![expected_text_line("cd", &outer, None)],
|
||||
}
|
||||
);
|
||||
assert_eq!(wrapped.into_tape(6), expected_wrapped);
|
||||
}
|
||||
|
||||
fn expected_column_with_prefix(prefix_lines: usize) -> LayoutTape {
|
||||
let parent_only = AtomProperties {
|
||||
owner: Some(1),
|
||||
owners: vec![1],
|
||||
..AtomProperties::default()
|
||||
};
|
||||
let owned_line = |region_id, content_index| TapeLine {
|
||||
width: 4,
|
||||
atoms: vec![
|
||||
TapeAtom::Text {
|
||||
text: "x".to_owned(),
|
||||
width: 1,
|
||||
properties: AtomProperties {
|
||||
content: Some(region_id),
|
||||
content_idx: Some(content_index),
|
||||
owner: Some(1),
|
||||
owners: vec![region_id, 1],
|
||||
..AtomProperties::default()
|
||||
},
|
||||
},
|
||||
TapeAtom::Space {
|
||||
width: 3,
|
||||
properties: parent_only.clone(),
|
||||
},
|
||||
],
|
||||
break_after: Some(AtomProperties::default()),
|
||||
};
|
||||
let mut lines = (0..prefix_lines)
|
||||
.map(|index| owned_line(10, index as i64))
|
||||
.collect::<Vec<_>>();
|
||||
lines.push(TapeLine {
|
||||
width: 4,
|
||||
atoms: vec![
|
||||
TapeAtom::Space {
|
||||
width: 0,
|
||||
properties: AtomProperties {
|
||||
content: Some(20),
|
||||
content_idx: Some(0),
|
||||
owner: Some(1),
|
||||
owners: vec![20, 1],
|
||||
..AtomProperties::default()
|
||||
},
|
||||
},
|
||||
TapeAtom::Space {
|
||||
width: 4,
|
||||
properties: parent_only.clone(),
|
||||
},
|
||||
],
|
||||
break_after: Some(AtomProperties::default()),
|
||||
});
|
||||
let mut suffix = owned_line(30, 0);
|
||||
suffix.break_after = None;
|
||||
lines.push(suffix);
|
||||
LayoutTape {
|
||||
style_count: 0,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_line_growth_and_shrink_preserve_suffix_carrier_ownership() {
|
||||
let mut empty_suffix = nonuniform_text(20, &[0]);
|
||||
let LayoutNode::Text { content, .. } = &mut empty_suffix else {
|
||||
unreachable!()
|
||||
};
|
||||
*content = Arc::new(measured_text(vec![Vec::new()]));
|
||||
let root = identified(
|
||||
child_box(
|
||||
1,
|
||||
LayoutNode::Column {
|
||||
node_id: None,
|
||||
node_revision: None,
|
||||
children: Arc::new(vec![
|
||||
identified(nonuniform_text(10, &[1]), 2, 1),
|
||||
identified(empty_suffix, 3, 1),
|
||||
identified(nonuniform_text(30, &[1]), 4, 1),
|
||||
]),
|
||||
},
|
||||
None,
|
||||
),
|
||||
1,
|
||||
1,
|
||||
);
|
||||
let mut document = RetainedDocument::bootstrap(retained_document(root))
|
||||
.unwrap()
|
||||
.0;
|
||||
let context = LayoutContext {
|
||||
viewport_width: 4,
|
||||
..test_context()
|
||||
};
|
||||
let mut frame = document.render_frame(None, None, context, None).unwrap();
|
||||
assert_eq!(frame.materialize_tape(), expected_column_with_prefix(1));
|
||||
|
||||
for (revision, prefix_lines) in [(1, 3), (2, 1)] {
|
||||
let lines = vec![
|
||||
serde_json::json!({"clusters":[{
|
||||
"text":"x","width":1,"cjk":false,"space":false
|
||||
}]});
|
||||
prefix_lines
|
||||
];
|
||||
let applied = document
|
||||
.apply_delta(context_test_delta(vec![serde_json::json!({
|
||||
"node-id":2,"expected-revision":revision,"target-revision":revision+1,
|
||||
"slot-patches":[{"slot":0,"local":{"content":{"lines":lines}}}]
|
||||
})]))
|
||||
.unwrap();
|
||||
frame = applied
|
||||
.document
|
||||
.render_frame(Some(&frame), Some(&applied.changes), context, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
frame.materialize_tape(),
|
||||
expected_column_with_prefix(prefix_lines)
|
||||
);
|
||||
document = applied.document;
|
||||
}
|
||||
}
|
||||
}
|
||||
1075
native/src/evaluation.rs
Normal file
1075
native/src/evaluation.rs
Normal file
File diff suppressed because it is too large
Load Diff
1724
native/src/evaluation_tests.rs
Normal file
1724
native/src/evaluation_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
5211
native/src/layout.rs
5211
native/src/layout.rs
File diff suppressed because it is too large
Load Diff
1401
native/src/lib.rs
1401
native/src/lib.rs
File diff suppressed because it is too large
Load Diff
295
native/src/line_metrics.rs
Normal file
295
native/src/line_metrics.rs
Normal file
@ -0,0 +1,295 @@
|
||||
//! Persistent numeric summaries. No node retains a line, document, or old projection.
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{record, Line, LinePlanWork};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct LineMetric {
|
||||
pub width: i64,
|
||||
pub chars: u64,
|
||||
pub min_content: i64,
|
||||
pub nonempty: bool,
|
||||
pub whitespace: bool,
|
||||
pub noncontent: bool,
|
||||
}
|
||||
|
||||
impl LineMetric {
|
||||
pub fn line(line: &Line) -> Self {
|
||||
Self {
|
||||
width: line.width,
|
||||
chars: line.atoms.char_count(),
|
||||
min_content: line.atoms.min_content_width(),
|
||||
nonempty: !line.atoms.is_empty(),
|
||||
whitespace: line.whitespace_only(),
|
||||
noncontent: line.has_noncontent_properties(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct Summary {
|
||||
pub count: usize,
|
||||
pub chars: u64,
|
||||
pub width_sum: i64,
|
||||
pub first_width: i64,
|
||||
pub min_width: i64,
|
||||
pub max_width: i64,
|
||||
pub min_content: i64,
|
||||
pub all_nonempty: bool,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
fn line(line: LineMetric) -> Self {
|
||||
Self {
|
||||
count: 1,
|
||||
chars: line.chars,
|
||||
width_sum: line.width,
|
||||
first_width: line.width,
|
||||
min_width: line.width,
|
||||
max_width: line.width,
|
||||
min_content: line.min_content,
|
||||
all_nonempty: line.nonempty,
|
||||
}
|
||||
}
|
||||
|
||||
fn combine(self, other: Self) -> Self {
|
||||
if self.count == 0 {
|
||||
return other;
|
||||
}
|
||||
if other.count == 0 {
|
||||
return self;
|
||||
}
|
||||
Self {
|
||||
count: self
|
||||
.count
|
||||
.checked_add(other.count)
|
||||
.expect("validated line count"),
|
||||
chars: self
|
||||
.chars
|
||||
.checked_add(other.chars)
|
||||
.expect("validated character count"),
|
||||
width_sum: self
|
||||
.width_sum
|
||||
.checked_add(other.width_sum)
|
||||
.expect("validated line widths"),
|
||||
first_width: self.first_width,
|
||||
min_width: self.min_width.min(other.min_width),
|
||||
max_width: self.max_width.max(other.max_width),
|
||||
min_content: self.min_content.max(other.min_content),
|
||||
all_nonempty: self.all_nonempty && other.all_nonempty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct Metrics(Option<Arc<Node>>);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Node {
|
||||
summary: Summary,
|
||||
height: u32,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Kind {
|
||||
Leaf(Box<[LineMetric]>),
|
||||
Branch(Metrics, Metrics),
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
pub fn from_iter(lines: impl IntoIterator<Item = LineMetric>) -> Self {
|
||||
let mut lines = lines.into_iter();
|
||||
let mut blocks = Vec::new();
|
||||
loop {
|
||||
let block = lines.by_ref().take(16).collect::<Vec<_>>();
|
||||
if block.is_empty() {
|
||||
break;
|
||||
}
|
||||
blocks.push(Self::leaf(block.into_boxed_slice()));
|
||||
}
|
||||
fn build(lines: &[Metrics]) -> Metrics {
|
||||
match lines {
|
||||
[] => Metrics::default(),
|
||||
[line] => line.clone(),
|
||||
_ => Metrics::branch(
|
||||
build(&lines[..lines.len() / 2]),
|
||||
build(&lines[lines.len() / 2..]),
|
||||
),
|
||||
}
|
||||
}
|
||||
build(&blocks)
|
||||
}
|
||||
|
||||
fn line(line: LineMetric) -> Self {
|
||||
Self::leaf(Box::new([line]))
|
||||
}
|
||||
|
||||
fn leaf(lines: Box<[LineMetric]>) -> Self {
|
||||
record(LinePlanWork {
|
||||
metric_nodes_created: 1,
|
||||
metric_entries_written: lines.len() as u64,
|
||||
..LinePlanWork::default()
|
||||
});
|
||||
let summary = lines
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(Summary::default(), |summary, line| {
|
||||
summary.combine(Summary::line(line))
|
||||
});
|
||||
Self(Some(Arc::new(Node {
|
||||
summary,
|
||||
height: 1,
|
||||
kind: Kind::Leaf(lines),
|
||||
})))
|
||||
}
|
||||
|
||||
fn height(&self) -> u32 {
|
||||
self.0.as_ref().map_or(0, |node| node.height)
|
||||
}
|
||||
pub fn summary(&self) -> Summary {
|
||||
self.0
|
||||
.as_ref()
|
||||
.map_or(Summary::default(), |node| node.summary)
|
||||
}
|
||||
|
||||
fn branch(left: Self, right: Self) -> Self {
|
||||
if left.0.is_none() {
|
||||
return right;
|
||||
}
|
||||
if right.0.is_none() {
|
||||
return left;
|
||||
}
|
||||
record(LinePlanWork {
|
||||
metric_nodes_created: 1,
|
||||
..LinePlanWork::default()
|
||||
});
|
||||
Self(Some(Arc::new(Node {
|
||||
summary: left.summary().combine(right.summary()),
|
||||
height: left.height().max(right.height()) + 1,
|
||||
kind: Kind::Branch(left, right),
|
||||
})))
|
||||
}
|
||||
|
||||
fn children(&self) -> (&Self, &Self) {
|
||||
let Kind::Branch(left, right) = &self.0.as_ref().expect("nonempty metrics").kind else {
|
||||
unreachable!("metric balance branch")
|
||||
};
|
||||
(left, right)
|
||||
}
|
||||
|
||||
fn balanced(left: Self, right: Self) -> Self {
|
||||
if left.height() > right.height() + 1 {
|
||||
let (ll, lr) = left.children();
|
||||
if ll.height() >= lr.height() {
|
||||
Self::branch(ll.clone(), Self::branch(lr.clone(), right))
|
||||
} else {
|
||||
let (lrl, lrr) = lr.children();
|
||||
Self::branch(
|
||||
Self::branch(ll.clone(), lrl.clone()),
|
||||
Self::branch(lrr.clone(), right),
|
||||
)
|
||||
}
|
||||
} else if right.height() > left.height() + 1 {
|
||||
let (rl, rr) = right.children();
|
||||
if rr.height() >= rl.height() {
|
||||
Self::branch(Self::branch(left, rl.clone()), rr.clone())
|
||||
} else {
|
||||
let (rll, rlr) = rl.children();
|
||||
Self::branch(
|
||||
Self::branch(left, rll.clone()),
|
||||
Self::branch(rlr.clone(), rr.clone()),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Self::branch(left, right)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn concat(&self, other: &Self) -> Self {
|
||||
if self.height() > other.height() + 1 {
|
||||
let (left, right) = self.children();
|
||||
Self::balanced(left.clone(), right.concat(other))
|
||||
} else if other.height() > self.height() + 1 {
|
||||
let (left, right) = other.children();
|
||||
Self::balanced(self.concat(left), right.clone())
|
||||
} else {
|
||||
Self::branch(self.clone(), other.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn get(&self, index: usize) -> Option<LineMetric> {
|
||||
let node = self.0.as_ref()?;
|
||||
record(LinePlanWork {
|
||||
metric_nodes_visited: 1,
|
||||
..LinePlanWork::default()
|
||||
});
|
||||
match &node.kind {
|
||||
Kind::Leaf(lines) => lines.get(index).copied(),
|
||||
Kind::Branch(left, right) => {
|
||||
if index < left.summary().count {
|
||||
left.get(index)
|
||||
} else {
|
||||
right.get(index - left.summary().count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slice(&self, range: Range<usize>) -> Self {
|
||||
assert!(range.start <= range.end && range.end <= self.summary().count);
|
||||
if range.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
if range.start == 0 && range.end == self.summary().count {
|
||||
return self.clone();
|
||||
}
|
||||
record(LinePlanWork {
|
||||
metric_nodes_visited: 1,
|
||||
..LinePlanWork::default()
|
||||
});
|
||||
if let Kind::Leaf(lines) = &self.0.as_ref().expect("nonempty metrics").kind {
|
||||
return Self::leaf(lines[range].into());
|
||||
}
|
||||
let (left, right) = self.children();
|
||||
let middle = left.summary().count;
|
||||
if range.end <= middle {
|
||||
left.slice(range)
|
||||
} else if range.start >= middle {
|
||||
right.slice(range.start - middle..range.end - middle)
|
||||
} else {
|
||||
left.slice(range.start..middle)
|
||||
.concat(&right.slice(0..range.end - middle))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace(&self, index: usize, line: LineMetric) -> Self {
|
||||
self.slice(0..index)
|
||||
.concat(&Self::line(line))
|
||||
.concat(&self.slice(index + 1..self.summary().count))
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = LineMetric> + '_ {
|
||||
let mut stack = self.0.as_deref().into_iter().collect::<Vec<_>>();
|
||||
let mut leaf = [].iter();
|
||||
std::iter::from_fn(move || loop {
|
||||
if let Some(line) = leaf.next() {
|
||||
return Some(*line);
|
||||
}
|
||||
let node = stack.pop()?;
|
||||
record(LinePlanWork {
|
||||
metric_nodes_visited: 1,
|
||||
..LinePlanWork::default()
|
||||
});
|
||||
match &node.kind {
|
||||
Kind::Leaf(lines) => leaf = lines.iter(),
|
||||
Kind::Branch(left, right) => {
|
||||
stack.push(right.0.as_deref().expect("nonempty metric branch"));
|
||||
stack.push(left.0.as_deref().expect("nonempty metric branch"));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
170
native/src/line_ops.rs
Normal file
170
native/src/line_ops.rs
Normal file
@ -0,0 +1,170 @@
|
||||
use super::super::{AtomProperties, HorizontalAlign, Line, RegionRole};
|
||||
use super::line_metrics::LineMetric;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(in crate::layout) enum BoxProjectionSlot {
|
||||
ContentPad,
|
||||
ContentOwn,
|
||||
ContentCollapse,
|
||||
VerticalFrame,
|
||||
PaddingFrame,
|
||||
MarginFrame,
|
||||
PaddingEdges,
|
||||
BoundaryBorders,
|
||||
SurfaceTemplate,
|
||||
MarginEdges,
|
||||
OverflowEdges,
|
||||
ScrollWindow,
|
||||
ScrollContent,
|
||||
TransparentContent,
|
||||
}
|
||||
|
||||
/// Ordered, cardinality-preserving operations. Each index is in this stage's input.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(in crate::layout) enum LineOp {
|
||||
PadTo {
|
||||
width: i64,
|
||||
align: HorizontalAlign,
|
||||
},
|
||||
AppendSpace(i64),
|
||||
OwnContent {
|
||||
region: i64,
|
||||
start: i64,
|
||||
},
|
||||
CollapseWhitespace {
|
||||
width: i64,
|
||||
region: i64,
|
||||
},
|
||||
EdgeSpaces {
|
||||
left: i64,
|
||||
left_properties: Arc<AtomProperties>,
|
||||
right: i64,
|
||||
right_properties: Arc<AtomProperties>,
|
||||
},
|
||||
Style(u32),
|
||||
Template(u32),
|
||||
ClearBreaks,
|
||||
ScrollWindow(i64),
|
||||
FirstStyleRole {
|
||||
style: u32,
|
||||
region: i64,
|
||||
},
|
||||
LastStyleRole {
|
||||
style: u32,
|
||||
region: i64,
|
||||
},
|
||||
}
|
||||
|
||||
impl LineOp {
|
||||
pub fn apply(&self, mut line: Line, index: usize, length: usize) -> Line {
|
||||
match self {
|
||||
Self::PadTo { width, align } => line = line.padded(*width, *align),
|
||||
Self::AppendSpace(width) => line.push_space(*width),
|
||||
Self::OwnContent { region, start } => line.own_content(*region, *start + index as i64),
|
||||
Self::CollapseWhitespace { width, region } => {
|
||||
line = line.collapse_whitespace_content(*width, *region)
|
||||
}
|
||||
Self::EdgeSpaces {
|
||||
left,
|
||||
left_properties,
|
||||
right,
|
||||
right_properties,
|
||||
} => {
|
||||
line.prepend_space_with_properties(*left, left_properties.as_ref().clone());
|
||||
line.push_space_with_properties(*right, right_properties.as_ref().clone());
|
||||
}
|
||||
Self::Style(style) => line.apply_style(Some(*style)),
|
||||
Self::Template(template) => line.apply_property_template(Some(*template)),
|
||||
Self::ScrollWindow(region) => line.atoms = line.atoms.apply_scroll_window(*region),
|
||||
Self::FirstStyleRole { style, region } if index == 0 => {
|
||||
line.apply_style(Some(*style));
|
||||
line.apply_role(RegionRole::BorderTop, *region);
|
||||
}
|
||||
Self::LastStyleRole { style, region } if index + 1 == length => {
|
||||
line.apply_style(Some(*style));
|
||||
line.apply_role(RegionRole::BorderBottom, *region);
|
||||
}
|
||||
Self::FirstStyleRole { .. } | Self::LastStyleRole { .. } | Self::ClearBreaks => {}
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
pub(super) fn metric(&self, mut line: LineMetric, index: usize, length: usize) -> LineMetric {
|
||||
fn space(line: &mut LineMetric, width: i64, properties: Option<&AtomProperties>) {
|
||||
if width <= 0 {
|
||||
return;
|
||||
}
|
||||
line.width += width;
|
||||
line.chars += 1;
|
||||
if !line.nonempty {
|
||||
line.whitespace = true;
|
||||
}
|
||||
line.nonempty = true;
|
||||
line.noncontent |= properties.is_some_and(|p| {
|
||||
!p.style_ids.is_empty()
|
||||
|| !p.roles.is_empty()
|
||||
|| !p.property_template_ids.is_empty()
|
||||
});
|
||||
}
|
||||
match self {
|
||||
Self::PadTo { width, align } => {
|
||||
let remaining = (*width - line.width).max(0);
|
||||
let left = match align {
|
||||
HorizontalAlign::Left => 0,
|
||||
HorizontalAlign::Right => remaining,
|
||||
HorizontalAlign::Center => remaining / 2,
|
||||
};
|
||||
space(&mut line, left, None);
|
||||
space(&mut line, remaining - left, None);
|
||||
}
|
||||
Self::AppendSpace(width) => space(&mut line, *width, None),
|
||||
Self::EdgeSpaces {
|
||||
left,
|
||||
left_properties,
|
||||
right,
|
||||
right_properties,
|
||||
} => {
|
||||
space(&mut line, *left, Some(left_properties));
|
||||
space(&mut line, *right, Some(right_properties));
|
||||
}
|
||||
Self::CollapseWhitespace { width, .. } if line.whitespace && !line.noncontent => {
|
||||
line = LineMetric {
|
||||
width: (*width).max(0),
|
||||
chars: 1,
|
||||
min_content: 0,
|
||||
nonempty: true,
|
||||
whitespace: true,
|
||||
noncontent: false,
|
||||
};
|
||||
}
|
||||
Self::Style(_) | Self::Template(_) => line.noncontent |= line.nonempty,
|
||||
Self::FirstStyleRole { .. } if index == 0 => line.noncontent |= line.nonempty,
|
||||
Self::LastStyleRole { .. } if index + 1 == length => line.noncontent |= line.nonempty,
|
||||
_ => {}
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
pub fn apply_break(&self, properties: &mut AtomProperties) {
|
||||
match self {
|
||||
Self::ClearBreaks => *properties = AtomProperties::default(),
|
||||
Self::ScrollWindow(region) => properties.scroll_window = Some(*region),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn boundary_sensitive(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::FirstStyleRole { .. } | Self::LastStyleRole { .. }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn preserves_metrics(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::OwnContent { .. } | Self::ClearBreaks | Self::ScrollWindow(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
1990
native/src/line_plan.rs
Normal file
1990
native/src/line_plan.rs
Normal file
File diff suppressed because it is too large
Load Diff
896
native/src/sequence.rs
Normal file
896
native/src/sequence.rs
Normal file
@ -0,0 +1,896 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
const LEAF_CAPACITY: usize = 16;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Measure {
|
||||
pub chars: u64,
|
||||
pub lines: u64,
|
||||
pub width_sum: i64,
|
||||
pub width_max: i64,
|
||||
}
|
||||
|
||||
impl Measure {
|
||||
pub fn new(chars: u64, lines: u64, width_sum: i64, width_max: i64) -> Result<Self, Error> {
|
||||
if width_sum < 0 || width_max < 0 {
|
||||
return Err(Error::NegativeWidth);
|
||||
}
|
||||
Ok(Self {
|
||||
chars,
|
||||
lines,
|
||||
width_sum,
|
||||
width_max,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn checked_combine(self, other: Self) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
chars: self.chars.checked_add(other.chars).ok_or(Error::Overflow)?,
|
||||
lines: self.lines.checked_add(other.lines).ok_or(Error::Overflow)?,
|
||||
width_sum: self
|
||||
.width_sum
|
||||
.checked_add(other.width_sum)
|
||||
.ok_or(Error::Overflow)?,
|
||||
width_max: self.width_max.max(other.width_max),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Entry<T> {
|
||||
value: Arc<T>,
|
||||
measure: Measure,
|
||||
}
|
||||
|
||||
impl<T> Clone for Entry<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
value: Arc::clone(&self.value),
|
||||
measure: self.measure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Entry<T> {
|
||||
pub fn new(value: Arc<T>, measure: Measure) -> Result<Self, Error> {
|
||||
if measure.width_sum < 0 || measure.width_max < 0 {
|
||||
return Err(Error::NegativeWidth);
|
||||
}
|
||||
Ok(Self { value, measure })
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &Arc<T> {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn measure(&self) -> Measure {
|
||||
self.measure
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Work {
|
||||
pub nodes_created: u64,
|
||||
pub entries_written: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
IndexOutOfBounds,
|
||||
NegativeWidth,
|
||||
Overflow,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Node<T> {
|
||||
Leaf {
|
||||
entries: Box<[Entry<T>]>,
|
||||
measure: Measure,
|
||||
},
|
||||
Branch {
|
||||
left: Arc<Node<T>>,
|
||||
right: Arc<Node<T>>,
|
||||
len: usize,
|
||||
height: u32,
|
||||
measure: Measure,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum NodeView<'a, T> {
|
||||
Leaf {
|
||||
entries: &'a [Entry<T>],
|
||||
measure: Measure,
|
||||
},
|
||||
Branch {
|
||||
left: &'a Arc<Node<T>>,
|
||||
right: &'a Arc<Node<T>>,
|
||||
len: usize,
|
||||
height: u32,
|
||||
measure: Measure,
|
||||
},
|
||||
}
|
||||
|
||||
type OptionalRoot<T> = Option<Arc<Node<T>>>;
|
||||
type SplitRoots<T> = (OptionalRoot<T>, OptionalRoot<T>);
|
||||
|
||||
impl<T> Node<T> {
|
||||
pub fn view(&self) -> NodeView<'_, T> {
|
||||
match self {
|
||||
Self::Leaf { entries, measure } => NodeView::Leaf {
|
||||
entries,
|
||||
measure: *measure,
|
||||
},
|
||||
Self::Branch {
|
||||
left,
|
||||
right,
|
||||
len,
|
||||
height,
|
||||
measure,
|
||||
} => NodeView::Branch {
|
||||
left,
|
||||
right,
|
||||
len: *len,
|
||||
height: *height,
|
||||
measure: *measure,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
match self {
|
||||
Self::Leaf { entries, .. } => entries.len(),
|
||||
Self::Branch { len, .. } => *len,
|
||||
}
|
||||
}
|
||||
|
||||
fn height(&self) -> u32 {
|
||||
match self {
|
||||
Self::Leaf { .. } => 1,
|
||||
Self::Branch { height, .. } => *height,
|
||||
}
|
||||
}
|
||||
|
||||
fn measure(&self) -> Measure {
|
||||
match self {
|
||||
Self::Leaf { measure, .. } | Self::Branch { measure, .. } => *measure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Sequence<T> {
|
||||
root: Option<Arc<Node<T>>>,
|
||||
}
|
||||
|
||||
/// Borrowed in-order traversal with an O(log N) stack and no entry clones.
|
||||
pub struct Iter<'a, T> {
|
||||
pending: Vec<&'a Node<T>>,
|
||||
leaf: std::slice::Iter<'a, Entry<T>>,
|
||||
remaining: usize,
|
||||
nodes_visited: u64,
|
||||
}
|
||||
|
||||
impl<T> Iter<'_, T> {
|
||||
pub fn nodes_visited(&self) -> u64 {
|
||||
self.nodes_visited
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> Iterator for Iter<'a, T> {
|
||||
type Item = &'a Entry<T>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
if let Some(entry) = self.leaf.next() {
|
||||
self.remaining -= 1;
|
||||
return Some(entry);
|
||||
}
|
||||
let node = self.pending.pop()?;
|
||||
self.nodes_visited = self
|
||||
.nodes_visited
|
||||
.checked_add(1)
|
||||
.expect("sequence traversal count fits u64");
|
||||
match node {
|
||||
Node::Leaf { entries, .. } => self.leaf = entries.iter(),
|
||||
Node::Branch { left, right, .. } => {
|
||||
self.pending.push(right);
|
||||
self.pending.push(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
(self.remaining, Some(self.remaining))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ExactSizeIterator for Iter<'_, T> {}
|
||||
|
||||
impl<T> Clone for Sequence<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
root: self.root.as_ref().map(Arc::clone),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Default for Sequence<T> {
|
||||
fn default() -> Self {
|
||||
Self::empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Sequence<T> {
|
||||
pub fn empty() -> Self {
|
||||
Self { root: None }
|
||||
}
|
||||
|
||||
pub fn from_entries<I>(entries: I, work: &mut Work) -> Result<Self, Error>
|
||||
where
|
||||
I: IntoIterator<Item = Entry<T>>,
|
||||
{
|
||||
let entries = entries.into_iter().collect::<Vec<_>>();
|
||||
let mut leaves = Vec::with_capacity(entries.len().div_ceil(LEAF_CAPACITY));
|
||||
for chunk in entries.chunks(LEAF_CAPACITY) {
|
||||
leaves.push(make_leaf(chunk.to_vec(), work)?);
|
||||
}
|
||||
fn build<T>(leaves: &[Arc<Node<T>>], work: &mut Work) -> Result<Arc<Node<T>>, Error> {
|
||||
if leaves.len() == 1 {
|
||||
return Ok(Arc::clone(&leaves[0]));
|
||||
}
|
||||
let middle = leaves.len() / 2;
|
||||
make_branch(
|
||||
build(&leaves[..middle], work)?,
|
||||
build(&leaves[middle..], work)?,
|
||||
work,
|
||||
)
|
||||
}
|
||||
Ok(Self {
|
||||
root: if leaves.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(build(&leaves, work)?)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.root.as_ref().map_or(0, |root| root.len())
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.root.is_none()
|
||||
}
|
||||
|
||||
pub fn measure(&self) -> Measure {
|
||||
self.root
|
||||
.as_ref()
|
||||
.map_or(Measure::default(), |root| root.measure())
|
||||
}
|
||||
|
||||
pub fn root(&self) -> Option<&Arc<Node<T>>> {
|
||||
self.root.as_ref()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> Iter<'_, T> {
|
||||
Iter {
|
||||
pending: self.root.as_deref().into_iter().collect(),
|
||||
leaf: [].iter(),
|
||||
remaining: self.len(),
|
||||
nodes_visited: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, index: usize) -> Option<&Entry<T>> {
|
||||
fn get_at<T>(node: &Node<T>, index: usize) -> Option<&Entry<T>> {
|
||||
match node {
|
||||
Node::Leaf { entries, .. } => entries.get(index),
|
||||
Node::Branch { left, right, .. } => {
|
||||
if index < left.len() {
|
||||
get_at(left, index)
|
||||
} else {
|
||||
get_at(right, index - left.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(index < self.len()).then(|| get_at(self.root.as_deref()?, index))?
|
||||
}
|
||||
|
||||
pub fn prefix_measure(&self, end: usize) -> Result<Measure, Error> {
|
||||
fn prefix<T>(node: &Node<T>, end: usize) -> Result<Measure, Error> {
|
||||
match node {
|
||||
Node::Leaf { entries, .. } => entries[..end]
|
||||
.iter()
|
||||
.try_fold(Measure::default(), |sum, entry| {
|
||||
sum.checked_combine(entry.measure)
|
||||
}),
|
||||
Node::Branch { left, right, .. } => {
|
||||
if end <= left.len() {
|
||||
prefix(left, end)
|
||||
} else {
|
||||
left.measure()
|
||||
.checked_combine(prefix(right, end - left.len())?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if end > self.len() {
|
||||
return Err(Error::IndexOutOfBounds);
|
||||
}
|
||||
self.root
|
||||
.as_deref()
|
||||
.map_or(Ok(Measure::default()), |root| prefix(root, end))
|
||||
}
|
||||
|
||||
/// Aggregate a range by visiting its boundary paths and cached interior nodes.
|
||||
pub fn range_measure(&self, range: Range<usize>) -> Result<Measure, Error> {
|
||||
fn measure<T>(node: &Node<T>, start: usize, end: usize) -> Result<Measure, Error> {
|
||||
if start == end {
|
||||
return Ok(Measure::default());
|
||||
}
|
||||
if start == 0 && end == node.len() {
|
||||
return Ok(node.measure());
|
||||
}
|
||||
match node {
|
||||
Node::Leaf { entries, .. } => entries[start..end]
|
||||
.iter()
|
||||
.try_fold(Measure::default(), |sum, entry| {
|
||||
sum.checked_combine(entry.measure)
|
||||
}),
|
||||
Node::Branch { left, right, .. } => {
|
||||
let middle = left.len();
|
||||
if end <= middle {
|
||||
measure(left, start, end)
|
||||
} else if start >= middle {
|
||||
measure(right, start - middle, end - middle)
|
||||
} else {
|
||||
measure(left, start, middle)?.checked_combine(measure(
|
||||
right,
|
||||
0,
|
||||
end - middle,
|
||||
)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if range.start > range.end || range.end > self.len() {
|
||||
return Err(Error::IndexOutOfBounds);
|
||||
}
|
||||
self.root.as_deref().map_or(Ok(Measure::default()), |root| {
|
||||
measure(root, range.start, range.end)
|
||||
})
|
||||
}
|
||||
|
||||
/// Share the interior of a range, copying only its two boundary paths.
|
||||
pub fn slice(&self, range: Range<usize>, work: &mut Work) -> Result<Self, Error> {
|
||||
if range.start > range.end || range.end > self.len() {
|
||||
return Err(Error::IndexOutOfBounds);
|
||||
}
|
||||
let (before, _) = self.split_at(range.end, work)?;
|
||||
let (_, selected) = before.split_at(range.start, work)?;
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
pub fn split_at(&self, index: usize, work: &mut Work) -> Result<(Self, Self), Error> {
|
||||
if index > self.len() {
|
||||
return Err(Error::IndexOutOfBounds);
|
||||
}
|
||||
let (left, right) = split_node(self.root.as_ref(), index, work)?;
|
||||
Ok((Self { root: left }, Self { root: right }))
|
||||
}
|
||||
|
||||
pub fn concat(&self, other: &Self, work: &mut Work) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
root: join(self.root.clone(), other.root.clone(), work)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn replace(&self, index: usize, entry: Entry<T>, work: &mut Work) -> Result<Self, Error> {
|
||||
if index >= self.len() {
|
||||
return Err(Error::IndexOutOfBounds);
|
||||
}
|
||||
Ok(Self {
|
||||
root: Some(replace_node(
|
||||
self.root.as_ref().expect("nonempty checked"),
|
||||
index,
|
||||
entry,
|
||||
work,
|
||||
)?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn count_node(work: &mut Work) -> Result<(), Error> {
|
||||
work.nodes_created = work.nodes_created.checked_add(1).ok_or(Error::Overflow)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_leaf<T>(entries: Vec<Entry<T>>, work: &mut Work) -> Result<Arc<Node<T>>, Error> {
|
||||
debug_assert!(!entries.is_empty() && entries.len() <= LEAF_CAPACITY);
|
||||
let measure = entries.iter().try_fold(Measure::default(), |sum, entry| {
|
||||
sum.checked_combine(entry.measure)
|
||||
})?;
|
||||
let written = u64::try_from(entries.len()).map_err(|_| Error::Overflow)?;
|
||||
let next_written = work
|
||||
.entries_written
|
||||
.checked_add(written)
|
||||
.ok_or(Error::Overflow)?;
|
||||
count_node(work)?;
|
||||
work.entries_written = next_written;
|
||||
Ok(Arc::new(Node::Leaf {
|
||||
entries: entries.into_boxed_slice(),
|
||||
measure,
|
||||
}))
|
||||
}
|
||||
|
||||
fn make_branch<T>(
|
||||
left: Arc<Node<T>>,
|
||||
right: Arc<Node<T>>,
|
||||
work: &mut Work,
|
||||
) -> Result<Arc<Node<T>>, Error> {
|
||||
let len = left.len().checked_add(right.len()).ok_or(Error::Overflow)?;
|
||||
let height = left
|
||||
.height()
|
||||
.max(right.height())
|
||||
.checked_add(1)
|
||||
.ok_or(Error::Overflow)?;
|
||||
let measure = left.measure().checked_combine(right.measure())?;
|
||||
count_node(work)?;
|
||||
Ok(Arc::new(Node::Branch {
|
||||
left,
|
||||
right,
|
||||
len,
|
||||
height,
|
||||
measure,
|
||||
}))
|
||||
}
|
||||
|
||||
fn join<T>(
|
||||
left: Option<Arc<Node<T>>>,
|
||||
right: Option<Arc<Node<T>>>,
|
||||
work: &mut Work,
|
||||
) -> Result<Option<Arc<Node<T>>>, Error> {
|
||||
let (left, right) = match (left, right) {
|
||||
(None, right) => return Ok(right),
|
||||
(left, None) => return Ok(left),
|
||||
(Some(left), Some(right)) => (left, right),
|
||||
};
|
||||
if let (Node::Leaf { entries: a, .. }, Node::Leaf { entries: b, .. }) =
|
||||
(left.as_ref(), right.as_ref())
|
||||
{
|
||||
if a.len() + b.len() <= LEAF_CAPACITY {
|
||||
let mut entries = Vec::with_capacity(a.len() + b.len());
|
||||
entries.extend_from_slice(a);
|
||||
entries.extend_from_slice(b);
|
||||
return make_leaf(entries, work).map(Some);
|
||||
}
|
||||
}
|
||||
if left.height() > right.height().saturating_add(1) {
|
||||
let Node::Branch {
|
||||
left: outer,
|
||||
right: inner,
|
||||
..
|
||||
} = left.as_ref()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let joined = join(Some(Arc::clone(inner)), Some(right), work)?.expect("nonempty join");
|
||||
return rebalance(Arc::clone(outer), joined, work).map(Some);
|
||||
}
|
||||
if right.height() > left.height().saturating_add(1) {
|
||||
let Node::Branch {
|
||||
left: inner,
|
||||
right: outer,
|
||||
..
|
||||
} = right.as_ref()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let joined = join(Some(left), Some(Arc::clone(inner)), work)?.expect("nonempty join");
|
||||
return rebalance(joined, Arc::clone(outer), work).map(Some);
|
||||
}
|
||||
make_branch(left, right, work).map(Some)
|
||||
}
|
||||
|
||||
fn rebalance<T>(
|
||||
left: Arc<Node<T>>,
|
||||
right: Arc<Node<T>>,
|
||||
work: &mut Work,
|
||||
) -> Result<Arc<Node<T>>, Error> {
|
||||
if left.height() > right.height().saturating_add(1) {
|
||||
let Node::Branch {
|
||||
left: ll,
|
||||
right: lr,
|
||||
..
|
||||
} = left.as_ref()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
if ll.height() >= lr.height() {
|
||||
return make_branch(
|
||||
Arc::clone(ll),
|
||||
make_branch(Arc::clone(lr), right, work)?,
|
||||
work,
|
||||
);
|
||||
}
|
||||
let Node::Branch {
|
||||
left: lrl,
|
||||
right: lrr,
|
||||
..
|
||||
} = lr.as_ref()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
return make_branch(
|
||||
make_branch(Arc::clone(ll), Arc::clone(lrl), work)?,
|
||||
make_branch(Arc::clone(lrr), right, work)?,
|
||||
work,
|
||||
);
|
||||
}
|
||||
if right.height() > left.height().saturating_add(1) {
|
||||
let Node::Branch {
|
||||
left: rl,
|
||||
right: rr,
|
||||
..
|
||||
} = right.as_ref()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
if rr.height() >= rl.height() {
|
||||
return make_branch(
|
||||
make_branch(left, Arc::clone(rl), work)?,
|
||||
Arc::clone(rr),
|
||||
work,
|
||||
);
|
||||
}
|
||||
let Node::Branch {
|
||||
left: rll,
|
||||
right: rlr,
|
||||
..
|
||||
} = rl.as_ref()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
return make_branch(
|
||||
make_branch(left, Arc::clone(rll), work)?,
|
||||
make_branch(Arc::clone(rlr), Arc::clone(rr), work)?,
|
||||
work,
|
||||
);
|
||||
}
|
||||
make_branch(left, right, work)
|
||||
}
|
||||
|
||||
fn split_node<T>(
|
||||
node: Option<&Arc<Node<T>>>,
|
||||
index: usize,
|
||||
work: &mut Work,
|
||||
) -> Result<SplitRoots<T>, Error> {
|
||||
let Some(node) = node else {
|
||||
return Ok((None, None));
|
||||
};
|
||||
if index == 0 {
|
||||
return Ok((None, Some(Arc::clone(node))));
|
||||
}
|
||||
if index == node.len() {
|
||||
return Ok((Some(Arc::clone(node)), None));
|
||||
}
|
||||
match node.as_ref() {
|
||||
Node::Leaf { entries, .. } => Ok((
|
||||
Some(make_leaf(entries[..index].to_vec(), work)?),
|
||||
Some(make_leaf(entries[index..].to_vec(), work)?),
|
||||
)),
|
||||
Node::Branch { left, right, .. } => {
|
||||
if index < left.len() {
|
||||
let (before, after) = split_node(Some(left), index, work)?;
|
||||
Ok((before, join(after, Some(Arc::clone(right)), work)?))
|
||||
} else if index == left.len() {
|
||||
Ok((Some(Arc::clone(left)), Some(Arc::clone(right))))
|
||||
} else {
|
||||
let (before, after) = split_node(Some(right), index - left.len(), work)?;
|
||||
Ok((join(Some(Arc::clone(left)), before, work)?, after))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_node<T>(
|
||||
node: &Arc<Node<T>>,
|
||||
index: usize,
|
||||
entry: Entry<T>,
|
||||
work: &mut Work,
|
||||
) -> Result<Arc<Node<T>>, Error> {
|
||||
match node.as_ref() {
|
||||
Node::Leaf { entries, .. } => {
|
||||
let mut entries = entries.to_vec();
|
||||
entries[index] = entry;
|
||||
make_leaf(entries, work)
|
||||
}
|
||||
Node::Branch { left, right, .. } => {
|
||||
if index < left.len() {
|
||||
make_branch(
|
||||
replace_node(left, index, entry, work)?,
|
||||
Arc::clone(right),
|
||||
work,
|
||||
)
|
||||
} else {
|
||||
make_branch(
|
||||
Arc::clone(left),
|
||||
replace_node(right, index - left.len(), entry, work)?,
|
||||
work,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(value: usize) -> Entry<String> {
|
||||
let text = if value % 5 == 0 {
|
||||
format!("界{value}")
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
Entry::new(
|
||||
Arc::new(text.clone()),
|
||||
Measure::new(text.chars().count() as u64, 1, value as i64, value as i64).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn values(sequence: &Sequence<String>) -> Vec<String> {
|
||||
(0..sequence.len())
|
||||
.map(|index| sequence.get(index).unwrap().value().as_ref().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn height<T>(sequence: &Sequence<T>) -> u32 {
|
||||
sequence.root().map_or(0, |root| root.height())
|
||||
}
|
||||
|
||||
fn shared_nodes<T>(left: &Arc<Node<T>>, right: &Arc<Node<T>>) -> usize {
|
||||
if Arc::ptr_eq(left, right) {
|
||||
return 1;
|
||||
}
|
||||
match (left.as_ref(), right.as_ref()) {
|
||||
(
|
||||
Node::Branch {
|
||||
left: ll,
|
||||
right: lr,
|
||||
..
|
||||
},
|
||||
Node::Branch {
|
||||
left: rl,
|
||||
right: rr,
|
||||
..
|
||||
},
|
||||
) => shared_nodes(ll, rl) + shared_nodes(lr, rr),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_valid<T>(node: &Arc<Node<T>>) -> (usize, u32, Measure) {
|
||||
match node.as_ref() {
|
||||
Node::Leaf { entries, measure } => {
|
||||
assert!(!entries.is_empty() && entries.len() <= LEAF_CAPACITY);
|
||||
let calculated = entries
|
||||
.iter()
|
||||
.try_fold(Measure::default(), |sum, entry| {
|
||||
sum.checked_combine(entry.measure())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(*measure, calculated);
|
||||
(entries.len(), 1, calculated)
|
||||
}
|
||||
Node::Branch {
|
||||
left,
|
||||
right,
|
||||
len,
|
||||
height,
|
||||
measure,
|
||||
} => {
|
||||
let (left_len, left_height, left_measure) = assert_valid(left);
|
||||
let (right_len, right_height, right_measure) = assert_valid(right);
|
||||
assert!(left_height.abs_diff(right_height) <= 1);
|
||||
assert_eq!(*len, left_len + right_len);
|
||||
assert_eq!(*height, left_height.max(right_height) + 1);
|
||||
assert_eq!(
|
||||
*measure,
|
||||
left_measure.checked_combine(right_measure).unwrap()
|
||||
);
|
||||
(*len, *height, *measure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_unicode_measures_are_exact() {
|
||||
let empty = Sequence::<String>::empty();
|
||||
assert!(empty.is_empty());
|
||||
assert_eq!(empty.measure(), Measure::default());
|
||||
let mut work = Work::default();
|
||||
let sequence = Sequence::from_entries([entry(0), entry(12)], &mut work).unwrap();
|
||||
assert_eq!(sequence.len(), 2);
|
||||
assert_eq!(sequence.measure().chars, 4);
|
||||
assert_eq!(sequence.measure().lines, 2);
|
||||
assert_eq!(sequence.measure().width_sum, 12);
|
||||
assert_eq!(sequence.measure().width_max, 12);
|
||||
assert_eq!(sequence.prefix_measure(1).unwrap().chars, 2);
|
||||
for size in 1..=257 {
|
||||
let mut work = Work::default();
|
||||
let varied = Sequence::from_entries((0..size).map(entry), &mut work).unwrap();
|
||||
assert_eq!(varied.len(), size);
|
||||
assert_valid(varied.root().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_split_point_rejoins_exactly() {
|
||||
let mut setup = Work::default();
|
||||
let original = Sequence::from_entries((0..97).map(entry), &mut setup).unwrap();
|
||||
let oracle = values(&original);
|
||||
for index in 0..=original.len() {
|
||||
let mut work = Work::default();
|
||||
let (left, right) = original.split_at(index, &mut work).unwrap();
|
||||
assert_eq!(values(&left), oracle[..index]);
|
||||
assert_eq!(values(&right), oracle[index..]);
|
||||
let joined = left.concat(&right, &mut work).unwrap();
|
||||
assert_eq!(values(&joined), oracle);
|
||||
assert!(height(&joined) <= 8);
|
||||
assert_valid(joined.root().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_iteration_and_range_operations_match_exact_measures() {
|
||||
let mut setup = Work::default();
|
||||
let original = Sequence::from_entries((0..97).map(entry), &mut setup).unwrap();
|
||||
let mut iter = original.iter();
|
||||
for index in 0..original.len() {
|
||||
assert_eq!(iter.len(), original.len() - index);
|
||||
assert!(std::ptr::eq(
|
||||
iter.next().unwrap(),
|
||||
original.get(index).unwrap()
|
||||
));
|
||||
}
|
||||
assert!(iter.next().is_none());
|
||||
assert_eq!(iter.nodes_visited(), setup.nodes_created);
|
||||
for start in 0..=97 {
|
||||
for end in start..=97 {
|
||||
let expected = original
|
||||
.iter()
|
||||
.skip(start)
|
||||
.take(end - start)
|
||||
.try_fold(Measure::default(), |sum, entry| {
|
||||
sum.checked_combine(entry.measure())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(original.range_measure(start..end).unwrap(), expected);
|
||||
let mut work = Work::default();
|
||||
let selected = original.slice(start..end, &mut work).unwrap();
|
||||
assert_eq!(selected.measure(), expected);
|
||||
assert!(selected
|
||||
.iter()
|
||||
.zip(original.iter().skip(start))
|
||||
.all(|(a, b)| Arc::ptr_eq(a.value(), b.value())));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
original.slice(98..98, &mut Work::default()).unwrap_err(),
|
||||
Error::IndexOutOfBounds
|
||||
);
|
||||
assert_eq!(original.range_measure(98..98), Err(Error::IndexOutOfBounds));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_left_and_right_concat_remain_balanced() {
|
||||
let mut left = Sequence::empty();
|
||||
let mut right = Sequence::empty();
|
||||
let mut work = Work::default();
|
||||
for index in 0..2048 {
|
||||
let one = Sequence::from_entries([entry(index)], &mut work).unwrap();
|
||||
left = left.concat(&one, &mut work).unwrap();
|
||||
right = one.concat(&right, &mut work).unwrap();
|
||||
}
|
||||
assert_eq!(left.len(), 2048);
|
||||
assert_eq!(right.len(), 2048);
|
||||
assert!(height(&left) <= 16, "left height={}", height(&left));
|
||||
assert!(height(&right) <= 16, "right height={}", height(&right));
|
||||
assert_valid(left.root().unwrap());
|
||||
assert_valid(right.root().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_random_operations_match_vec_oracle() {
|
||||
let mut seed = 0x91e1_0da5_u64;
|
||||
let mut oracle = (0..64).collect::<Vec<_>>();
|
||||
let mut setup = Work::default();
|
||||
let mut sequence =
|
||||
Sequence::from_entries(oracle.iter().copied().map(entry), &mut setup).unwrap();
|
||||
for _ in 0..1000 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let index = (seed as usize) % oracle.len();
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let value = (seed >> 16) as usize % 10_000;
|
||||
oracle[index] = value;
|
||||
let mut work = Work::default();
|
||||
sequence = sequence.replace(index, entry(value), &mut work).unwrap();
|
||||
assert!(work.entries_written <= LEAF_CAPACITY as u64);
|
||||
}
|
||||
assert_eq!(
|
||||
values(&sequence),
|
||||
oracle
|
||||
.iter()
|
||||
.map(|value| entry(*value).value().as_ref().clone())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_valid(sequence.root().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_replace_is_logarithmic_and_preserves_old_fork() {
|
||||
for size in [32, 128, 512, 8192] {
|
||||
let mut setup = Work::default();
|
||||
let original = Sequence::from_entries((0..size).map(entry), &mut setup).unwrap();
|
||||
let fork = original.clone();
|
||||
let old_value = original.get(1).unwrap().value().clone();
|
||||
let mut work = Work::default();
|
||||
let updated = original.replace(1, entry(99_999), &mut work).unwrap();
|
||||
assert!(work.nodes_created <= u64::from(height(&original)) + 1);
|
||||
assert!(work.entries_written <= LEAF_CAPACITY as u64);
|
||||
assert!(shared_nodes(original.root().unwrap(), updated.root().unwrap()) > 0);
|
||||
assert!(Arc::ptr_eq(fork.get(1).unwrap().value(), &old_value));
|
||||
assert_eq!(fork.get(1).unwrap().value().as_str(), "1");
|
||||
assert_eq!(updated.get(1).unwrap().value().as_str(), "99999");
|
||||
assert_valid(updated.root().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_split_and_rejoin_touch_only_logarithmic_paths() {
|
||||
let mut setup = Work::default();
|
||||
let original = Sequence::from_entries((0..8192).map(entry), &mut setup).unwrap();
|
||||
let mut split_work = Work::default();
|
||||
let (left, right) = original.split_at(4097, &mut split_work).unwrap();
|
||||
assert!(split_work.nodes_created <= 32, "{split_work:?}");
|
||||
assert!(split_work.entries_written <= 32, "{split_work:?}");
|
||||
let mut concat_work = Work::default();
|
||||
let rejoined = left.concat(&right, &mut concat_work).unwrap();
|
||||
assert!(concat_work.nodes_created <= 32, "{concat_work:?}");
|
||||
assert_eq!(values(&rejoined), values(&original));
|
||||
assert_valid(rejoined.root().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_and_invalid_updates_leave_the_old_root_unchanged() {
|
||||
let huge = Entry::new(
|
||||
Arc::new("x".to_owned()),
|
||||
Measure::new(u64::MAX, 0, i64::MAX, i64::MAX).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let one = Entry::new(Arc::new("y".to_owned()), Measure::new(1, 0, 1, 1).unwrap()).unwrap();
|
||||
let mut work = Work::default();
|
||||
let base = Sequence::from_entries([huge], &mut work).unwrap();
|
||||
let old_root = Arc::clone(base.root().unwrap());
|
||||
let mut failed_work = Work::default();
|
||||
assert!(matches!(
|
||||
base.concat(
|
||||
&Sequence::from_entries([one], &mut Work::default()).unwrap(),
|
||||
&mut failed_work
|
||||
),
|
||||
Err(Error::Overflow)
|
||||
));
|
||||
assert!(Arc::ptr_eq(base.root().unwrap(), &old_root));
|
||||
assert!(matches!(
|
||||
base.replace(2, entry(2), &mut failed_work),
|
||||
Err(Error::IndexOutOfBounds)
|
||||
));
|
||||
assert!(Measure::new(0, 0, -1, 0).is_err());
|
||||
assert!(Arc::ptr_eq(base.root().unwrap(), &old_root));
|
||||
}
|
||||
}
|
||||
214
native/src/source_topology.rs
Normal file
214
native/src/source_topology.rs
Normal file
@ -0,0 +1,214 @@
|
||||
//! Immutable source-reference occurrences, independent of layout evaluation phases.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) enum LocalStep {
|
||||
BoxChild,
|
||||
RowChild(usize),
|
||||
ColumnChild(usize),
|
||||
FlexItem(usize),
|
||||
}
|
||||
|
||||
/// A local structural address stops before crossing an identified node reference.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) struct SourceAddress {
|
||||
pub(super) owner_id: u64,
|
||||
pub(super) path: Arc<[LocalStep]>,
|
||||
}
|
||||
|
||||
/// One structural occurrence can have several distinct layout evaluation uses.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) struct ParentUse {
|
||||
pub(super) use_id: u64,
|
||||
pub(super) parent: SourceAddress,
|
||||
pub(super) child_slot: LocalStep,
|
||||
pub(super) target_owner: u64,
|
||||
}
|
||||
|
||||
/// Explicit bootstrap work; excludes allocator/map internals and layout work/K.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) struct TopologyBuildWork {
|
||||
pub(super) occurrences_written: u64,
|
||||
pub(super) path_steps_copied: u64,
|
||||
pub(super) incoming_lists_frozen: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct SourceTopology {
|
||||
root_owner: u64,
|
||||
incoming: BTreeMap<u64, Box<[ParentUse]>>,
|
||||
build_work: TopologyBuildWork,
|
||||
}
|
||||
|
||||
impl SourceTopology {
|
||||
pub(super) fn root_owner(&self) -> u64 {
|
||||
self.root_owner
|
||||
}
|
||||
|
||||
/// Borrows the complete occurrence list; no owner-pair deduplication.
|
||||
pub(super) fn incoming_uses(&self, owner_id: u64) -> &[ParentUse] {
|
||||
self.incoming.get(&owner_id).map_or(&[], Box::as_ref)
|
||||
}
|
||||
|
||||
pub(super) fn build_work(&self) -> TopologyBuildWork {
|
||||
self.build_work
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the bootstrap traversal can create a topology. Scalar deltas share it.
|
||||
#[derive(Default)]
|
||||
pub(super) struct TopologyBuilder {
|
||||
incoming: BTreeMap<u64, Vec<ParentUse>>,
|
||||
work: TopologyBuildWork,
|
||||
}
|
||||
|
||||
impl TopologyBuilder {
|
||||
pub(super) fn add_use(
|
||||
&mut self,
|
||||
parent_owner: u64,
|
||||
parent_path: &[LocalStep],
|
||||
child_slot: LocalStep,
|
||||
target_owner: u64,
|
||||
) -> Result<(), String> {
|
||||
let use_id = self.work.occurrences_written;
|
||||
let occurrences_written = use_id
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "Native source topology occurrence count overflow".to_owned())?;
|
||||
let path_steps_copied = self
|
||||
.work
|
||||
.path_steps_copied
|
||||
.checked_add(parent_path.len() as u64)
|
||||
.ok_or_else(|| "Native source topology path count overflow".to_owned())?;
|
||||
self.incoming
|
||||
.entry(target_owner)
|
||||
.or_default()
|
||||
.push(ParentUse {
|
||||
use_id,
|
||||
parent: SourceAddress {
|
||||
owner_id: parent_owner,
|
||||
path: Arc::from(parent_path),
|
||||
},
|
||||
child_slot,
|
||||
target_owner,
|
||||
});
|
||||
self.work.occurrences_written = occurrences_written;
|
||||
self.work.path_steps_copied = path_steps_copied;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The caller has already validated owner uniqueness and rejected NodeRef input.
|
||||
pub(super) fn finish(self, root_owner: u64) -> Arc<SourceTopology> {
|
||||
let mut work = self.work;
|
||||
let incoming = self
|
||||
.incoming
|
||||
.into_iter()
|
||||
.map(|(owner, uses)| {
|
||||
work.incoming_lists_frozen += 1;
|
||||
(owner, uses.into_boxed_slice())
|
||||
})
|
||||
.collect();
|
||||
Arc::new(SourceTopology {
|
||||
root_owner,
|
||||
incoming,
|
||||
build_work: work,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn anonymous_paths_and_structural_slots_remain_distinct() {
|
||||
let mut builder = TopologyBuilder::default();
|
||||
builder
|
||||
.add_use(11, &[LocalStep::BoxChild], LocalStep::FlexItem(3), 29)
|
||||
.unwrap();
|
||||
builder
|
||||
.add_use(
|
||||
29,
|
||||
&[LocalStep::BoxChild, LocalStep::RowChild(2)],
|
||||
LocalStep::ColumnChild(1),
|
||||
41,
|
||||
)
|
||||
.unwrap();
|
||||
let topology = builder.finish(11);
|
||||
assert_eq!(topology.root_owner(), 11);
|
||||
assert!(topology.incoming_uses(11).is_empty());
|
||||
assert!(topology.incoming_uses(999).is_empty());
|
||||
let first = &topology.incoming_uses(29)[0];
|
||||
assert_eq!(first.use_id, 0);
|
||||
assert_eq!(first.parent.owner_id, 11);
|
||||
assert_eq!(first.parent.path.as_ref(), &[LocalStep::BoxChild]);
|
||||
assert_eq!(first.child_slot, LocalStep::FlexItem(3));
|
||||
assert_eq!(first.target_owner, 29);
|
||||
let second = &topology.incoming_uses(41)[0];
|
||||
assert_eq!(second.parent.owner_id, 29);
|
||||
assert_eq!(second.use_id, 1);
|
||||
assert_eq!(
|
||||
second.parent.path.as_ref(),
|
||||
&[LocalStep::BoxChild, LocalStep::RowChild(2)]
|
||||
);
|
||||
assert_eq!(second.child_slot, LocalStep::ColumnChild(1));
|
||||
assert_eq!(
|
||||
topology.build_work(),
|
||||
TopologyBuildWork {
|
||||
occurrences_written: 2,
|
||||
path_steps_copied: 3,
|
||||
incoming_lists_frozen: 2,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_occurrence_is_retained_even_for_the_same_owner_pair() {
|
||||
// The storage must not silently deduplicate occurrences. This does not
|
||||
// authorize duplicate identified owners in the public bootstrap input.
|
||||
let mut builder = TopologyBuilder::default();
|
||||
for slot in [2, 7, 2] {
|
||||
builder
|
||||
.add_use(1, &[], LocalStep::RowChild(slot), 9)
|
||||
.unwrap();
|
||||
}
|
||||
let topology = builder.finish(1);
|
||||
let uses = topology.incoming_uses(9);
|
||||
assert_eq!(uses.len(), 3);
|
||||
assert_eq!(
|
||||
uses.iter().map(|usage| usage.use_id).collect::<Vec<_>>(),
|
||||
[0, 1, 2]
|
||||
);
|
||||
assert_eq!(uses[0].child_slot, uses[2].child_slot);
|
||||
assert_ne!(uses[0].use_id, uses[2].use_id);
|
||||
assert_eq!(topology.build_work().incoming_lists_frozen, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forks_borrow_the_same_lists_without_changing_build_work() {
|
||||
for n in [32, 128, 512] {
|
||||
let mut builder = TopologyBuilder::default();
|
||||
for index in 0..n {
|
||||
builder
|
||||
.add_use(1, &[], LocalStep::ColumnChild(index), index as u64 + 2)
|
||||
.unwrap();
|
||||
}
|
||||
let base = builder.finish(1);
|
||||
let original_work = base.build_work();
|
||||
let left = Arc::clone(&base);
|
||||
let right = Arc::clone(&base);
|
||||
let target = (n / 2 + 2) as u64;
|
||||
assert!(Arc::ptr_eq(&base, &left));
|
||||
assert!(Arc::ptr_eq(&left, &right));
|
||||
assert!(std::ptr::eq(
|
||||
base.incoming_uses(target),
|
||||
right.incoming_uses(target)
|
||||
));
|
||||
assert_eq!(base.build_work(), original_work);
|
||||
assert_eq!(original_work.occurrences_written, n as u64);
|
||||
assert_eq!(original_work.path_steps_copied, 0);
|
||||
assert_eq!(original_work.incoming_lists_frozen, n as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
162
native/src/source_topology_tests.rs
Normal file
162
native/src/source_topology_tests.rs
Normal file
@ -0,0 +1,162 @@
|
||||
// Included in layout::tests after the production bootstrap topology is attached.
|
||||
|
||||
#[test]
|
||||
fn retained_topology_bootstrap_records_exact_anonymous_use_paths() {
|
||||
use source_topology::LocalStep;
|
||||
let leaf = |id| {
|
||||
identified(
|
||||
text_box(
|
||||
id as i64,
|
||||
measured_text(vec![vec![cluster("x", 1, None)]]),
|
||||
None,
|
||||
),
|
||||
id,
|
||||
1,
|
||||
)
|
||||
};
|
||||
let nested_owner = identified(child_box(3, child_box(30, leaf(4), None), None), 3, 1);
|
||||
let root = identified(
|
||||
child_box(
|
||||
1,
|
||||
LayoutNode::Column {
|
||||
node_id: None,
|
||||
node_revision: None,
|
||||
children: Arc::new(vec![
|
||||
LayoutNode::Row {
|
||||
node_id: None,
|
||||
node_revision: None,
|
||||
children: Arc::new(vec![
|
||||
text_box(20, measured_text(vec![vec![cluster("a", 1, None)]]), None),
|
||||
leaf(2),
|
||||
]),
|
||||
},
|
||||
context_test_flex(Size::Auto, vec![nested_owner]),
|
||||
]),
|
||||
},
|
||||
None,
|
||||
),
|
||||
1,
|
||||
1,
|
||||
);
|
||||
let full = retained_document(root);
|
||||
let (retained, _) = RetainedDocument::bootstrap(full.clone()).unwrap();
|
||||
let topology = &retained.topology;
|
||||
assert_eq!(topology.root_owner(), 1);
|
||||
assert!(topology.incoming_uses(1).is_empty());
|
||||
let cases = [
|
||||
(
|
||||
2,
|
||||
1,
|
||||
vec![LocalStep::BoxChild, LocalStep::ColumnChild(0)],
|
||||
LocalStep::RowChild(1),
|
||||
),
|
||||
(
|
||||
3,
|
||||
1,
|
||||
vec![LocalStep::BoxChild, LocalStep::ColumnChild(1)],
|
||||
LocalStep::FlexItem(0),
|
||||
),
|
||||
(4, 3, vec![LocalStep::BoxChild], LocalStep::BoxChild),
|
||||
];
|
||||
let mut use_ids = HashSet::new();
|
||||
for (target, parent, path, slot) in cases {
|
||||
let uses = topology.incoming_uses(target);
|
||||
assert_eq!(uses.len(), 1);
|
||||
assert_eq!(uses[0].target_owner, target);
|
||||
assert_eq!(uses[0].parent.owner_id, parent);
|
||||
assert_eq!(uses[0].parent.path.as_ref(), path.as_slice());
|
||||
assert_eq!(uses[0].child_slot, slot);
|
||||
assert!(use_ids.insert(uses[0].use_id));
|
||||
}
|
||||
assert_eq!(topology.build_work().occurrences_written, 3);
|
||||
assert_eq!(topology.build_work().path_steps_copied, 5);
|
||||
assert_eq!(
|
||||
retained.layout_tape(test_context(), None).unwrap(),
|
||||
full.layout_tape(test_context(), None).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_topology_real_deltas_share_lists_across_forks_and_failures() {
|
||||
for n in [32, 128, 512] {
|
||||
let children = (0..n)
|
||||
.map(|i| {
|
||||
identified(
|
||||
text_box(
|
||||
i + 2,
|
||||
measured_text(vec![vec![cluster("x", 1, None)]]),
|
||||
None,
|
||||
),
|
||||
(i + 2) as u64,
|
||||
1,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let root = identified(
|
||||
LayoutNode::Column {
|
||||
node_id: None,
|
||||
node_revision: None,
|
||||
children: Arc::new(children),
|
||||
},
|
||||
1,
|
||||
1,
|
||||
);
|
||||
let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap();
|
||||
let target_id = (n / 2 + 2) as u64;
|
||||
let original = Arc::clone(&base.topology);
|
||||
let original_list = original.incoming_uses(target_id);
|
||||
let patch = |region| {
|
||||
context_test_delta(vec![serde_json::json!({
|
||||
"node-id": target_id, "expected-revision": 1, "target-revision": 2,
|
||||
"slot-patches": [{"slot": 0, "local": {"region-id": region}}]
|
||||
})])
|
||||
};
|
||||
let left = base.apply_delta(patch(100_000)).unwrap().document;
|
||||
let right = base.apply_delta(patch(100_001)).unwrap().document;
|
||||
for target in [&base, &left, &right] {
|
||||
assert!(Arc::ptr_eq(&original, &target.topology));
|
||||
assert!(std::ptr::eq(
|
||||
original_list,
|
||||
target.topology.incoming_uses(target_id)
|
||||
));
|
||||
assert_eq!(target.topology.root_owner(), 1);
|
||||
}
|
||||
assert_eq!(original.incoming_uses(target_id)[0].target_owner, target_id);
|
||||
assert_eq!(original.build_work().occurrences_written, n as u64);
|
||||
// Later-entry rejection cannot install a partially updated topology.
|
||||
let failed = base.apply_delta(context_test_delta(vec![
|
||||
serde_json::json!({"node-id": target_id, "expected-revision": 1, "target-revision": 2}),
|
||||
serde_json::json!({"node-id": 999_999, "expected-revision": 1, "target-revision": 2}),
|
||||
]));
|
||||
assert!(failed.is_err());
|
||||
assert!(Arc::ptr_eq(&base.topology, &original));
|
||||
assert_eq!(radix_lookup(&base.entries, target_id).unwrap().revision, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_topology_identity_is_local_to_one_validated_bootstrap() {
|
||||
let leaf = identified(
|
||||
text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None),
|
||||
1,
|
||||
1,
|
||||
);
|
||||
let full = retained_document(leaf.clone());
|
||||
let (first, _) = RetainedDocument::bootstrap(full.clone()).unwrap();
|
||||
let (second, _) = RetainedDocument::bootstrap(full).unwrap();
|
||||
assert!(!Arc::ptr_eq(&first.topology, &second.topology));
|
||||
assert!(first.topology.incoming_uses(1).is_empty());
|
||||
assert_eq!(first.topology.build_work().occurrences_written, 0);
|
||||
let duplicate = identified(
|
||||
LayoutNode::Row {
|
||||
node_id: None,
|
||||
node_revision: None,
|
||||
children: Arc::new(vec![leaf.clone(), leaf]),
|
||||
},
|
||||
2,
|
||||
1,
|
||||
);
|
||||
assert!(RetainedDocument::bootstrap(retained_document(duplicate)).is_err());
|
||||
let external = identified(child_box(2, LayoutNode::NodeRef { node_id: 1 }, None), 2, 1);
|
||||
assert!(RetainedDocument::bootstrap(retained_document(external)).is_err());
|
||||
}
|
||||
19
release-dependencies.json
Normal file
19
release-dependencies.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"dependencies": {
|
||||
"tp": {
|
||||
"url": "https://gitea.gklazycat.heiyu.space/geekinney/tp.git",
|
||||
"revision": "0a820bd0cb9cf227f08c3776f9cbe1d4c302d196"
|
||||
},
|
||||
"ecss": {
|
||||
"url": "https://gitea.gklazycat.heiyu.space/geekinney/ecss.git",
|
||||
"revision": "13559a41f095b4f74acab67e589ad8bd8c21a248"
|
||||
}
|
||||
},
|
||||
"optional": {
|
||||
"ekp": {
|
||||
"url": "https://github.com/Kinneyzhang/emacs-kp.git",
|
||||
"revision": "578540549511e352696c430338afb0c98522cd2f"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -59,6 +59,13 @@
|
||||
tp-surface-mount
|
||||
tp-surface-update
|
||||
tp-surface-update-scoped
|
||||
tp--prepare-surface
|
||||
tp--publish-buffer-content
|
||||
tp--publish-one-surface
|
||||
tp--capture-property-journals
|
||||
tp--surface-state-snapshots
|
||||
tp--validate-retained-batch-precommit
|
||||
tp--validate-surface-precommit
|
||||
ebox-surface--publish-runtime-state))
|
||||
"Functions grouped by computation or publication stage.")
|
||||
|
||||
@ -299,8 +306,8 @@
|
||||
(append
|
||||
(list 'box
|
||||
:key (format "cell-%d-%d" row column)
|
||||
:width '(52)
|
||||
:padding '(0 1)
|
||||
:width '(px 52)
|
||||
:padding '((lh 0) (ch 1))
|
||||
:color "#0F172A"
|
||||
:bgcolor "#F8FAFC")
|
||||
(when (and (= row 2) (= column 1)) '(:id target))
|
||||
@ -320,8 +327,8 @@
|
||||
(push
|
||||
(list 'box
|
||||
:key (format "resize-item-%d" index)
|
||||
:width '(70)
|
||||
:padding '(0 1)
|
||||
:width '(px 70)
|
||||
:padding '((lh 0) (ch 1))
|
||||
:color "#172554"
|
||||
:bgcolor "#DBEAFE"
|
||||
(format "resize-%02d" index))
|
||||
@ -329,10 +336,10 @@
|
||||
(ebox-build
|
||||
(append (list 'flex
|
||||
:key 'resize-root
|
||||
:width '(viewport)
|
||||
:width '(vw 100)
|
||||
:flex-wrap 'wrap
|
||||
:column-gap '(6)
|
||||
:row-gap 1)
|
||||
:column-gap '(px 6)
|
||||
:row-gap '(lh 1))
|
||||
(nreverse items)))))
|
||||
|
||||
(defun ebox-performance-evaluator--pure-static ()
|
||||
|
||||
145
scripts/ebox-release-smoke.el
Normal file
145
scripts/ebox-release-smoke.el
Normal file
@ -0,0 +1,145 @@
|
||||
;;; ebox-release-smoke.el --- Isolated package installation smoke -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Invoked by scripts/ebox-release.py verify in a disposable user directory.
|
||||
;; No source checkout or sibling package may enter this process's load-path.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'json)
|
||||
(require 'package)
|
||||
(require 'bytecomp)
|
||||
|
||||
(let* ((archive (getenv "EBOX_RELEASE_ARCHIVE"))
|
||||
(profile (getenv "EBOX_RELEASE_PROFILE"))
|
||||
(native (getenv "EBOX_RELEASE_NATIVE"))
|
||||
(build-native (equal (getenv "EBOX_RELEASE_BUILD_NATIVE") "1"))
|
||||
(manifest (with-temp-buffer
|
||||
(insert-file-contents (expand-file-name "manifest.json" archive))
|
||||
(json-parse-buffer :object-type 'alist :array-type 'list)))
|
||||
(packages (alist-get 'packages manifest)))
|
||||
(unless (and archive profile)
|
||||
(error "Run through the release verification entry point"))
|
||||
(setq user-emacs-directory (file-name-as-directory profile)
|
||||
package-user-dir (expand-file-name "elpa" profile)
|
||||
package-archives (list (cons "ebox-release" (file-name-as-directory archive)))
|
||||
package-check-signature nil
|
||||
package-directory-list nil
|
||||
native-comp-jit-compilation nil)
|
||||
(package-initialize)
|
||||
(package-refresh-contents)
|
||||
(let* (compile-issues
|
||||
(capture-warning
|
||||
(lambda (message &rest _)
|
||||
(when (and byte-compile-current-file
|
||||
(string-match-p "\\`ebox.*\\.el\\'"
|
||||
(file-name-nondirectory byte-compile-current-file))
|
||||
;; package.el owns generated descriptor/autoload compilation.
|
||||
(not (string-match-p "-\\(?:pkg\\|autoloads\\)\\.el\\'"
|
||||
byte-compile-current-file)))
|
||||
(push (format "%s: %s" (file-name-nondirectory byte-compile-current-file)
|
||||
message) compile-issues)))))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(advice-add 'byte-compile-log-warning :before capture-warning)
|
||||
(package-install 'ebox)
|
||||
(when (seq-some (lambda (entry) (equal (alist-get 'name entry) "ekp")) packages)
|
||||
(package-install 'ekp)))
|
||||
(advice-remove 'byte-compile-log-warning capture-warning))
|
||||
(when compile-issues
|
||||
(error "Installed Ebox compilation diagnostics:\n%s"
|
||||
(mapconcat #'identity (nreverse compile-issues) "\n"))))
|
||||
(dolist (command '(ebox-build ebox-render ebox-next-interaction ebox-previous-interaction))
|
||||
(unless (fboundp command)
|
||||
(error "Installed autoload registry omitted %s" command)))
|
||||
(require 'ebox)
|
||||
(dolist (feature '(tp ecss ebox))
|
||||
(let ((library (locate-library (symbol-name feature))))
|
||||
(unless (and (featurep feature) library
|
||||
(file-in-directory-p library package-user-dir))
|
||||
(error "Package escaped the clean installation: %s" feature))))
|
||||
(when (featurep 'etaf)
|
||||
(error "Standalone installation unexpectedly loaded ETAF"))
|
||||
(when (featurep 'ebox-native-reflow)
|
||||
(error "Source installation eagerly loaded native subsystem"))
|
||||
(let* ((input (ebox-build '(box :width (ch 10) :height (lh 1) :overflow scroll
|
||||
"first\nsecond\nthird")))
|
||||
(region-counter ebox--region-id-counter)
|
||||
(node-counter ebox--runtime-node-id-counter)
|
||||
(scroll-count (hash-table-count ebox--scroll-global-state)))
|
||||
(dotimes (_ 3) (ebox-render input))
|
||||
(unless (and (= region-counter ebox--region-id-counter)
|
||||
(= node-counter ebox--runtime-node-id-counter)
|
||||
(= scroll-count (hash-table-count ebox--scroll-global-state)))
|
||||
(error "Installed ephemeral rendering leaked live identity or scroll state")))
|
||||
(when build-native
|
||||
(let ((process (ebox-native-build))
|
||||
(deadline (+ (float-time) 480)))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(while (and (process-live-p process) (< (float-time) deadline))
|
||||
(accept-process-output process 0.1))
|
||||
(when (process-live-p process)
|
||||
(error "Installed native build timed out"))
|
||||
(unless (and (zerop (process-exit-status process))
|
||||
(ebox-native-reflow-layout-ready-p))
|
||||
(error "Installed native build failed: %s"
|
||||
(with-current-buffer (process-buffer process) (buffer-string))))
|
||||
(setq native (plist-get (ebox-native-reflow-runtime-report) :loaded-module-path)))
|
||||
(when (process-live-p process) (delete-process process)))))
|
||||
(when (and native (not (string-empty-p native)))
|
||||
(require 'ebox-native-reflow)
|
||||
(setq ebox-native-reflow-module-path native)
|
||||
(unless (ebox-native-reflow-layout-ready-p)
|
||||
(error "Installed Ebox could not load native module: %S"
|
||||
(ebox-native-reflow-runtime-report))))
|
||||
(let ((buffer (generate-new-buffer " *ebox-release-smoke*")) stale-handle)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(ebox-render-to-buffer
|
||||
buffer (ebox-build '(column
|
||||
(box :id "counter" :class "value" "before")
|
||||
(box "unchanged"))))
|
||||
(with-current-buffer buffer
|
||||
(setq stale-handle (ebox-region-resolve buffer "counter"))
|
||||
(dotimes (index 30)
|
||||
(ebox-region-update stale-handle :content (format "after %02d" index)))
|
||||
(unless (and (string-match-p "after" (buffer-string))
|
||||
(string-match-p "unchanged" (buffer-string))
|
||||
(not (string-match-p "before" (buffer-string)))
|
||||
(= 1 (length (ebox-selector-query-buffer buffer ".value"))))
|
||||
(error "Installed render/update/selector smoke failed"))))
|
||||
(kill-buffer buffer))
|
||||
(condition-case nil
|
||||
(progn (ebox-region-update stale-handle :content "invalid")
|
||||
(error "Installed stale handle accepted an update after unmount"))
|
||||
(user-error nil)))
|
||||
(if (assoc "ekp" (mapcar (lambda (entry) (cons (alist-get 'name entry) entry)) packages))
|
||||
(let ((calls 0))
|
||||
(require 'ekp)
|
||||
(let ((probe (lambda (&rest _) (cl-incf calls))))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(advice-add 'ekp-pixel-justify :before probe)
|
||||
(ebox-render (ebox-build '(box :width (ch 12) :wrap-mode kp
|
||||
"one two three four five six seven")))
|
||||
(unless (> calls 0)
|
||||
(error "KP installation did not invoke the real EKP provider")))
|
||||
(advice-remove 'ekp-pixel-justify probe))))
|
||||
(when (locate-library "ekp")
|
||||
(error "Default installation unexpectedly found optional EKP")))
|
||||
(when (and native (not (string-empty-p native)))
|
||||
(let* ((input (ebox-build '(box :width (ch 8) "native")))
|
||||
(node (ebox-canonical-input--single-root input "Release smoke"))
|
||||
(result (ebox-native-reflow-execute-sync
|
||||
node '(:key 1 :viewport-width 80 :viewport-height 20
|
||||
:runtime-revision 0 :context-hash 0 :complete t))))
|
||||
(unless (and (plist-get result :native-frame)
|
||||
(string-match-p "native" (plist-get result :rendered)))
|
||||
(error "Installed native module did not produce a native render frame"))))
|
||||
(princ (format "Clean installation OK: Emacs %s; render, update, selector; EKP %s; native %s\n"
|
||||
emacs-version (if (featurep 'ekp) "verified" "absent")
|
||||
(if (and native (not (string-empty-p native))) "verified" "not requested"))))
|
||||
|
||||
;;; ebox-release-smoke.el ends here
|
||||
239
scripts/ebox-release.py
Normal file
239
scripts/ebox-release.py
Normal file
@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build deterministic package.el archives and verify clean-room installation.
|
||||
|
||||
Use `make release-check RELEASE_DIR=/tmp/ebox-release EMACS=/path/to/emacs`.
|
||||
Only the explicit output directory is retained; subprocesses have timeouts and
|
||||
temporary checkouts/installations are removed on success, error, or interruption.
|
||||
No command publishes, changes a source checkout, or installs into the user profile.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def run(args, cwd=None, env=None, timeout=180):
|
||||
"""Run a bounded subprocess; include its output on failure."""
|
||||
process = subprocess.Popen(args, cwd=cwd, env=env, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True,
|
||||
start_new_session=(os.name != "nt"))
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout)
|
||||
except (subprocess.TimeoutExpired, KeyboardInterrupt):
|
||||
if os.name == "nt":
|
||||
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||
capture_output=True, timeout=20, check=False)
|
||||
else:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.communicate()
|
||||
raise
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"Command failed: {args[0]}\n{stdout}{stderr}")
|
||||
return stdout
|
||||
|
||||
|
||||
def lock_entries(lock, with_ekp=False):
|
||||
"""Validate and select required dependencies and the optional KP provider."""
|
||||
if lock.get("schema") != 1 or set(lock.get("dependencies", {})) != {"tp", "ecss"}:
|
||||
raise ValueError("Expected schema 1 with pinned tp and ecss dependencies")
|
||||
entries = dict(lock["dependencies"])
|
||||
if with_ekp:
|
||||
entries["ekp"] = lock["optional"]["ekp"]
|
||||
for name, entry in entries.items():
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", entry["revision"]):
|
||||
raise ValueError(f"{name}: revision must be a full commit hash")
|
||||
if not entry["url"].startswith("https://"):
|
||||
raise ValueError(f"{name}: public dependency URL must use HTTPS")
|
||||
return entries
|
||||
|
||||
|
||||
def checkout_dependencies(directory, entries):
|
||||
"""Fetch exact commits into a new directory; never replace a checkout."""
|
||||
if directory.exists():
|
||||
raise ValueError(f"Refusing to replace existing dependency directory: {directory}")
|
||||
directory.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=".ebox-deps-", dir=directory.parent) as work:
|
||||
stage = Path(work) / "deps"
|
||||
stage.mkdir()
|
||||
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
||||
for name, entry in entries.items():
|
||||
target = stage / name
|
||||
run(["git", "init", "-q", str(target)])
|
||||
run(["git", "fetch", "--quiet", "--depth", "1", entry["url"],
|
||||
entry["revision"]], cwd=target, env=env)
|
||||
run(["git", "checkout", "--quiet", "--detach", "FETCH_HEAD"], cwd=target)
|
||||
if run(["git", "rev-parse", "HEAD"], cwd=target).strip() != entry["revision"]:
|
||||
raise ValueError(f"{name}: fetched revision mismatch")
|
||||
stage.rename(directory)
|
||||
|
||||
|
||||
def validate_dependencies(directory, entries):
|
||||
"""Reject stale or modified explicit dependency checkouts."""
|
||||
for name, entry in entries.items():
|
||||
target = directory / name
|
||||
revision = run(["git", "rev-parse", "HEAD"], cwd=target).strip()
|
||||
dirty = run(["git", "status", "--porcelain", "--untracked-files=all"], cwd=target)
|
||||
if revision != entry["revision"] or dirty:
|
||||
raise ValueError(f"{name}: dependency must be clean at {entry['revision']}")
|
||||
|
||||
|
||||
def package_metadata(root, name):
|
||||
"""Read package.el metadata from the package's main Lisp header."""
|
||||
source = (root / f"{name}.el").read_text()
|
||||
version = re.search(r"^;; Version: ([0-9]+(?:\.[0-9]+)*)$", source, re.M)
|
||||
requirements = re.search(r"^;; Package-Requires: (.+)$", source, re.M)
|
||||
if not version or not requirements:
|
||||
raise ValueError(f"{name}: missing supported version or requirements header")
|
||||
return version[1], requirements[1]
|
||||
|
||||
|
||||
def package_files(root, name):
|
||||
"""Select source inputs, documentation and licenses, excluding build output."""
|
||||
result = {}
|
||||
candidates = run(["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=root)
|
||||
for filename in sorted(set(candidates.split("\0")) - {""}):
|
||||
path = root / filename
|
||||
relative = path.relative_to(root)
|
||||
parts = relative.parts
|
||||
if (not path.is_file() or path.is_symlink()
|
||||
or any(part.startswith(".") or part in ("target", "__pycache__")
|
||||
for part in parts)
|
||||
or path.suffix in (".elc", ".eln", ".so", ".dylib", ".dll", ".o", ".a")):
|
||||
continue
|
||||
top_file = len(parts) == 1
|
||||
include = (top_file and (path.suffix == ".el" or
|
||||
path.name.startswith(("README", "LICENSE", "COPYING", "NOTICE", "CHANGELOG"))))
|
||||
include = include or (name == "ebox" and parts[0] in ("native", "docs"))
|
||||
include = include or (name == "ekp" and parts[0] in ("dictionaries", "ekp_c"))
|
||||
if include and not path.name.endswith("-pkg.el"):
|
||||
result[relative.as_posix()] = path.read_bytes()
|
||||
if f"{name}.el" not in result:
|
||||
raise ValueError(f"{name}: package entry missing")
|
||||
return result
|
||||
|
||||
|
||||
def write_package(output, root, name):
|
||||
"""Write a deterministic uncompressed package.el tar archive."""
|
||||
version, requirements = package_metadata(root, name)
|
||||
files = package_files(root, name)
|
||||
files[f"{name}-pkg.el"] = (
|
||||
f';;; {name}-pkg.el --- Package metadata -*- lexical-binding: t; -*-\n'
|
||||
f'(define-package "{name}" "{version}" "{name} source package" '
|
||||
f"'{requirements})\n").encode()
|
||||
archive = output / f"{name}-{version}.tar"
|
||||
with tarfile.open(archive, "w", format=tarfile.USTAR_FORMAT) as stream:
|
||||
for relative, content in sorted(files.items()):
|
||||
info = tarfile.TarInfo(f"{name}-{version}/{relative}")
|
||||
info.size, info.mode, info.mtime = len(content), 0o644, 0
|
||||
info.uid = info.gid = 0
|
||||
stream.addfile(info, io.BytesIO(content))
|
||||
return {"name": name, "version": version, "requirements": requirements,
|
||||
"file": archive.name, "sha256": hashlib.sha256(archive.read_bytes()).hexdigest()}
|
||||
|
||||
|
||||
def build(output, root, dependencies, entries):
|
||||
"""Atomically publish a local archive directory after all packages build."""
|
||||
if output.exists():
|
||||
raise ValueError(f"Refusing to replace existing release directory: {output}")
|
||||
validate_dependencies(dependencies, entries)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=".ebox-release-", dir=output.parent) as work:
|
||||
stage = Path(work) / "archive"
|
||||
stage.mkdir()
|
||||
packages = [write_package(stage, dependencies / name, name) for name in entries]
|
||||
packages.append(write_package(stage, root, "ebox"))
|
||||
manifest = {"schema": 1, "dependencies": entries, "packages": packages,
|
||||
"source_revision": run(["git", "rev-parse", "HEAD"], cwd=root).strip(),
|
||||
"source_dirty": bool(run(["git", "status", "--porcelain"], cwd=root))}
|
||||
(stage / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
||||
records = []
|
||||
for package in packages:
|
||||
version_list = "(" + package["version"].replace(".", " ") + ")"
|
||||
requirements = re.sub(r'"([0-9]+(?:\.[0-9]+)*)"',
|
||||
lambda match: "(" + match[1].replace(".", " ") + ")",
|
||||
package["requirements"])
|
||||
records.append(f' ({package["name"]} . [{version_list} '
|
||||
f'{requirements} "{package["name"]} source package" tar])')
|
||||
(stage / "archive-contents").write_text("(1\n" + "\n".join(records) + "\n)\n")
|
||||
stage.rename(output)
|
||||
return manifest
|
||||
|
||||
|
||||
def verify(directory, emacs, native_module=None, build_native=False):
|
||||
"""Install source archives in a disposable package-user-dir and exercise Ebox."""
|
||||
manifest = json.loads((directory / "manifest.json").read_text())
|
||||
if native_module and not native_module.is_file():
|
||||
raise ValueError(f"Native module does not exist: {native_module}")
|
||||
for package in manifest["packages"]:
|
||||
file = directory / package["file"]
|
||||
if file.parent != directory or not file.is_file():
|
||||
raise ValueError("Invalid archive filename")
|
||||
if hashlib.sha256(file.read_bytes()).hexdigest() != package["sha256"]:
|
||||
raise ValueError(f"Archive checksum mismatch: {file.name}")
|
||||
with tempfile.TemporaryDirectory(prefix="ebox-install-") as work:
|
||||
env = dict(os.environ, EBOX_RELEASE_ARCHIVE=str(directory),
|
||||
EBOX_RELEASE_PROFILE=work, EBOX_RELEASE_NATIVE=str(native_module or ""),
|
||||
EBOX_RELEASE_BUILD_NATIVE="1" if build_native else "")
|
||||
# No repository or sibling load paths, HOME changes, network archive refresh,
|
||||
# or user package installation. package.el compiles only this temp profile.
|
||||
result = run([emacs, "-Q", "--batch", "-l", str(ROOT / "scripts/ebox-release-smoke.el")],
|
||||
cwd=work, env=env, timeout=600)
|
||||
print(result.strip())
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=("deps", "build", "verify"))
|
||||
parser.add_argument("--directory", type=Path, required=True,
|
||||
help="New deps/build output directory, or archive to verify")
|
||||
parser.add_argument("--dependencies", type=Path, help="Explicit pinned dependency checkout directory")
|
||||
parser.add_argument("--with-ekp", action="store_true", help="Include optional Knuth-Plass provider")
|
||||
parser.add_argument("--emacs", default="emacs", help="Emacs executable for verify")
|
||||
parser.add_argument("--native-module", type=Path, help="Require loading and executing this native module")
|
||||
parser.add_argument("--build-native", action="store_true",
|
||||
help="Build, install and execute bundled native source in the temporary profile")
|
||||
args = parser.parse_args()
|
||||
directory = args.directory.resolve()
|
||||
entries = lock_entries(json.loads((ROOT / "release-dependencies.json").read_text()), args.with_ekp)
|
||||
if args.command == "deps":
|
||||
checkout_dependencies(directory, entries)
|
||||
print(f"Pinned dependencies ready: {directory}")
|
||||
elif args.command == "build":
|
||||
if args.dependencies:
|
||||
manifest = build(directory, ROOT, args.dependencies.resolve(), entries)
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(prefix="ebox-release-deps-") as work:
|
||||
dependencies = Path(work) / "deps"
|
||||
checkout_dependencies(dependencies, entries)
|
||||
manifest = build(directory, ROOT, dependencies, entries)
|
||||
print(json.dumps({"archive": str(directory), "packages": manifest["packages"]}, indent=2))
|
||||
else:
|
||||
if args.native_module and args.build_native:
|
||||
parser.error("Choose --native-module or --build-native, not both")
|
||||
verify(directory, args.emacs, args.native_module.resolve() if args.native_module else None,
|
||||
args.build_native)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as error:
|
||||
print(f"release verification failed: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print("release operation interrupted; temporary resources removed", file=sys.stderr)
|
||||
sys.exit(130)
|
||||
@ -30,25 +30,25 @@
|
||||
"Return the fixed layout used by visual verification."
|
||||
(ebox-build
|
||||
'(column :id dashboard
|
||||
(box :id title :box-sizing border-box :width 74
|
||||
:padding (0 1) :border "#4A90D9"
|
||||
(box :id title :box-sizing border-box :width (ch 74)
|
||||
:padding ((lh 0) (ch 1)) :border "#4A90D9"
|
||||
:color "#EAF6FF" :bgcolor "#203040"
|
||||
"Ebox Visual Check")
|
||||
(row :id cards
|
||||
(box :id hidden :box-sizing border-box :width 24 :height 3
|
||||
:padding (1 1) :border "#27AE60"
|
||||
(box :id hidden :box-sizing border-box :width (ch 24) :height (lh 3)
|
||||
:padding ((lh 1) (ch 1)) :border "#27AE60"
|
||||
:color "#F0FFF4" :bgcolor "#17351F" :overflow hidden
|
||||
"Hidden: visible line\nHIDDEN-CLIPPED-LINE")
|
||||
(box :id scroll :box-sizing border-box :width 24 :height 3
|
||||
:padding (1 1) :border "#E67E22"
|
||||
(box :id scroll :box-sizing border-box :width (ch 24) :height (lh 3)
|
||||
:padding ((lh 1) (ch 1)) :border "#E67E22"
|
||||
:color "#FFF4E6" :bgcolor "#3A2410" :overflow scroll
|
||||
"Scroll: first\nSCROLL-SECOND\nSCROLL-THIRD")
|
||||
(box :id sized :box-sizing border-box :width 26 :max-height 3
|
||||
:padding (1 1) :border "#9B59B6"
|
||||
(box :id sized :box-sizing border-box :width (ch 26) :max-height (lh 3)
|
||||
:padding ((lh 1) (ch 1)) :border "#9B59B6"
|
||||
:color "#F8EEFF" :bgcolor "#2F1B3A" :overflow hidden
|
||||
"border-box max-height\nline 2\nSIZED-CLIPPED"))
|
||||
(box :id footer :box-sizing border-box :width 74
|
||||
:padding (0 1) :border "#7F8C8D"
|
||||
(box :id footer :box-sizing border-box :width (ch 74)
|
||||
:padding ((lh 0) (ch 1)) :border "#7F8C8D"
|
||||
:color "#F8F9F9" :bgcolor "#2C3E50"
|
||||
"Checks: text properties + display specs + GUI screenshot"))))
|
||||
|
||||
|
||||
426
scripts/migrate-css-sizes.py
Normal file
426
scripts/migrate-css-sizes.py
Normal file
@ -0,0 +1,426 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate explicit Elisp paths to Ebox CSS sizes without evaluating their code.
|
||||
|
||||
Dry-run: python3 scripts/migrate-css-sizes.py ../ebox-playground/examples
|
||||
Apply: python3 scripts/migrate-css-sizes.py --write path/to/example.ebox
|
||||
Scoped: python3 scripts/migrate-css-sizes.py --callers ebox-build,ebox-create tests
|
||||
Tests: python3 -m unittest discover -s scripts -p 'test_migrate_css_sizes.py'
|
||||
|
||||
Directories include .el/.ebox/.etaf/.ecss files and skip generated, Git, and historical
|
||||
trees. Comments, strings, reader quoting, and whitespace outside replaced
|
||||
values are preserved. This is a property migration, not a type checker: pass
|
||||
public style/DSL callers, not engine structs whose similarly named fields are
|
||||
already measured sizes. Inspect the diff. Dynamic expressions, removed forms,
|
||||
and ambiguous axes are reported for manual review, never evaluated or guessed.
|
||||
Inline units are px/ch/vw/%, block units are lh/vh/%, and border strokes use
|
||||
non-percent lengths. Function operands obey the same rule recursively. Flex
|
||||
bases use a directly enclosing Flex direction when statically available.
|
||||
Exit 0 means no review items, 1 means review is needed, 2 means an I/O or reader
|
||||
error. --write applies safe edits even when review items remain. Each file is
|
||||
atomically replaced; interruption/error cleans up its temporary file.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass, field
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
NUMBER = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\Z")
|
||||
UNITS = {"px", "%", "vw", "vh", "ch", "lh"}
|
||||
AXIS_UNITS = {"ch": {"px", "ch", "vw", "%"},
|
||||
"lh": {"lh", "vh", "%"},
|
||||
"px": UNITS - {"%"}, None: {"%"}}
|
||||
FUNCTIONS = {"calc", "min", "max", "clamp"}
|
||||
KEYWORDS = {"auto", "none", "min-content", "max-content", "fit-content"}
|
||||
KEYWORDS.add("stretch")
|
||||
SKIP_DIRS = {".git", ".omx", "node_modules", "target", "__pycache__",
|
||||
"emacs-box", "archive", "historical"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Form:
|
||||
start: int
|
||||
end: int
|
||||
kind: str
|
||||
text: str = ""
|
||||
children: list = field(default_factory=list)
|
||||
|
||||
|
||||
class Reader:
|
||||
"""A non-evaluating reader retaining source offsets, comments, and quoting."""
|
||||
|
||||
def __init__(self, source):
|
||||
self.source = source
|
||||
self.pos = 0
|
||||
|
||||
def whitespace(self):
|
||||
while self.pos < len(self.source):
|
||||
char = self.source[self.pos]
|
||||
if char.isspace():
|
||||
self.pos += 1
|
||||
elif char == ";":
|
||||
end = self.source.find("\n", self.pos)
|
||||
self.pos = len(self.source) if end < 0 else end + 1
|
||||
else:
|
||||
break
|
||||
|
||||
def read(self):
|
||||
self.whitespace()
|
||||
start = self.pos
|
||||
if start == len(self.source):
|
||||
raise ValueError("unexpected end of input")
|
||||
char = self.source[self.pos]
|
||||
self.pos += 1
|
||||
if char in "([":
|
||||
closer = ")" if char == "(" else "]"
|
||||
children = []
|
||||
while True:
|
||||
self.whitespace()
|
||||
if self.pos == len(self.source):
|
||||
raise ValueError(f"unclosed {char} at offset {start}")
|
||||
if self.source[self.pos] == closer:
|
||||
self.pos += 1
|
||||
return Form(start, self.pos, "list", char, children)
|
||||
children.append(self.read())
|
||||
if char in ")]":
|
||||
raise ValueError(f"unexpected {char} at offset {start}")
|
||||
if char in "'`," or (char == "#" and self.source[self.pos:self.pos + 1] == "'"):
|
||||
if char == "#":
|
||||
self.pos += 1
|
||||
char = "#'"
|
||||
if char == "," and self.source[self.pos:self.pos + 1] == "@":
|
||||
self.pos += 1
|
||||
char = ",@"
|
||||
child = self.read()
|
||||
return Form(start, child.end, "prefix", char, [child])
|
||||
if char == '"':
|
||||
while self.pos < len(self.source):
|
||||
char = self.source[self.pos]
|
||||
self.pos += 1
|
||||
if char == "\\":
|
||||
self.pos += 1
|
||||
elif char == '"':
|
||||
return Form(start, self.pos, "string")
|
||||
raise ValueError(f"unclosed string at offset {start}")
|
||||
if char == "?":
|
||||
# Character literals can contain quote/paren/string delimiters.
|
||||
if self.source[self.pos:self.pos + 1] == "\\":
|
||||
self.pos += 1
|
||||
self.pos += 1
|
||||
while self.pos < len(self.source):
|
||||
char = self.source[self.pos]
|
||||
if char.isspace() or char in "()[];'`\",":
|
||||
break
|
||||
self.pos += 2 if char == "\\" else 1
|
||||
return Form(start, self.pos, "atom", self.source[start:self.pos])
|
||||
|
||||
def forms(self):
|
||||
result = []
|
||||
self.whitespace()
|
||||
while self.pos < len(self.source):
|
||||
result.append(self.read())
|
||||
self.whitespace()
|
||||
return result
|
||||
|
||||
|
||||
class Migration:
|
||||
def __init__(self, source, callers=None, literal=False):
|
||||
self.source = source
|
||||
self.callers = set(callers) if callers else None
|
||||
self.literal = literal
|
||||
self.edits = []
|
||||
self.reviews = []
|
||||
|
||||
def raw(self, node):
|
||||
return self.source[node.start:node.end]
|
||||
|
||||
def review(self, node, reason):
|
||||
self.reviews.append((self.source.count("\n", 0, node.start) + 1, reason,
|
||||
self.raw(node).replace("\n", " ")[:100]))
|
||||
return self.raw(node)
|
||||
|
||||
@staticmethod
|
||||
def head(node):
|
||||
return node.children[0].text if node.kind == "list" and node.children else ""
|
||||
|
||||
@staticmethod
|
||||
def numeric(node):
|
||||
return node.kind == "atom" and bool(NUMBER.fullmatch(node.text))
|
||||
|
||||
def compose(self, node, replacements):
|
||||
result = self.raw(node)
|
||||
for child, value in reversed(replacements):
|
||||
left, right = child.start - node.start, child.end - node.start
|
||||
result = result[:left] + value + result[right:]
|
||||
return result
|
||||
|
||||
def check_units(self, node, unit):
|
||||
"""Report axis-invalid lengths, including nested function operands."""
|
||||
head = self.head(node)
|
||||
if node.kind == "prefix":
|
||||
self.review(node, "dynamic size operand; review its units on this axis")
|
||||
elif head in UNITS:
|
||||
if head not in AXIS_UNITS[unit]:
|
||||
reason = ("parent axis is unknown; review this length in its Flex context"
|
||||
if unit is None else
|
||||
"unit is invalid on this axis; allowed units: " +
|
||||
", ".join(sorted(AXIS_UNITS[unit])))
|
||||
self.review(node, reason)
|
||||
elif node.kind == "list":
|
||||
for child in node.children[1:]:
|
||||
self.check_units(child, unit)
|
||||
return self.raw(node)
|
||||
|
||||
def scalar(self, node, unit, row_track=False):
|
||||
if node.kind == "prefix":
|
||||
if node.text in ("'", "`"):
|
||||
return self.compose(node, [(node.children[0], self.scalar(node.children[0], unit, row_track))])
|
||||
return self.review(node, "dynamic size; wrap the computed value in its explicit unit")
|
||||
if self.numeric(node):
|
||||
if unit is None:
|
||||
return self.review(node, "axis-dependent size; choose ch or lh from the parent layout")
|
||||
return f"({unit} {node.text})"
|
||||
if node.kind == "atom" and node.text in KEYWORDS:
|
||||
return self.raw(node)
|
||||
if node.text == "contain":
|
||||
return self.review(node, "contain was removed; choose the intended CSS sizing keyword")
|
||||
viewport = node.text if node.kind == "atom" else self.head(node)
|
||||
if viewport in ("viewport", "viewport-height"):
|
||||
if node.kind == "atom" or len(node.children) == 1:
|
||||
viewport_unit = "vw" if viewport == "viewport" else "vh"
|
||||
if viewport_unit not in AXIS_UNITS[unit]:
|
||||
return self.review(node, "legacy viewport unit is invalid or ambiguous on this axis")
|
||||
return "(vw 100)" if viewport == "viewport" else "(vh 100)"
|
||||
if node.kind == "list" and len(node.children) == 1 and self.numeric(node.children[0]):
|
||||
child = node.children[0]
|
||||
if unit is None:
|
||||
return self.review(node, "axis-dependent singleton size; choose px or lh from the parent layout")
|
||||
if unit == "lh" and not row_track:
|
||||
return self.review(node, "ambiguous legacy block-axis singleton; choose explicit lh")
|
||||
# Legacy Grid rows interpreted singleton tracks as lines.
|
||||
return self.compose(node, [(child, ("lh " if row_track else "px ") + child.text)])
|
||||
head = self.head(node)
|
||||
if head in UNITS | FUNCTIONS:
|
||||
return self.check_units(node, unit)
|
||||
if head == "fit-content":
|
||||
return self.review(node, "parameterized fit-content was removed; choose keyword or min/max/clamp")
|
||||
return self.review(node, "unresolved size expression; manual migration required")
|
||||
|
||||
def edges(self, node, axes, gap=False):
|
||||
if self.head(node) in UNITS | FUNCTIONS:
|
||||
for axis in dict.fromkeys(axes):
|
||||
self.check_units(node, axis)
|
||||
return self.raw(node)
|
||||
if node.kind != "list":
|
||||
if len(set(axes)) == 1:
|
||||
return self.scalar(node, axes[0])
|
||||
if self.numeric(node):
|
||||
return "(" + " ".join(self.scalar(node, axis) for axis in axes[:2]) + ")"
|
||||
return self.review(node, "dynamic shorthand; specify explicit edge units")
|
||||
parts = node.children
|
||||
if not 1 <= len(parts) <= len(axes) or parts[0].text.startswith(":"):
|
||||
return self.review(node, "unsupported edge shorthand; use CSS-ordered unit values")
|
||||
if len(parts) == 1 and len(set(axes)) > 1:
|
||||
if not self.numeric(parts[0]):
|
||||
return self.review(node, "ambiguous single edge shorthand; specify explicit edge units")
|
||||
child = parts[0]
|
||||
units = ["lh", "px"] if gap else axes[:2]
|
||||
return self.compose(node, [(child, " ".join(self.scalar(child, axis) for axis in units))])
|
||||
return self.compose(node, [(part, self.scalar(part, axes[i], row_track=gap and i == 0))
|
||||
for i, part in enumerate(parts)])
|
||||
|
||||
def track(self, node, axis):
|
||||
head = self.head(node)
|
||||
if head == "fr":
|
||||
return self.raw(node)
|
||||
if head == "minmax" and len(node.children) == 3:
|
||||
return self.compose(node, [(part, self.track(part, axis)) for part in node.children[1:]])
|
||||
if head == "repeat" and len(node.children) == 3:
|
||||
body = node.children[2]
|
||||
if body.kind == "list" and not self.head(body) and len(body.children) > 1:
|
||||
new = self.tracks(body, axis)
|
||||
else:
|
||||
new = self.track(body, axis)
|
||||
return self.compose(node, [(body, new)])
|
||||
return self.scalar(node, axis, row_track=axis == "lh")
|
||||
|
||||
def tracks(self, node, axis):
|
||||
if node.kind != "list" or self.head(node) in UNITS | FUNCTIONS | {"fr", "minmax", "repeat"}:
|
||||
return self.track(node, axis)
|
||||
return self.compose(node, [(part, self.track(part, axis)) for part in node.children])
|
||||
|
||||
def value(self, prop, node, context, tag, parent_axis=None):
|
||||
if node.kind == "prefix":
|
||||
if node.text in ("'", "`"):
|
||||
child = node.children[0]
|
||||
return self.compose(node, [(child, self.value(prop, child, "data", tag, parent_axis))])
|
||||
return self.review(node, f"dynamic {prop}; manual migration required")
|
||||
if node.kind == "list" and self.head(node) == "quote" and len(node.children) == 2:
|
||||
child = node.children[1]
|
||||
return self.compose(node, [(child, self.value(prop, child, "data", tag, parent_axis))])
|
||||
if context == "code" and node.kind == "list":
|
||||
return self.review(node, f"computed {prop}; return an explicit unit value")
|
||||
if prop in {"max-width", "max-height"} and node.text == "auto":
|
||||
return self.review(node, "maximum sizes do not accept auto; choose none or an explicit size")
|
||||
if prop in {"padding", "margin", "border-width", "gap"} or prop in {
|
||||
"padding-inline", "padding-block", "margin-inline", "margin-block"}:
|
||||
if prop == "border-width":
|
||||
axes = ["px"] * 4
|
||||
elif prop.endswith("-inline"):
|
||||
axes = ["ch"] * 2
|
||||
elif prop.endswith("-block"):
|
||||
axes = ["lh"] * 2
|
||||
else:
|
||||
axes = ["lh", "ch"] * (1 if prop == "gap" else 2)
|
||||
result = self.edges(node, axes, gap=prop == "gap")
|
||||
elif prop == "border" or prop in {"border-top", "border-right", "border-bottom", "border-left"}:
|
||||
if node.kind == "string" or node.text in {"none", "solid", "nil"}:
|
||||
result = self.raw(node)
|
||||
elif node.kind == "list" and self.head(node) not in UNITS | FUNCTIONS:
|
||||
result = self.compose(node, [(part, self.scalar(part, "px"))
|
||||
for part in node.children
|
||||
if self.numeric(part) or self.head(part) in UNITS | FUNCTIONS])
|
||||
else:
|
||||
result = self.scalar(node, "px")
|
||||
elif prop == "flex":
|
||||
if node.kind == "atom":
|
||||
# Numbers here are unitless grow factors, not lengths.
|
||||
result = self.raw(node)
|
||||
elif len(node.children) == 3:
|
||||
basis = node.children[2]
|
||||
result = self.compose(node, [(basis, self.scalar(basis, parent_axis))])
|
||||
else:
|
||||
result = self.review(node, "unresolved flex shorthand; migrate only its basis size")
|
||||
elif prop.startswith("grid-"):
|
||||
result = self.tracks(node, "lh" if prop.endswith("rows") else "ch")
|
||||
else:
|
||||
axis = ("px" if prop.startswith("border-") else
|
||||
"lh" if re.search(r"height|top|bottom|block|row-gap", prop) else "ch")
|
||||
if prop in {"item-gap", "flex-basis"}:
|
||||
axis = {"row": "ch", "column": "lh"}.get(tag) if prop == "item-gap" else parent_axis
|
||||
result = self.scalar(node, axis, row_track=prop == "row-gap")
|
||||
if context == "code" and result != self.raw(node) and result.startswith("("):
|
||||
result = "'" + result
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def property(node):
|
||||
if node.kind != "atom" or not node.text.startswith(":"):
|
||||
return None
|
||||
prop = node.text[1:]
|
||||
if re.fullmatch(r"(?:(?:min-|max-)?(?:width|height)|(?:padding|margin)(?:-(?:top|right|bottom|left|inline(?:-start|-end)?|block(?:-start|-end)?))?|border(?:-(?:top|right|bottom|left))?(?:-width)?|(?:row-|column-)?gap|item-gap|flex(?:-basis)?|grid-(?:template|auto)-(?:columns|rows))", prop):
|
||||
return prop
|
||||
return None
|
||||
|
||||
def visit(self, node, context="code", in_scope=False, parent_axis=None):
|
||||
if node.kind == "prefix":
|
||||
self.visit(node.children[0], "data" if node.text in {"'", "`"} else "code", in_scope, parent_axis)
|
||||
elif node.kind == "list":
|
||||
children = node.children
|
||||
tag = self.head(node)
|
||||
child_axis = None
|
||||
if tag == "flex":
|
||||
child_axis = "ch"
|
||||
for i, child in enumerate(children[:-1]):
|
||||
if child.text == ":flex-direction":
|
||||
child_axis = {"row": "ch", "row-reverse": "ch",
|
||||
"column": "lh", "column-reverse": "lh"}.get(children[i + 1].text)
|
||||
elif tag not in {"box", "text", "row", "column", "grid"}:
|
||||
child_axis = parent_axis
|
||||
in_scope = in_scope or self.callers is None or self.head(node) in self.callers
|
||||
if self.head(node) == "styles":
|
||||
# ETAF's styles macro consumes its selector rules as data.
|
||||
context = "data"
|
||||
if self.head(node) in {"space", "image", "create-image"}:
|
||||
# Native Emacs display specs use their own pixel grammar.
|
||||
return
|
||||
if self.head(node) in {"quote", "backquote"} and len(children) == 2:
|
||||
self.visit(children[1], "data", in_scope, parent_axis)
|
||||
return
|
||||
i = 0
|
||||
while i < len(children):
|
||||
prop = self.property(children[i]) if in_scope else None
|
||||
if prop and i + 1 < len(children) and not children[i + 1].text.startswith(":"):
|
||||
value = children[i + 1]
|
||||
result = self.value(prop, value, context, tag, parent_axis)
|
||||
if result != self.raw(value):
|
||||
self.edits.append((value.start, value.end, result))
|
||||
i += 2
|
||||
else:
|
||||
self.visit(children[i], context, in_scope, child_axis)
|
||||
i += 1
|
||||
|
||||
def run(self):
|
||||
for form in Reader(self.source).forms():
|
||||
self.visit(form, "data" if self.literal else "code")
|
||||
output = self.source
|
||||
for start, end, value in sorted(self.edits, reverse=True):
|
||||
output = output[:start] + value + output[end:]
|
||||
return output
|
||||
|
||||
|
||||
def paths_from(arguments):
|
||||
paths = set()
|
||||
for argument in arguments:
|
||||
path = Path(argument)
|
||||
if path.is_dir():
|
||||
for root, dirs, files in os.walk(path):
|
||||
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
|
||||
paths.update(Path(root, name) for name in files if Path(name).suffix in {".el", ".ebox", ".etaf", ".ecss"})
|
||||
else:
|
||||
paths.add(path)
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def atomic_write(path, output):
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="", dir=path.parent,
|
||||
prefix=".migrate-css-", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
stream.write(output)
|
||||
temporary.chmod(path.stat().st_mode)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--write", action="store_true", help="apply safe edits to the explicitly selected files")
|
||||
parser.add_argument("--callers", help="restrict edits/reviews to subforms rooted at these comma-separated API names")
|
||||
parser.add_argument("paths", nargs="+", help="Elisp/ebox or static etaf/ecss files, or directories containing public style callers")
|
||||
args = parser.parse_args(argv)
|
||||
changes = reviews = errors = count = 0
|
||||
for path in paths_from(args.paths):
|
||||
count += 1
|
||||
try:
|
||||
if path.is_symlink():
|
||||
raise ValueError("symlink inputs require selecting their actual target")
|
||||
with path.open(encoding="utf-8", newline="") as stream:
|
||||
source = stream.read()
|
||||
migration = Migration(source, args.callers.split(",") if args.callers else None,
|
||||
literal=path.suffix in {".etaf", ".ecss"})
|
||||
output = migration.run()
|
||||
changes += len(migration.edits)
|
||||
reviews += len(migration.reviews)
|
||||
if args.write and output != source:
|
||||
atomic_write(path, output)
|
||||
if migration.edits or migration.reviews:
|
||||
print(f"{path}: {len(migration.edits)} edits, {len(migration.reviews)} review items")
|
||||
for line, reason, snippet in migration.reviews:
|
||||
print(f" {path}:{line}: REVIEW {reason}: {snippet}")
|
||||
except (OSError, UnicodeError, ValueError) as error:
|
||||
print(f"{path}: ERROR {error}", file=sys.stderr)
|
||||
errors += 1
|
||||
print(f"{'Applied' if args.write else 'Dry run'}: {count} files, {changes} property edits, {reviews} review items, {errors} errors")
|
||||
return 2 if errors else 1 if reviews else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
100
scripts/test_ebox_release.py
Normal file
100
scripts/test_ebox_release.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""Regression tests for deterministic packaging and dependency boundaries."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("ebox_release", Path(__file__).with_name("ebox-release.py"))
|
||||
RELEASE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(RELEASE)
|
||||
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
def test_lock_requires_exact_revisions_and_optional_provider_is_explicit(self):
|
||||
lock = json.loads((RELEASE.ROOT / "release-dependencies.json").read_text())
|
||||
self.assertEqual(set(RELEASE.lock_entries(lock)), {"tp", "ecss"})
|
||||
self.assertEqual(set(RELEASE.lock_entries(lock, True)), {"tp", "ecss", "ekp"})
|
||||
lock["dependencies"]["tp"]["revision"] = "main"
|
||||
with self.assertRaises(ValueError):
|
||||
RELEASE.lock_entries(lock)
|
||||
|
||||
def test_archives_are_reproducible_source_complete_and_exclude_local_outputs(self):
|
||||
with tempfile.TemporaryDirectory() as work:
|
||||
root = Path(work) / "source"
|
||||
root.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
||||
(root / "ebox.el").write_text(';;; ebox.el --- Test -*- lexical-binding: t -*-\n;; Version: 3.0.0\n;; Package-Requires: ((emacs "29.1"))\n')
|
||||
(root / "LICENSE").write_text("license")
|
||||
(root / "native/src").mkdir(parents=True)
|
||||
(root / "native/src/lib.rs").write_text("native source")
|
||||
(root / "native/target/release").mkdir(parents=True)
|
||||
(root / "native/target/release/private.log").write_text("exclude")
|
||||
(root / "ebox.elc").write_text("stale bytecode")
|
||||
first, second = Path(work) / "first", Path(work) / "second"
|
||||
first.mkdir()
|
||||
second.mkdir()
|
||||
one = RELEASE.write_package(first, root, "ebox")
|
||||
(root / "ebox.el").touch()
|
||||
two = RELEASE.write_package(second, root, "ebox")
|
||||
self.assertEqual(one["sha256"], two["sha256"])
|
||||
with tarfile.open(first / one["file"]) as archive:
|
||||
names = archive.getnames()
|
||||
self.assertIn("ebox-3.0.0/LICENSE", names)
|
||||
self.assertIn("ebox-3.0.0/native/src/lib.rs", names)
|
||||
self.assertIn("ebox-3.0.0/ebox-pkg.el", names)
|
||||
self.assertFalse(any("target/" in name or name.endswith(".elc") for name in names))
|
||||
self.assertTrue(all(member.mtime == 0 for member in archive.getmembers()))
|
||||
|
||||
def test_existing_output_is_preserved(self):
|
||||
with tempfile.TemporaryDirectory() as work:
|
||||
directory = Path(work)
|
||||
sentinel = directory / "keep"
|
||||
sentinel.write_text("owned")
|
||||
with self.assertRaises(ValueError):
|
||||
RELEASE.checkout_dependencies(directory, {})
|
||||
with self.assertRaises(ValueError):
|
||||
RELEASE.build(directory, directory, directory, {})
|
||||
self.assertEqual(sentinel.read_text(), "owned")
|
||||
|
||||
def test_modified_archive_fails_before_emacs_runs(self):
|
||||
with tempfile.TemporaryDirectory() as work:
|
||||
directory = Path(work)
|
||||
(directory / "bad.tar").write_bytes(b"changed")
|
||||
(directory / "manifest.json").write_text(json.dumps({"packages": [{"file": "bad.tar", "sha256": "wrong"}]}))
|
||||
with self.assertRaisesRegex(ValueError, "checksum mismatch"):
|
||||
RELEASE.verify(directory, "must-not-run")
|
||||
|
||||
def test_failed_fetch_removes_temporary_checkout_and_never_creates_output(self):
|
||||
with tempfile.TemporaryDirectory() as work:
|
||||
parent = Path(work)
|
||||
output = parent / "deps"
|
||||
with mock.patch.object(RELEASE, "run", side_effect=RuntimeError("fetch failed")):
|
||||
with self.assertRaisesRegex(RuntimeError, "fetch failed"):
|
||||
RELEASE.checkout_dependencies(output, {"tp": {"url": "https://invalid", "revision": "0" * 40}})
|
||||
self.assertEqual(list(parent.iterdir()), [])
|
||||
|
||||
def test_dependency_validation_rejects_modified_checkout(self):
|
||||
with tempfile.TemporaryDirectory() as work:
|
||||
root = Path(work)
|
||||
dependency = root / "tp"
|
||||
dependency.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(dependency)], check=True)
|
||||
(dependency / "tp.el").write_text("original")
|
||||
subprocess.run(["git", "add", "tp.el"], cwd=dependency, check=True)
|
||||
subprocess.run(["git", "-c", "user.name=Release Test", "-c", "user.email=release@example.invalid",
|
||||
"commit", "-qm", "fixture"], cwd=dependency, check=True)
|
||||
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=dependency).decode().strip()
|
||||
entries = {"tp": {"revision": revision}}
|
||||
RELEASE.validate_dependencies(root, entries)
|
||||
(dependency / "tp.el").write_text("modified")
|
||||
with self.assertRaisesRegex(ValueError, "dependency must be clean"):
|
||||
RELEASE.validate_dependencies(root, entries)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
167
scripts/test_migrate_css_sizes.py
Normal file
167
scripts/test_migrate_css_sizes.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""Behavior and non-destructive migration tests; standard library only."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).with_name("migrate-css-sizes.py")
|
||||
SPEC = importlib.util.spec_from_file_location("migrate_css_sizes", SCRIPT)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class MigrationTests(unittest.TestCase):
|
||||
def migrate(self, source):
|
||||
migration = MODULE.Migration(source)
|
||||
result = migration.run()
|
||||
self.assertFalse(migration.reviews)
|
||||
second = MODULE.Migration(result)
|
||||
self.assertEqual(second.run(), result)
|
||||
self.assertFalse(second.reviews)
|
||||
return result
|
||||
|
||||
def test_preserves_reader_quoting_and_text(self):
|
||||
source = '; :width 999\n`(box\t:width ( 240 ) :height 3 ":height 99" ,label)\n'
|
||||
self.assertEqual(self.migrate(source), '; :width 999\n`(box\t:width ( px 240 ) :height (lh 3) ":height 99" ,label)\n')
|
||||
|
||||
def test_executable_calls_keep_values_quoted(self):
|
||||
self.assertEqual(self.migrate("(ebox-create :width 80 :height 3 :margin '(1 2))"),
|
||||
"(ebox-create :width '(ch 80) :height '(lh 3) :margin '((lh 1) (ch 2)))")
|
||||
|
||||
def test_scalar_and_edge_shorthands_preserve_axes(self):
|
||||
self.assertEqual(self.migrate("'(:padding 2 :margin (1 2 3 4) :gap (1 (12)) :border-width (1 2))"),
|
||||
"'(:padding ((lh 2) (ch 2)) :margin ((lh 1) (ch 2) (lh 3) (ch 4)) :gap ((lh 1) (px 12)) :border-width ((px 1) (px 2)))")
|
||||
|
||||
def test_one_edge_and_border_components(self):
|
||||
self.assertEqual(self.migrate("'(:padding (2) :margin-inline (3) :border (2 solid \"red\"))"),
|
||||
"'(:padding ((lh 2) (ch 2)) :margin-inline ((ch 3)) :border ((px 2) solid \"red\"))")
|
||||
|
||||
def test_grid_counts_and_legacy_row_tracks(self):
|
||||
self.assertEqual(self.migrate("'(grid :grid-template-columns ((repeat 2 (80)) (fr 1)) :grid-template-rows ((minmax (1) (3)) 1) :grid-row (2 :span 3))"),
|
||||
"'(grid :grid-template-columns ((repeat 2 (px 80)) (fr 1)) :grid-template-rows ((minmax (lh 1) (lh 3)) (lh 1)) :grid-row (2 :span 3))")
|
||||
|
||||
def test_keywords_and_numeric_non_sizes(self):
|
||||
self.assertEqual(self.migrate("'(:width viewport :height (viewport-height) :flex-grow 3 :order 2 :font-size 16 :border-top-width 1)"),
|
||||
"'(:width (vw 100) :height (vh 100) :flex-grow 3 :order 2 :font-size 16 :border-top-width (px 1))")
|
||||
|
||||
def test_flex_only_converts_basis(self):
|
||||
self.assertEqual(self.migrate("'(flex (box :flex (2 1 0)) (box :flex 2))"),
|
||||
"'(flex (box :flex (2 1 (ch 0))) (box :flex 2))")
|
||||
self.assertEqual(self.migrate("'(flex :flex-direction column (box :flex (2 1 0) :flex-basis 3))"),
|
||||
"'(flex :flex-direction column (box :flex (2 1 (lh 0)) :flex-basis (lh 3)))")
|
||||
|
||||
def test_unknown_flex_axis_never_generates_pixel_zero(self):
|
||||
source = "'(:flex (2 1 0) :flex-basis (0))"
|
||||
migration = MODULE.Migration(source)
|
||||
self.assertEqual(migration.run(), source)
|
||||
self.assertEqual(len(migration.reviews), 2)
|
||||
|
||||
def test_cross_axis_units_are_reported_recursively(self):
|
||||
source = "'(:height (px 0) :width (lh 2) :padding (ch 1) :gap (vw 2) :height (clamp (lh 1) (calc (* (px 2) 0)) (vh 20)))"
|
||||
migration = MODULE.Migration(source)
|
||||
self.assertEqual(migration.run(), source)
|
||||
self.assertEqual(len(migration.reviews), 5)
|
||||
self.assertTrue(all("invalid on this axis" in item[1] for item in migration.reviews))
|
||||
|
||||
def test_legal_functions_edges_and_strokes_are_preserved(self):
|
||||
source = "'(:width (calc (- (vw 50) (ch 2))) :height (max (lh 2) (vh 10)) :padding ((lh 1) (px 2)) :border ((px 2) solid \"red\") :border-width (lh 0.1))"
|
||||
self.assertEqual(self.migrate(source), source)
|
||||
migration = MODULE.Migration("'(:border ((max (px 1) (% 2)) solid \"red\"))")
|
||||
self.assertEqual(migration.run(), migration.source)
|
||||
self.assertEqual(len(migration.reviews), 1)
|
||||
|
||||
def test_grid_and_flex_lengths_use_their_actual_axis(self):
|
||||
source = "'(flex :flex-direction column-reverse (box :flex-basis (px 2)) (flex (box :flex-basis (lh 2))) (grid :grid-template-rows ((repeat 2 (minmax (lh 1) (px 4))))))"
|
||||
migration = MODULE.Migration(source)
|
||||
self.assertEqual(migration.run(), source)
|
||||
self.assertEqual(len(migration.reviews), 3)
|
||||
|
||||
def test_cross_axis_legacy_viewports_require_review(self):
|
||||
source = "'(:width viewport-height :height (viewport))"
|
||||
migration = MODULE.Migration(source)
|
||||
self.assertEqual(migration.run(), source)
|
||||
self.assertEqual(len(migration.reviews), 2)
|
||||
|
||||
def test_dynamic_function_operands_require_axis_review(self):
|
||||
source = "`(:height (calc (+ (lh 1) ,size)))"
|
||||
migration = MODULE.Migration(source)
|
||||
self.assertEqual(migration.run(), source)
|
||||
self.assertEqual(len(migration.reviews), 1)
|
||||
|
||||
def test_legacy_row_gap_singletons_are_lines(self):
|
||||
self.assertEqual(self.migrate("'(:row-gap (2) :gap ((2) (3)))"),
|
||||
"'(:row-gap (lh 2) :gap ((lh 2) (px 3)))")
|
||||
self.assertEqual(self.migrate("'(:gap (2))"), "'(:gap ((lh 2) (px 2)))")
|
||||
|
||||
def test_ambiguous_singletons_and_max_auto_require_review(self):
|
||||
source = "'(:flex-basis (3) :padding-top (2) :max-width auto)"
|
||||
migration = MODULE.Migration(source)
|
||||
self.assertEqual(migration.run(), source)
|
||||
self.assertEqual(len(migration.reviews), 3)
|
||||
|
||||
def test_native_display_specs_keep_their_own_size_grammar(self):
|
||||
source = "(list '(space :width (720) :height 2) '(image :width 30 :height 20))"
|
||||
self.assertEqual(self.migrate(source), source)
|
||||
|
||||
def test_etaf_styles_macro_consumes_literal_values(self):
|
||||
self.assertEqual(self.migrate('(etaf-define-component card () :styles (styles ("&" :width 3 :padding (1 2))))'),
|
||||
'(etaf-define-component card () :styles (styles ("&" :width (ch 3) :padding ((lh 1) (ch 2)))))')
|
||||
|
||||
def test_callers_filter_preserves_measured_structs_and_limits_reviews(self):
|
||||
source = "(list :width 30 :height unknown (ebox-build '(box :width 4)) (ebox-create :height 3))"
|
||||
migration = MODULE.Migration(source, {"ebox-build", "ebox-create"})
|
||||
self.assertEqual(migration.run(),
|
||||
"(list :width 30 :height unknown (ebox-build '(box :width (ch 4))) (ebox-create :height '(lh 3)))")
|
||||
self.assertFalse(migration.reviews)
|
||||
|
||||
def test_static_etaf_data_keeps_sizes_unquoted(self):
|
||||
migration = MODULE.Migration('(box :width 20 :height 3)', literal=True)
|
||||
self.assertEqual(migration.run(), '(box :width (ch 20) :height (lh 3))')
|
||||
self.assertFalse(migration.reviews)
|
||||
|
||||
def test_dynamic_and_removed_forms_report_without_guessing(self):
|
||||
source = "`(box :width ,width :height (fit-content 3) :max-width contain :flex-basis 2)"
|
||||
migration = MODULE.Migration(source)
|
||||
self.assertEqual(migration.run(), source)
|
||||
self.assertEqual(len(migration.reviews), 4)
|
||||
|
||||
def test_reader_explicit_quote_and_char_literals(self):
|
||||
self.assertEqual(self.migrate('(list ?\\" ?\\( (quote (:width 20)) :height (quote 2))'),
|
||||
'(list ?\\" ?\\( (quote (:width (ch 20))) :height (quote (lh 2)))')
|
||||
|
||||
def test_cli_dry_run_write_and_syntax_error(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory, "demo.ebox")
|
||||
path.write_text("'(box :width 20)\n")
|
||||
result = subprocess.run([sys.executable, str(SCRIPT), str(path)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("1 property edits", result.stdout)
|
||||
self.assertEqual(path.read_text(), "'(box :width 20)\n")
|
||||
result = subprocess.run([sys.executable, str(SCRIPT), "--write", str(path)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(path.read_text(), "'(box :width (ch 20))\n")
|
||||
self.assertFalse(list(Path(directory).glob(".migrate-css-*")))
|
||||
path.write_text("'(box :width 20")
|
||||
result = subprocess.run([sys.executable, str(SCRIPT), "--write", str(path)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertEqual(path.read_text(), "'(box :width 20")
|
||||
|
||||
def test_cli_preserves_cross_axis_values_and_writes_safe_edits(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory, "demo.ebox")
|
||||
path.write_text("'(box :width 20 :height (px 3))\n")
|
||||
result = subprocess.run([sys.executable, str(SCRIPT), "--write", str(path)],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("unit is invalid on this axis", result.stdout)
|
||||
self.assertEqual(path.read_text(), "'(box :width (ch 20) :height (px 3))\n")
|
||||
self.assertFalse(list(Path(directory).glob(".migrate-css-*")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
363
tests/ebox-box-line-flow-tests.el
Normal file
363
tests/ebox-box-line-flow-tests.el
Normal file
@ -0,0 +1,363 @@
|
||||
;;; ebox-box-line-flow-tests.el --- Box line flow contracts -*- lexical-binding: t; -*-
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'ert)
|
||||
(setq load-prefer-newer t)
|
||||
(load-file (expand-file-name "../ebox.el"
|
||||
(file-name-directory load-file-name)))
|
||||
|
||||
(defun ebox-box-line-test--previous-pad (string height &optional offset padding)
|
||||
"Apply the frozen string padding algorithm to STRING at HEIGHT."
|
||||
(let* ((lines (ebox-string-lines string))
|
||||
(count (length lines)))
|
||||
(when (> height count)
|
||||
(let* ((fill (or padding
|
||||
(ebox--pixel-blank (ebox--string-pixel-width string) 1)))
|
||||
(rest (- height count))
|
||||
(shift (or offset 0))
|
||||
(top (if (>= shift 0) (min shift rest)
|
||||
(max 0 (+ shift rest)))))
|
||||
(setq lines (append (make-list top fill) lines
|
||||
(make-list (- rest top) fill)))))
|
||||
(ebox-lines-join (seq-take lines height))))
|
||||
|
||||
(defun ebox-box-line-test--previous-prepare (box formatted height region-id filler)
|
||||
"Freeze BOX's old join/align/split/blank-normalization front half.
|
||||
FORMATTED is captured before decoration; HEIGHT and REGION-ID are the actual
|
||||
render constraints. This oracle does not call the new line operation."
|
||||
(let* ((text-height (ebox-string-height formatted))
|
||||
(scroll-p (eq (ebox-get box :overflow) 'scroll))
|
||||
(start (if scroll-p
|
||||
(max 0 (min (or (ebox-get box :scroll-offset) 0)
|
||||
(max 0 (- text-height height))))
|
||||
0))
|
||||
(lines (ebox-string-lines formatted))
|
||||
(visible (if scroll-p
|
||||
(seq-subseq lines start (min (+ start height) text-height))
|
||||
(seq-take lines height)))
|
||||
(ebox--propertize-private-content-line-p
|
||||
(not (and scroll-p (> text-height height))))
|
||||
(decorated
|
||||
(ebox-lines-join
|
||||
(cl-loop for line in visible for index from 0
|
||||
collect (ebox--propertize-content-line
|
||||
line region-id index filler))))
|
||||
(count (ebox-string-height decorated))
|
||||
(offset (pcase (ebox-get box :vertical-align)
|
||||
('bottom (- height count))
|
||||
('center (/ (- height count) 2))
|
||||
(_ 0)))
|
||||
(aligned (ebox-box-line-test--previous-pad decorated height offset)))
|
||||
(mapcar
|
||||
(lambda (line)
|
||||
(if (and (string-empty-p (string-trim line))
|
||||
(ebox--line-content-metadata-uniform-p line)
|
||||
(not (ebox--line-has-non-content-properties-p line)))
|
||||
(let ((id (get-text-property 0 'ebox-content line))
|
||||
(index (get-text-property 0 'ebox-content-idx line)))
|
||||
(propertize filler 'ebox-content (or id region-id)
|
||||
'ebox-content-idx (if id index 0)
|
||||
'ebox-content-owner region-id))
|
||||
line))
|
||||
(ebox-string-lines aligned))))
|
||||
|
||||
(defun ebox-box-line-test--reference-render (input &optional actual-p)
|
||||
"Render INPUT with the old front half, or current code when ACTUAL-P.
|
||||
Check opaque identity between the actual formatted source and compositor output;
|
||||
the surrounding ephemeral TP surface has its own snapshot value ownership."
|
||||
(let ((format-function (symbol-function 'ebox--format-content))
|
||||
(compositor (symbol-function 'ebox--window-render-flat-wrapper-chunk-lines))
|
||||
(formatted (make-hash-table :test 'eq)))
|
||||
(cl-letf (((symbol-function 'ebox--format-content)
|
||||
(lambda (box)
|
||||
(let ((result (funcall format-function box)))
|
||||
(puthash box
|
||||
(list (copy-sequence result)
|
||||
(plist-get ebox--scroll-window-render-result
|
||||
:content-height))
|
||||
formatted)
|
||||
result)))
|
||||
((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)
|
||||
(lambda (&rest args)
|
||||
(when (and (nth 7 args) (not actual-p))
|
||||
(let* ((box (car args))
|
||||
(record (gethash box formatted))
|
||||
(text (car record))
|
||||
(height (or (cadr record)
|
||||
(ebox--content-height
|
||||
box (ebox-string-height text)))))
|
||||
(should record)
|
||||
(setf (nth 1 args)
|
||||
(ebox-box-line-test--previous-prepare
|
||||
box text height (nth 3 args) (nth 4 args)))))
|
||||
(let* ((result (apply compositor args))
|
||||
(source (car (gethash (car args) formatted)))
|
||||
(source-pos (and source (string-match "Open" source)))
|
||||
(line (cl-find-if (lambda (value) (string-match "Open" value))
|
||||
result)))
|
||||
(when (and source-pos line)
|
||||
(let ((pos (string-match "Open" line)))
|
||||
(dolist (property '(keymap help-echo))
|
||||
(should (eq (get-text-property pos property line)
|
||||
(get-text-property source-pos property source))))))
|
||||
result))))
|
||||
(ebox-render input))))
|
||||
|
||||
(defun ebox-box-line-test--materializations (input)
|
||||
"Record actual intermediate newline joins/splits while rendering INPUT's root."
|
||||
(let* ((handle (ebox-node-source-handle (car (ebox-canonical-input-roots input))))
|
||||
(render (symbol-function 'ebox--render-box))
|
||||
(format-function (symbol-function 'ebox--format-content))
|
||||
(join (symbol-function 'mapconcat))
|
||||
(slice (symbol-function 'substring))
|
||||
(joined (make-hash-table :test 'eq))
|
||||
(joins 0) (splits 0) active formatting output)
|
||||
(cl-letf (((symbol-function 'ebox--render-box)
|
||||
(lambda (box)
|
||||
(let ((previous active))
|
||||
(setq active (eq (ebox-node-source-handle box) handle))
|
||||
(unwind-protect (funcall render box)
|
||||
(setq active previous)))))
|
||||
((symbol-function 'ebox--format-content)
|
||||
(lambda (box)
|
||||
(let ((previous formatting))
|
||||
(setq formatting t)
|
||||
(unwind-protect (funcall format-function box)
|
||||
(setq formatting previous)))))
|
||||
((symbol-function 'mapconcat)
|
||||
(lambda (function sequence separator)
|
||||
(let ((result (funcall join function sequence separator)))
|
||||
(when (and active (not formatting) (equal separator "\n")
|
||||
(string-match-p "alpha" result))
|
||||
(cl-incf joins)
|
||||
(puthash result t joined))
|
||||
result)))
|
||||
((symbol-function 'substring)
|
||||
(lambda (string from &optional to)
|
||||
(when (and active (not formatting) (gethash string joined))
|
||||
(cl-incf splits))
|
||||
(funcall slice string from to))))
|
||||
(setq output (ebox-render input)))
|
||||
(list :output output :joins joins :splits splits)))
|
||||
|
||||
(ert-deftest ebox-box-line-flow-avoids-intermediate-join-and-split ()
|
||||
"Ordinary decoration should join only at the existing compositor exit."
|
||||
(let* ((input (ebox-build
|
||||
'(box :width (px 120) :height (lh 8) :overflow hidden
|
||||
:padding ((lh 1) (px 3)) :border ((px 1) solid "#112233")
|
||||
:bgcolor "#ddeeff" (text "alpha\nbeta"))))
|
||||
(expected (ebox-box-line-test--reference-render input))
|
||||
(observed (ebox-box-line-test--materializations input)))
|
||||
(should (equal-including-properties (plist-get observed :output) expected))
|
||||
(ert-info ((format "Actual content joins=%S, joined-string substrings=%S"
|
||||
(plist-get observed :joins) (plist-get observed :splits)))
|
||||
(should (= (plist-get observed :joins) 1))
|
||||
(should (= (plist-get observed :splits) 0)))))
|
||||
|
||||
(ert-deftest ebox-box-line-flow-preserves-old-front-half-output ()
|
||||
"Composition, chrome, visibility and ownership match the frozen string path."
|
||||
(let* ((map (make-sparse-keymap))
|
||||
(callback (let ((value 'opened)) (lambda () value)))
|
||||
(rich (concat (propertize "Open 中" 'keymap map 'help-echo callback)
|
||||
(propertize "\n" 'help-echo "newline only")
|
||||
(propertize " " 'help-echo "rich blank") "\ntail\n")))
|
||||
(define-key map [mouse-1] #'ignore)
|
||||
(dolist (kind '(box row column flex grid))
|
||||
(dolist (height '(0 4 8))
|
||||
(dolist (align '(top center bottom))
|
||||
(dolist (overflow '(hidden visible scroll))
|
||||
(let* ((child (pcase kind
|
||||
('box `(box (text ,rich)))
|
||||
('grid `(grid :grid-template-columns ((px 110)) (text ,rich)))
|
||||
(_ `(,kind (text ,rich)))))
|
||||
(input
|
||||
(ebox-build
|
||||
`(box :width (px 140) :height (lh ,height) :overflow ,overflow
|
||||
:padding ((lh 1) (px 3))
|
||||
:border ((px 1) solid "#112233") :margin ((lh 1) (px 2))
|
||||
:color "#123456" :bgcolor "#ddeeff" ,child)))
|
||||
(_alignment
|
||||
(plist-put (car (ebox-canonical-input-roots input))
|
||||
:vertical-align align))
|
||||
(source-before (copy-sequence rich))
|
||||
(actual (ebox-box-line-test--reference-render input t))
|
||||
(expected (ebox-box-line-test--reference-render input)))
|
||||
(ert-info ((format "%S height=%S align=%S overflow=%S"
|
||||
kind height align overflow))
|
||||
(should (equal-including-properties actual expected))
|
||||
(should (equal-including-properties rich source-before))
|
||||
(when-let* ((pos (string-match "Open" actual)))
|
||||
(should (eq (lookup-key (get-text-property pos 'keymap actual)
|
||||
[mouse-1]) #'ignore))
|
||||
(should (eq (funcall (get-text-property pos 'help-echo actual))
|
||||
'opened))
|
||||
(should (> (length (get-text-property
|
||||
pos 'ebox-content-owners actual)) 1)))))))))))
|
||||
|
||||
(ert-deftest ebox-box-line-flow-keeps-string-padding-contract ()
|
||||
"String padding retains nil, empty, first-line width and multiline filler."
|
||||
(dolist (input (list nil "" "A" "A\nlonger" "\n" "A\n"))
|
||||
(dolist (height '(0 1 2 4))
|
||||
(dolist (offset '(nil -2 0 2))
|
||||
(dolist (filler (list nil "" (propertize "L\nR" 'help-echo "fill")))
|
||||
(should (equal-including-properties
|
||||
(ebox--lines-pad-vertical input height offset filler)
|
||||
(ebox-box-line-test--previous-pad input height offset filler)))))))
|
||||
(let* ((input "A\nlonger")
|
||||
(output (ebox-string-lines (ebox--lines-pad-vertical input 3)))
|
||||
(fill (car (last output))))
|
||||
(should (= (ebox--string-pixel-width fill) (ebox--string-pixel-width "A")))))
|
||||
|
||||
(ert-deftest ebox-box-line-flow-keeps-simple-scroll-output ()
|
||||
"Complete simple scroll keeps its single join for overflow and underfill."
|
||||
(dolist (text '("alpha\nbeta" "alpha\n2\n3\n4\n5\n6\n7\n8\n9"))
|
||||
(let* ((input (ebox-build
|
||||
`(box :width (px 120) :height (lh 8) :overflow scroll
|
||||
:bgcolor "#ddeeff" (text ,text))))
|
||||
(expected (ebox-box-line-test--reference-render input))
|
||||
(observed (ebox-box-line-test--materializations input)))
|
||||
(should (equal-including-properties (plist-get observed :output) expected))
|
||||
(should (= (plist-get observed :joins) 1))
|
||||
(should (= (plist-get observed :splits) 0)))))
|
||||
|
||||
(ert-deftest ebox-box-line-flow-list-core-borrows-without-mutating ()
|
||||
"Line padding preserves the original spine, strings and opaque property values."
|
||||
(let* ((map (make-sparse-keymap))
|
||||
(callback (let ((value 'opened)) (lambda () value)))
|
||||
(rich (propertize "Open 中" 'keymap map 'help-echo callback
|
||||
'ebox-content 17 'ebox-content-idx 2
|
||||
'ebox-content-owner 18 'ebox-content-owners '(18 17)))
|
||||
(blank (propertize " " 'display '(space :width (7))
|
||||
'ebox-content-owner 19)))
|
||||
;; Use the explicit one-line string representation for string/list parity.
|
||||
;; A raw empty list entry is covered separately below.
|
||||
(dolist (lines (list nil (list (ebox-lines-join '("")))
|
||||
(list rich) (list rich "" blank)))
|
||||
(dolist (height '(0 1 3 5))
|
||||
(dolist (align '(top center bottom))
|
||||
(let* ((spine (cl-loop for tail on lines
|
||||
collect (list tail (car tail) (cdr tail)
|
||||
(copy-sequence (car tail)))))
|
||||
(count (length lines))
|
||||
(offset (pcase align
|
||||
('bottom (- height count))
|
||||
('center (/ (- height count) 2))
|
||||
(_ 0)))
|
||||
(expected (ebox-box-line-test--previous-pad
|
||||
(and lines (ebox-lines-join lines)) height offset))
|
||||
(actual (ebox--pad-lines-vertical lines height offset))
|
||||
(output (ebox-lines-join actual)))
|
||||
(should (equal-including-properties output expected))
|
||||
(when (= height count) (should (eq actual lines)))
|
||||
(when-let* ((pos (string-match "Open" output)))
|
||||
(should (eq (get-text-property pos 'keymap output) map))
|
||||
(should (eq (get-text-property pos 'help-echo output) callback)))
|
||||
;; Only the string boundary grants independent mutable ownership.
|
||||
(when (> (length output) 0)
|
||||
(put-text-property 0 1 'help-echo "output-only" output)
|
||||
(aset output 0 ?Z))
|
||||
(dolist (record spine)
|
||||
(should (eq (car (nth 0 record)) (nth 1 record)))
|
||||
(should (eq (cdr (nth 0 record)) (nth 2 record)))
|
||||
(should (equal-including-properties (nth 1 record) (nth 3 record)))))))))
|
||||
(let* ((empty-line "")
|
||||
(lines (list empty-line))
|
||||
(padded (ebox--pad-lines-vertical lines 3)))
|
||||
(should (eq (ebox--pad-lines-vertical lines 1) lines))
|
||||
(should (eq (car padded) empty-line))
|
||||
(should (equal lines '("")))
|
||||
(should (= (ebox-string-height (ebox-lines-join padded)) 3)))
|
||||
(should-not (ebox--pad-lines-vertical nil 0))
|
||||
(should-not (ebox--pad-lines-vertical (list "A") 0)))
|
||||
|
||||
(ert-deftest ebox-box-line-flow-empty-content-retains-only-declared-chrome ()
|
||||
"Empty content and zero used height preserve only the declared chrome."
|
||||
(dolist (formatted '(nil "" "A\nB"))
|
||||
(dolist (height '(0 1 3))
|
||||
(let* ((input (ebox-build
|
||||
'(box :width (px 40) :height (lh 3) :overflow hidden :padding ((lh 1) (px 2))
|
||||
:border ((px 1) solid "#112233") (text "unused"))))
|
||||
(root (car (ebox-canonical-input-roots input)))
|
||||
(handle (ebox-node-source-handle root))
|
||||
(format-function (symbol-function 'ebox--format-content))
|
||||
(height-function (symbol-function 'ebox--content-height)))
|
||||
;; Isolate the decoration boundary, including zero used content height.
|
||||
(cl-letf (((symbol-function 'ebox--format-content)
|
||||
(lambda (box)
|
||||
(if (eq (ebox-node-source-handle box) handle)
|
||||
formatted
|
||||
(funcall format-function box))))
|
||||
((symbol-function 'ebox--content-height)
|
||||
(lambda (box natural &optional rendered)
|
||||
(if (eq (ebox-node-source-handle box) handle)
|
||||
height
|
||||
(funcall height-function box natural rendered)))))
|
||||
(let ((actual (ebox-box-line-test--reference-render input t))
|
||||
(expected (ebox-box-line-test--reference-render input)))
|
||||
(should (equal-including-properties actual expected))
|
||||
(when (= height 0)
|
||||
(should (= (ebox-string-height actual) 2)))))))))
|
||||
|
||||
(ert-deftest ebox-box-line-flow-zero-width-default-padding-is-a-line ()
|
||||
"Zero-width default blanks remain empty string lines at the list boundary."
|
||||
(dolist (padding '(((lh 0) (px 0)) ((lh 1) (px 2))))
|
||||
(let* ((input (ebox-build
|
||||
`(box :width (px 0) :height (lh 3) :overflow hidden
|
||||
:padding ,padding (text ""))))
|
||||
(actual (ebox-box-line-test--reference-render input t))
|
||||
(expected (ebox-box-line-test--reference-render input)))
|
||||
(should (equal-including-properties actual expected)))))
|
||||
|
||||
(ert-deftest ebox-box-line-flow-scroll-to-end-and-back-preserves-source ()
|
||||
"Real decorated scroll A→B→A keeps stored lines intact and reaches the end."
|
||||
(let* ((buffer (generate-new-buffer " *ebox-box-line-scroll*"))
|
||||
(map (make-sparse-keymap))
|
||||
(_key (define-key map [mouse-1] #'ignore))
|
||||
(text (mapconcat (lambda (index) (format "Open %02d" index))
|
||||
(number-sequence 0 9) "\n"))
|
||||
(input (ebox-build
|
||||
`(box :width (px 120) :height (lh 4) :overflow scroll
|
||||
:padding ((lh 0) (px 2)) :border ((px 1) solid "#112233")
|
||||
(text ,(propertize text 'keymap map 'help-echo "action")))))
|
||||
(ebox-runtime-idle-prewarm nil)
|
||||
(ebox-runtime-idle-reflow-cache-prewarm nil))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(ebox-render-to-buffer buffer input)
|
||||
(with-current-buffer buffer
|
||||
(let* ((root (plist-get (ebox--buffer-render-state buffer) :root-node))
|
||||
(id (plist-get root :region-id))
|
||||
(state (ebox--scroll-get-state id))
|
||||
(lines (plist-get state :content-lines))
|
||||
(spine (cl-loop for tail on lines
|
||||
collect (list tail (car tail) (cdr tail)
|
||||
(copy-sequence (car tail)))))
|
||||
(initial (buffer-string))
|
||||
(end (max 0 (- (length lines) (plist-get state :content-height)))))
|
||||
(should state)
|
||||
(goto-char (point-min))
|
||||
(ebox-scroll-down end)
|
||||
(let* ((current (ebox--scroll-get-state id))
|
||||
(render-state (ebox--buffer-render-state buffer))
|
||||
(fresh-input
|
||||
(ebox-canonical-input-create
|
||||
(list (plist-get render-state :root-node))
|
||||
(plist-get render-state :source-index))))
|
||||
(should (= end (max 0 (- (length (plist-get current :content-lines))
|
||||
(plist-get current :content-height)))))
|
||||
(should (= (plist-get current :scroll-offset) end))
|
||||
(should (> end 0))
|
||||
(should (string-match-p "Open 09" (buffer-string)))
|
||||
(should (equal-including-properties
|
||||
(buffer-string) (ebox-box-line-test--reference-render fresh-input))))
|
||||
(ebox-scroll-up end)
|
||||
(should (= (plist-get (ebox--scroll-get-state id) :scroll-offset) 0))
|
||||
(should (equal-including-properties (buffer-string) initial))
|
||||
(dolist (record spine)
|
||||
(should (eq (car (nth 0 record)) (nth 1 record)))
|
||||
(should (eq (cdr (nth 0 record)) (nth 2 record)))
|
||||
(should (equal-including-properties (nth 1 record) (nth 3 record)))))))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(provide 'ebox-box-line-flow-tests)
|
||||
;;; ebox-box-line-flow-tests.el ends here
|
||||
File diff suppressed because it is too large
Load Diff
@ -56,8 +56,15 @@
|
||||
(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 "Kinneyzhang/tp" source))
|
||||
(should (string-match-p "setup-emacs@" source))
|
||||
(should (string-match-p "'29.1'" source))
|
||||
(should (string-match-p "'30.2'" source))
|
||||
(should (string-match-p "make release-deps" source))
|
||||
(should (string-match-p "TP_DIR=" source))
|
||||
(should (string-match-p "ECSS_DIR=" source))
|
||||
(should (string-match-p "make release-check" source))
|
||||
(should (string-match-p "--with-ekp" source))
|
||||
(should (string-match-p "--native-module" 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))))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
286
tests/ebox-composite-tests.el
Normal file
286
tests/ebox-composite-tests.el
Normal file
@ -0,0 +1,286 @@
|
||||
;;; ebox-composite-tests.el --- Pixel row compositor tests -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Independent deterministic display metrics exercise cuts and coverage without
|
||||
;; requiring a GUI frame. Production measurement still handles EQ display runs.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
(require 'ebox)
|
||||
(require 'ebox-composite)
|
||||
|
||||
(defun ebox-composite-test--plain-width (text)
|
||||
"Measure TEXT with seven-pixel glyphs, zero-width marks, and fi shaping."
|
||||
(let ((width 0) (position 0))
|
||||
(dolist (char (string-to-list text))
|
||||
(unless (or (memq (get-char-code-property char 'general-category)
|
||||
'(Mn Mc Me))
|
||||
(= char #x200d)
|
||||
(and (>= char #x1f3fb) (<= char #x1f3ff)))
|
||||
(cl-incf width 7)))
|
||||
(while (string-match "fi" text position)
|
||||
(cl-decf width 3)
|
||||
(setq position (match-end 0)))
|
||||
width))
|
||||
|
||||
(defun ebox-composite-test--pixel-width (text)
|
||||
"Return TEXT's deterministic width, replacing each EQ display run once."
|
||||
(let ((position 0) (width 0) (end (length text)))
|
||||
(while (< position end)
|
||||
(let* ((display (get-text-property position 'display text))
|
||||
(next (next-single-property-change position 'display text end)))
|
||||
(cl-incf
|
||||
width
|
||||
(cond
|
||||
((and (consp display) (eq (car display) 'space))
|
||||
(car (plist-get (cdr display) :width)))
|
||||
((and (consp display) (eq (car display) 'image))
|
||||
(plist-get (cdr display) :width))
|
||||
((stringp display) (ebox-composite-test--pixel-width display))
|
||||
(t (ebox-composite-test--plain-width
|
||||
(substring-no-properties text position next)))))
|
||||
(setq position next)))
|
||||
width))
|
||||
|
||||
(defmacro ebox-composite-test--with-metrics (&rest body)
|
||||
"Run BODY using the independent pixel oracle and fresh measurement caches."
|
||||
(declare (indent 0) (debug t))
|
||||
`(cl-letf (((symbol-function 'string-pixel-width)
|
||||
#'ebox-composite-test--pixel-width))
|
||||
(ebox-clear-cache)
|
||||
(unwind-protect (progn ,@body)
|
||||
(ebox-clear-cache))))
|
||||
|
||||
(defun ebox-composite-test--assert-width (text width)
|
||||
"Assert TEXT is WIDTH pixels in the production and independent metrics."
|
||||
(should (= (ebox--string-pixel-width text) width))
|
||||
(should (= (ebox-composite-test--pixel-width text) width)))
|
||||
|
||||
(ert-deftest ebox-composite-slice-keeps-original-coordinates ()
|
||||
"Partial edge glyphs leave neutral blanks and do not shift retained text."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let* ((keymap (make-sparse-keymap))
|
||||
(owner (list 'owner))
|
||||
(source (propertize "ABCD" 'keymap keymap 'ebox-owner owner)))
|
||||
(dotimes (index (length source))
|
||||
(put-text-property index (1+ index) 'source-position index source))
|
||||
(let ((output (ebox-composite-slice source 3 24)))
|
||||
(should (equal (substring-no-properties output) " BC "))
|
||||
(ebox-composite-test--assert-width output 21)
|
||||
(should-not (get-text-property 0 'keymap output))
|
||||
(should-not (get-text-property 3 'keymap output))
|
||||
(should-not (get-text-property 0 'ebox-owner output))
|
||||
(should (eq (get-text-property 1 'keymap output) keymap))
|
||||
(should (eq (get-text-property 2 'ebox-owner output) owner))
|
||||
(should (= (get-text-property 1 'source-position output) 1))
|
||||
(should (= (get-text-property 2 'source-position output) 2))
|
||||
(should (= (ebox--string-pixel-width (substring output 0 1)) 4))))))
|
||||
|
||||
(ert-deftest ebox-composite-slice-fills-exhausted-and-empty-source ()
|
||||
"Intervals beyond source content retain their exact neutral canvas width."
|
||||
(ebox-composite-test--with-metrics
|
||||
(dolist (case '((nil 0 21) ("" 3 10) ("A" 10 21) ("A" 3 21)))
|
||||
(pcase-let ((`(,source ,start ,end) case))
|
||||
(let ((output (ebox-composite-slice source start end)))
|
||||
(ebox-composite-test--assert-width output (- end start))
|
||||
(should (string-blank-p output))
|
||||
(should-not (text-property-not-all 0 (length output)
|
||||
'keymap nil output)))))
|
||||
(should (equal (ebox-composite-slice "abc" 2 2) ""))))
|
||||
|
||||
(ert-deftest ebox-composite-slice-keeps-supported-graphemes-whole ()
|
||||
"Both cut edges preserve combining, variation, modifier, ZWJ, and flags."
|
||||
(ebox-composite-test--with-metrics
|
||||
(dolist (cluster '("é" "✈️" "👍🏽" "👩💻" "🇨🇳"))
|
||||
(let* ((source (concat "A" cluster "Z"))
|
||||
(right (+ 7 (ebox--string-pixel-width cluster)))
|
||||
(whole (ebox-composite-slice source 7 right))
|
||||
(cut-left (ebox-composite-slice source 8 (+ right 7)))
|
||||
(cut-right (ebox-composite-slice source 0 (1- right))))
|
||||
(should (equal (substring-no-properties whole) cluster))
|
||||
(should (equal (substring-no-properties cut-left) " Z"))
|
||||
(should (equal (substring-no-properties cut-right) "A "))
|
||||
(ebox-composite-test--assert-width whole (- right 7))
|
||||
(ebox-composite-test--assert-width cut-left (- right 1))
|
||||
(ebox-composite-test--assert-width cut-right (1- right))))))
|
||||
|
||||
(ert-deftest ebox-composite-slice-preserves-whole-prefix-shaping ()
|
||||
"A shaped fitting prefix is retained, and a detached glyph cannot spill."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let ((whole (ebox-composite-slice "fiZ" 0 11))
|
||||
(suffix (ebox-composite-slice "fiZ" 7 18)))
|
||||
(should (equal whole "fi"))
|
||||
(ebox-composite-test--assert-width whole 11)
|
||||
(should (equal (substring-no-properties suffix) " Z"))
|
||||
(should (= (ebox--string-pixel-width (substring suffix 0 1)) 4))
|
||||
(ebox-composite-test--assert-width suffix 11))))
|
||||
|
||||
(ert-deftest ebox-composite-slice-cuts-grouped-fractional-spaces ()
|
||||
"A shared space can be cut on both ends without copying nested properties."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let* ((height (list 20))
|
||||
(ascent (list 80))
|
||||
(display (list 'space :width '(15.5) :height height :ascent ascent))
|
||||
(keymap (make-sparse-keymap))
|
||||
(face (list :foreground "red"))
|
||||
(source (concat (propertize "abc" 'display display
|
||||
'keymap keymap 'face face) "Z"))
|
||||
(before (copy-sequence source))
|
||||
(output (ebox-composite-slice source 2.25 13.75))
|
||||
(result-display (get-text-property 0 'display output))
|
||||
(whole (ebox-composite-slice source 0 15.5)))
|
||||
(ebox-composite-test--assert-width output 11.5)
|
||||
(should (equal (substring-no-properties output) "abc"))
|
||||
(should (= (car (plist-get (cdr result-display) :width)) 11.5))
|
||||
(should (eq (plist-get (cdr result-display) :height) height))
|
||||
(should (eq (plist-get (cdr result-display) :ascent) ascent))
|
||||
(dotimes (index (length output))
|
||||
(should (eq (get-text-property index 'display output) result-display))
|
||||
(should (eq (get-text-property index 'keymap output) keymap))
|
||||
(should (eq (get-text-property index 'face output) face)))
|
||||
(should (eq (get-text-property 0 'display whole) display))
|
||||
(should (equal-including-properties before source))
|
||||
(should (equal (plist-get (cdr display) :width) '(15.5))))))
|
||||
|
||||
(ert-deftest ebox-composite-slice-never-splits-replacement-runs ()
|
||||
"Shared string and image replacements are kept whole, even across faces."
|
||||
(ebox-composite-test--with-metrics
|
||||
(dolist (display (list (copy-sequence "WXYZ")
|
||||
(list 'image :type 'xpm :width 28)))
|
||||
(let* ((keymap (make-sparse-keymap))
|
||||
(source (propertize "abc" 'display display 'keymap keymap)))
|
||||
(put-text-property 1 2 'face 'bold source)
|
||||
(let ((whole (ebox-composite-slice source 0 28))
|
||||
(middle (ebox-composite-slice source 7 21)))
|
||||
(should (equal-including-properties source whole))
|
||||
(should (eq (get-text-property 2 'display whole) display))
|
||||
(should (string-blank-p middle))
|
||||
(should-not (get-text-property 0 'keymap middle))
|
||||
(ebox-composite-test--assert-width whole 28)
|
||||
(ebox-composite-test--assert-width middle 14))))))
|
||||
|
||||
(ert-deftest ebox-composite-line-resolves-overlapping-opaque-a-b-c ()
|
||||
"The last placement wins, including its blank pixels, at stable positions."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let* ((base "0123456789")
|
||||
(placements (list (list :text "AAAAAA" :x 7)
|
||||
(list :text "BBBB" :x 21)
|
||||
(list :text " C " :x 28)))
|
||||
(output (ebox-composite-line base placements 70)))
|
||||
(should (equal (substring-no-properties output) "0AAB C 789"))
|
||||
(ebox-composite-test--assert-width output 70)
|
||||
(should (equal base "0123456789"))
|
||||
(should (equal placements '((:text "AAAAAA" :x 7)
|
||||
(:text "BBBB" :x 21)
|
||||
(:text " C " :x 28)))))))
|
||||
|
||||
(ert-deftest ebox-composite-line-preserves-underlay-right-coordinates ()
|
||||
"Cutting an underlay glyph cannot pull its right-hand neighbor leftward."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let ((output (ebox-composite-line
|
||||
"ABCD" (list (list :text (ebox-pixel-space 10) :x 3)) 28)))
|
||||
(should (equal (substring-no-properties output) " CD"))
|
||||
(ebox-composite-test--assert-width output 28)
|
||||
(should (= (ebox--string-pixel-width
|
||||
(substring output 0 (string-match "C" output))) 14)))))
|
||||
|
||||
(ert-deftest ebox-composite-line-handles-negative-and-clipped-placements ()
|
||||
"Canvas clipping retains original placement coordinates at both edges."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let ((output
|
||||
(ebox-composite-line
|
||||
"012345" (list (list :text "ABC" :x -10)
|
||||
(list :text "XY" :x 35)
|
||||
(list :text "hidden" :x 100)) 42)))
|
||||
(should (equal (substring-no-properties output) " C 234X"))
|
||||
(ebox-composite-test--assert-width output 42))))
|
||||
|
||||
(ert-deftest ebox-composite-line-hidden-boundaries-do-not-split-top-glyph ()
|
||||
"A hidden lower placement cannot fragment a fully visible replacement."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let* ((display (copy-sequence "ABC"))
|
||||
(top (propertize "source" 'display display))
|
||||
(output (ebox-composite-line
|
||||
nil (list (list :text (ebox-pixel-space 2) :x 4)
|
||||
(list :text (ebox-pixel-space 3) :x 10)
|
||||
(list :text top :x 0)) 21)))
|
||||
(should (equal-including-properties output top))
|
||||
(should (eq (get-text-property 0 'display output) display))
|
||||
(ebox-composite-test--assert-width output 21))))
|
||||
|
||||
(ert-deftest ebox-composite-line-keeps-adjacent-eq-runs-separate ()
|
||||
"Independent touching placements sharing a display still occupy two runs."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let* ((display (list 'space :width '(10)))
|
||||
(keymap (make-sparse-keymap))
|
||||
(text (propertize "ab" 'display display 'keymap keymap))
|
||||
(output (ebox-composite-line
|
||||
nil (list (list :text text :x 0)
|
||||
(list :text text :x 10)) 20)))
|
||||
(ebox-composite-test--assert-width output 20)
|
||||
(should (eq (get-text-property 0 'display output) display))
|
||||
(should (eq (get-text-property (1- (length output)) 'display output)
|
||||
display))
|
||||
(should-not (get-text-property 2 'keymap output))
|
||||
(should (eq (get-text-property 3 'keymap output) keymap)))))
|
||||
|
||||
(ert-deftest ebox-composite-line-keeps-independent-shaping-extents ()
|
||||
"Neighboring sources cannot form a ligature that moves the right edge."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let ((output (ebox-composite-line
|
||||
"fZ" (list (list :text "i" :x 7)) 14)))
|
||||
(ebox-composite-test--assert-width output 14)
|
||||
(should (equal (substring-no-properties output) "f i"))
|
||||
(should (equal (get-text-property 1 'display output)
|
||||
'(space :width (0)))))))
|
||||
|
||||
(ert-deftest ebox-composite-slice-full-line-keeps-zero-width-source-units ()
|
||||
"A complete line retains even a zero-width source's display height."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let* ((display (list 'space :width '(0) :height '(24)))
|
||||
(source (concat "A" (propertize "tail" 'display display)))
|
||||
(output (ebox-composite-slice source 0 7)))
|
||||
(should (equal-including-properties output source))
|
||||
(should (eq (get-text-property 1 'display output) display))
|
||||
(ebox-composite-test--assert-width output 7))))
|
||||
|
||||
(ert-deftest ebox-composite-line-uses-latest-base-after-placement-removal ()
|
||||
"Composition retains no stale underlay state between successive calls."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let* ((placements (list (list :text "XX" :x 7)))
|
||||
(old (ebox-composite-line "abcdef" placements 42))
|
||||
(updated (ebox-composite-line "123456" placements 42))
|
||||
(restored (ebox-composite-line "123456" nil 42)))
|
||||
(should (equal old "aXXdef"))
|
||||
(should (equal updated "1XX456"))
|
||||
(should (equal restored "123456"))
|
||||
(dolist (output (list old updated restored))
|
||||
(ebox-composite-test--assert-width output 42)))))
|
||||
|
||||
(ert-deftest ebox-composite-line-neutral-gaps-have-no-source-properties ()
|
||||
"A short or absent base does not lend interaction properties to root fill."
|
||||
(ebox-composite-test--with-metrics
|
||||
(let* ((keymap (make-sparse-keymap))
|
||||
(base (propertize "A" 'keymap keymap 'help-echo "base"))
|
||||
(output (ebox-composite-line base nil 21)))
|
||||
(ebox-composite-test--assert-width output 21)
|
||||
(should (eq (get-text-property 0 'keymap output) keymap))
|
||||
(should-not (get-text-property 1 'keymap output))
|
||||
(should-not (get-text-property 1 'help-echo output)))
|
||||
(ebox-composite-test--assert-width (ebox-composite-line nil nil 12.5) 12.5)))
|
||||
|
||||
(ert-deftest ebox-composite-rejects-invalid-bounds-and-lines ()
|
||||
"Invalid extents and multiline inputs fail before source mutation."
|
||||
(dolist (bounds '((-1 2) (3 2) (nil 1) (0 nope) (0 1.0e+INF)))
|
||||
(should-error (apply #'ebox-composite-slice "abc" bounds)))
|
||||
(should-error (ebox-composite-slice "a\nb" 0 1))
|
||||
(should-error (ebox-composite-line "a\nb" nil 10))
|
||||
(should-error (ebox-composite-line nil '((:text "x" :x nope)) 10))
|
||||
(should-error (ebox-composite-line nil '((:text "x\ny" :x 0)) 10))
|
||||
(should-error (ebox-composite-line nil nil -1))
|
||||
(should-error (ebox-composite-line nil '(broken . list) 10)))
|
||||
|
||||
(provide 'ebox-composite-tests)
|
||||
;;; ebox-composite-tests.el ends here
|
||||
File diff suppressed because it is too large
Load Diff
496
tests/ebox-css-flex-grid-tests.el
Normal file
496
tests/ebox-css-flex-grid-tests.el
Normal file
@ -0,0 +1,496 @@
|
||||
;;; ebox-css-flex-grid-tests.el --- CSS sizes in formatting contexts -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Regression coverage for delayed relative sizes and the author/used boundary.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
(require 'ebox)
|
||||
|
||||
(ert-deftest ebox-css-flex-grid-empty-auto-height-is-zero ()
|
||||
"Empty formatting contexts and empty auto-sized children have no height."
|
||||
(dolist (form '((flex) (grid)
|
||||
(flex (box)) (flex (box) (box))
|
||||
(flex :flex-direction column (box) (box))
|
||||
(grid (box)) (grid (box) (box))
|
||||
(grid :grid-auto-rows (lh 3))
|
||||
(grid :grid-auto-columns (px 40) :grid-auto-rows (lh 3))
|
||||
(flex :row-gap (lh 2) :column-gap (px 8))))
|
||||
(let ((rendered (ebox-render (ebox-build form))))
|
||||
(should (equal rendered ""))
|
||||
(should (= (ebox-string-height rendered) 0)))))
|
||||
|
||||
(ert-deftest ebox-css-flex-grid-empty-items-preserve-gap-and-explicit-height ()
|
||||
"Empty children count toward gaps; explicit dimensions remain visible."
|
||||
(dolist (case '(((flex :flex-direction column :row-gap (lh 2)
|
||||
(box) (box)) 2)
|
||||
((flex :flex-direction column :row-gap (lh 2)
|
||||
(box) (box "A") (box)) 5)
|
||||
((flex :width (px 40) :flex-wrap wrap :row-gap (lh 2)
|
||||
(box :width (px 40)) (box :width (px 40))) 2)
|
||||
((flex :column-gap (px 8) (box) (box)) 0)
|
||||
((flex :height (lh 3)) 3)
|
||||
((grid :height (lh 3)) 3)
|
||||
((grid :grid-template-columns ((px 20))
|
||||
:row-gap (lh 2) (box) (box)) 2)
|
||||
((grid :grid-template-rows ((lh 0) (lh 0))
|
||||
:row-gap (lh 2)) 2)
|
||||
((grid :grid-template-rows ((lh 2) (lh 0) (lh 3))
|
||||
:row-gap (lh 1)) 7)
|
||||
((grid :height (lh 4)
|
||||
:grid-template-columns ((px 20))
|
||||
:grid-template-rows ((lh 0) (fr 1))
|
||||
:row-gap (lh 1) (box) (box)) 4)))
|
||||
(ert-info ((format "Formatting case: %S" (car case)))
|
||||
(let ((rendered (ebox-render (ebox-build (car case)))))
|
||||
(should (= (ebox-string-height rendered) (cadr case)))))))
|
||||
|
||||
(ert-deftest ebox-css-flex-grid-empty-items-do-not-add-content-lines ()
|
||||
"An empty sibling adds no line before or after real content."
|
||||
(dolist (form '((flex (box) (box "A") (box))
|
||||
(flex :flex-direction column (box) (box "A") (box))
|
||||
(grid :grid-template-columns ((px 20))
|
||||
(box) (box "A") (box))))
|
||||
(let ((rendered (ebox-render (ebox-build form))))
|
||||
(should (= (ebox-string-height rendered) 1))
|
||||
(should (string-match-p "A" rendered)))))
|
||||
|
||||
(ert-deftest ebox-css-flex-grid-empty-items-keep-their-inline-size ()
|
||||
"A zero-height item's declared width still positions its visible sibling."
|
||||
(dolist (form '((flex (box :width (px 20)) (box "A"))
|
||||
(grid :width max-content :grid-template-columns (auto auto)
|
||||
(box :width (px 20)) (box "A"))))
|
||||
(let* ((rendered (ebox-render (ebox-build form)))
|
||||
(position (string-match "A" rendered)))
|
||||
(should position)
|
||||
(should (= (ebox-string-height rendered) 1))
|
||||
(should (= (ebox--string-pixel-width (substring rendered 0 position)) 20)))))
|
||||
|
||||
(ert-deftest ebox-css-flex-grid-empty-composites-keep-their-inline-size ()
|
||||
"A nested empty layout keeps geometry without introducing a text line."
|
||||
(dolist (case '(((row :item-gap (px 3)
|
||||
(box :width (px 10)) (box :width (px 4))) 17)
|
||||
((column (box :width (px 10))) 10)
|
||||
((flex (box :width (px 10))) 10)
|
||||
((flex :flex-direction column (box :width (px 10))) 10)
|
||||
((grid :grid-template-columns ((px 10) (px 4))
|
||||
:column-gap (px 3) (box) (box)) 17)
|
||||
((box (box :width (px 10))) 10)))
|
||||
(dolist (outer '(row flex grid))
|
||||
(ert-info ((format "Nested empty layout: %S in %S" (car case) outer))
|
||||
(let* ((form (append (list outer :width '(px 60))
|
||||
(when (eq outer 'grid)
|
||||
'(:grid-template-columns (auto auto)
|
||||
:justify-items start))
|
||||
(list (car case) '(box :width (px 5) "B"))))
|
||||
(rendered (ebox-render (ebox-build form)))
|
||||
(position (string-match "B" rendered)))
|
||||
(should position)
|
||||
(should (= (ebox-string-height rendered) 1))
|
||||
(should (= (ebox--string-pixel-width (substring rendered 0 position))
|
||||
(cadr case)))
|
||||
(should (= (ebox-string-height
|
||||
(ebox-render (ebox-build (car case))))
|
||||
0)))))))
|
||||
|
||||
(ert-deftest ebox-css-flex-grid-empty-composite-widths-follow-render-context ()
|
||||
"Distinct empty layouts keep separate widths and resolve each new viewport."
|
||||
(let ((tree (ebox-build
|
||||
'(row :width (px 100)
|
||||
(grid :grid-template-columns ((px 23)))
|
||||
(column (box :width (vw 25)))
|
||||
(box :width (px 5) "B")))))
|
||||
(dolist (case '((80 43) (120 53) (80 43)))
|
||||
(let* ((ebox-viewport-width (car case))
|
||||
(rendered (ebox-render tree))
|
||||
(position (string-match "B" rendered)))
|
||||
(should position)
|
||||
(should (= (ebox-string-height rendered) 1))
|
||||
(should (= (ebox--string-pixel-width (substring rendered 0 position))
|
||||
(cadr case)))))))
|
||||
|
||||
(ert-deftest ebox-css-flex-grid-reject-cross-axis-config-units ()
|
||||
"Typed configs reject cross-axis lengths, including nested track functions."
|
||||
(dolist (value '((px 0) (ch 2) (vw 5)
|
||||
(calc (* 0 (px 2)))
|
||||
(min (lh 2) (max (lh 1) (px 2)))))
|
||||
(should-error (ebox-flex-layout-create :row-gap value))
|
||||
(should-error (ebox-grid-layout-create :grid-template-rows (list value)))
|
||||
(should-error (ebox-grid-layout-create :grid-auto-rows value)))
|
||||
(dolist (value '((lh 0) (vh 2)
|
||||
(clamp (px 1) (calc (+ (px 2) (lh 1))) (px 10))))
|
||||
(should-error (ebox-flex-layout-create :column-gap value))
|
||||
(should-error (ebox-grid-layout-create :grid-template-columns (list value)))
|
||||
(should-error (ebox-grid-layout-create :grid-auto-columns value)))
|
||||
(should-error
|
||||
(ebox-grid-layout-create :grid-template-rows
|
||||
'((repeat 2 (minmax (lh 0) (px 20))))))
|
||||
(should-error
|
||||
(ebox-grid-layout-create :grid-template-columns
|
||||
'((repeat 2 (minmax (px 0) (vh 20)))))))
|
||||
|
||||
(ert-deftest ebox-css-flex-basis-validates-parent-axis-before-render ()
|
||||
"Building a Flex parent validates item bases against its resolved direction."
|
||||
(dolist (direction '(row row-reverse column column-reverse))
|
||||
(let ((inline (memq direction '(row row-reverse))))
|
||||
(dolist (basis (if inline '((lh 0) (vh 5) (calc (* 0 (lh 1))))
|
||||
'((px 0) (ch 2) (vw 5) (calc (* 0 (px 1))))))
|
||||
(should-error
|
||||
(ebox-build `(flex :flex-direction ,direction
|
||||
(box :flex-basis ,basis)))))
|
||||
(dolist (basis (if inline '((px 0) (ch 2) (vw 5) (% 20))
|
||||
'((lh 0) (vh 5) (% 20))))
|
||||
(should
|
||||
(ebox-build `(flex :flex-direction ,direction
|
||||
(box :flex-basis ,basis))))))
|
||||
(should (ebox-build `(flex :flex-direction ,direction (box :flex 1)))))
|
||||
(should-error
|
||||
(ebox-build '(flex :flex-flow (column nowrap)
|
||||
(box :flex (1 1 (px 0))))))
|
||||
(should
|
||||
(ebox-build '(flex :flex-direction column
|
||||
(flex :flex-basis (lh 2) :flex-direction row
|
||||
(box :flex-basis (px 20)))))))
|
||||
|
||||
(ert-deftest ebox-css-flex-basis-validates-computed-parent-context ()
|
||||
"Stylesheet directions and bases are validated before layout starts."
|
||||
(dolist (rule '(("#parent" (:flex-direction column))
|
||||
("#item" (:flex-basis (lh 2)))))
|
||||
(let ((ebox-style-stylesheet (ecss-stylesheet-create))
|
||||
(original (symbol-function 'ebox--render-flex-children))
|
||||
(layout-count 0))
|
||||
(ebox-style-add-rule (car rule) (cadr rule))
|
||||
(cl-letf (((symbol-function 'ebox--render-flex-children)
|
||||
(lambda (&rest arguments)
|
||||
(cl-incf layout-count)
|
||||
(apply original arguments))))
|
||||
(should-error
|
||||
(ebox-render
|
||||
(ebox-build
|
||||
(if (equal (car rule) "#parent")
|
||||
'(flex :id "parent" (box :id "item" :flex-basis (px 10) "A"))
|
||||
'(flex :id "parent" (box :id "item" "A")))))
|
||||
:type 'ebox-size-error))
|
||||
(should (= layout-count 0)))))
|
||||
|
||||
(ert-deftest ebox-css-flex-basis-invalid-context-update-rolls-back ()
|
||||
"Changing either side of a parent/basis relation fails before publication."
|
||||
(dolist (case '((nil "parent" :flex-direction column)
|
||||
(nil "item" :flex-basis (lh 2))
|
||||
(t "parent" :flex-direction column)
|
||||
(t "item" :flex-basis (lh 2))))
|
||||
(let ((buffer (generate-new-buffer " *ebox-flex-axis-update*"))
|
||||
(ebox-style-stylesheet (ecss-stylesheet-create)))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(when (car case)
|
||||
(ebox-style-add-rule "flex" '(:color "red")))
|
||||
(ebox-render-to-buffer
|
||||
buffer (ebox-build '(flex :id "parent" :width (px 40)
|
||||
(box :id "item" :flex-basis (px 10) "A"))))
|
||||
(let* ((before (with-current-buffer buffer (buffer-string)))
|
||||
(report (ebox-buffer-update-report buffer))
|
||||
(state (ebox--buffer-render-state buffer))
|
||||
(runtime-revision (plist-get state :runtime-revision))
|
||||
(surface-revision (tp-surface-revision (plist-get state :surface)))
|
||||
(original (symbol-function 'ebox--render-flex-children))
|
||||
(layout-count 0))
|
||||
(cl-letf (((symbol-function 'ebox--render-flex-children)
|
||||
(lambda (&rest arguments)
|
||||
(cl-incf layout-count)
|
||||
(apply original arguments))))
|
||||
(should-error
|
||||
(apply #'ebox-region-update
|
||||
(ebox-region-resolve buffer (cadr case)) (cddr case))
|
||||
:type 'ebox-size-error))
|
||||
(should (= layout-count 0))
|
||||
(should (equal-including-properties
|
||||
before (with-current-buffer buffer (buffer-string))))
|
||||
(should (equal report (ebox-buffer-update-report buffer)))
|
||||
(should (= runtime-revision
|
||||
(plist-get (ebox--buffer-render-state buffer)
|
||||
:runtime-revision)))
|
||||
(should (= surface-revision
|
||||
(tp-surface-revision (plist-get state :surface)))))
|
||||
(should (ebox-region-update (ebox-region-resolve buffer "item")
|
||||
:flex-basis '(px 12))))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer))))))
|
||||
|
||||
(ert-deftest ebox-css-flex-basis-validates-final-batched-context ()
|
||||
"A batch can change both parent axis and child basis as one valid update."
|
||||
(let ((buffer (generate-new-buffer " *ebox-flex-axis-batch*"))
|
||||
(ebox-style-stylesheet (ecss-stylesheet-create)))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(ebox-render-to-buffer
|
||||
buffer (ebox-build '(flex :id "parent" :width (px 40)
|
||||
(box :id "item" :flex-basis (px 10) "A"))))
|
||||
(ebox-incremental-begin-batch buffer)
|
||||
(ebox-region-update (ebox-region-resolve buffer "parent")
|
||||
:flex-direction 'column)
|
||||
(ebox-region-update (ebox-region-resolve buffer "item")
|
||||
:flex-basis '(lh 2))
|
||||
(should (ebox-incremental-flush buffer))
|
||||
(should (= (ebox-string-height
|
||||
(with-current-buffer buffer (buffer-string))) 2)))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest ebox-css-flex-basis-validates-final-class-context ()
|
||||
"Class selectors compute the parent axis and child basis before validation."
|
||||
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
|
||||
(ebox-style-add-rule "#item" '(:flex-basis (px 10)))
|
||||
(ebox-style-add-rule ".vertical" '(:flex-direction column))
|
||||
(ebox-style-add-rule ".vertical > #item" '(:flex-basis (lh 2)))
|
||||
(dolist (case '((nil 1) (("vertical") 2) (nil 1)))
|
||||
(should (= (ebox-string-height
|
||||
(ebox-render
|
||||
(ebox-build `(flex :class ,(car case) :width (px 40)
|
||||
(box :id "item" "A")))))
|
||||
(cadr case))))))
|
||||
|
||||
(ert-deftest ebox-css-flex-grid-configs-preserve-size-expressions ()
|
||||
"Layout configs keep context-dependent author sizes until layout."
|
||||
(let* ((gap '(calc (+ (% 10) (px 2))))
|
||||
(flex (ebox-layout-config-props
|
||||
(ebox-flex-layout-create :column-gap gap :row-gap '(lh 1))))
|
||||
(grid (ebox-layout-config-props
|
||||
(ebox-grid-layout-create
|
||||
:column-gap gap :grid-template-columns
|
||||
'((repeat 2 (% 50)))))))
|
||||
(should (equal (plist-get flex :column-gap) gap))
|
||||
(should (equal (plist-get flex :row-gap) '(lh 1)))
|
||||
(should (equal (plist-get grid :grid-template-columns)
|
||||
'((:kind fixed :size (% 50))
|
||||
(:kind fixed :size (% 50)))))
|
||||
(should (ebox-flex-layout-config-props-p flex))
|
||||
(should (ebox-grid-layout-config-props-p grid))))
|
||||
|
||||
(ert-deftest ebox-css-flex-grid-reject-implicit-size-syntax ()
|
||||
"Unitless values and parameterized fit-content never enter configs."
|
||||
(dolist (value '(2 (2) viewport (viewport-height) (fit-content (ch 80))))
|
||||
(should-error (ebox-flex-layout-create :column-gap value))
|
||||
(should-error (ebox-flex-layout-create :row-gap value))
|
||||
(should-error
|
||||
(ebox-grid-layout-create :grid-template-columns (list value)))))
|
||||
|
||||
(ert-deftest ebox-css-flex-grid-gaps-use-definite-containing-axis ()
|
||||
"Percentages use each layout axis and unresolved gap percentages are zero."
|
||||
(cl-letf (((symbol-function 'ebox--size-line-height) (lambda (&optional _) 10)))
|
||||
(should (= (ebox--layout-used-gap '(% 25) 'width 200) 50))
|
||||
(should (= (ebox--layout-used-gap '(% 25) 'height 10) 2))
|
||||
(let ((ebox--css-containing-height 1000))
|
||||
(should (= (ebox--layout-used-gap '(% 25) 'height nil) 0)))
|
||||
(should (= (ebox--layout-used-gap '(calc (+ (% 25) (px 5)))
|
||||
'width nil) 5))
|
||||
(should (= (ebox--layout-used-gap '(calc (+ (px 5) (lh 1)))
|
||||
'height 20) 1))))
|
||||
|
||||
(ert-deftest ebox-css-grid-relative-tracks-reresolve-and-share-rounding ()
|
||||
"Track expressions survive resizing and do not each lose their fractions."
|
||||
(let ((tracks (ebox-grid--normalize-tracks '((% 50) (% 50)) 'columns))
|
||||
(rendered (make-hash-table :test 'eq)))
|
||||
(should (equal (ebox-grid--resolve-sizes tracks 2 'columns
|
||||
nil rendered 0 101)
|
||||
'(50 51)))
|
||||
(should (equal (ebox-grid--resolve-sizes tracks 2 'columns
|
||||
nil rendered 0 201)
|
||||
'(100 101)))
|
||||
(should (equal (plist-get (car tracks) :size) '(% 50)))))
|
||||
|
||||
(ert-deftest ebox-css-grid-minmax-minimum-wins-after-unit-resolution ()
|
||||
"A definite minmax maximum below its minimum becomes the minimum."
|
||||
(let ((track (ebox-grid--normalize-track
|
||||
'(minmax (% 60) (px 40)) 'columns)))
|
||||
(should (equal (ebox-grid--resolve-sizes
|
||||
(list track) 1 'columns nil (make-hash-table) 0 100)
|
||||
'(60)))))
|
||||
|
||||
(ert-deftest ebox-css-grid-percent-height-without-containing-height-is-auto ()
|
||||
"An indefinite percentage row sizes from content instead of viewport height."
|
||||
(let* ((node 'cell)
|
||||
(entries (list (list :node node :row 1 :row-span 1)))
|
||||
(rendered (make-hash-table :test 'eq))
|
||||
(tracks (ebox-grid--normalize-tracks '((% 100)) 'rows))
|
||||
(ebox-viewport-height 100))
|
||||
(puthash node "first\nsecond" rendered)
|
||||
(should (equal (ebox-grid--resolve-sizes
|
||||
tracks 1 'rows entries rendered 0 nil) '(2)))))
|
||||
|
||||
(ert-deftest ebox-css-flex-grid-render-public-size-expressions ()
|
||||
"The public DSL renders both formatting contexts with canonical units."
|
||||
(dolist (dsl '((flex :width (px 100) :height (lh 3)
|
||||
:column-gap (px 4)
|
||||
(box :min-width (px 0) :flex-basis (% 50) "A")
|
||||
(box :min-width (px 0) :flex-basis (% 50) "B"))
|
||||
(grid :width (px 100) :height (lh 3)
|
||||
:grid-template-columns ((fr 1) (fr 1))
|
||||
:column-gap (px 4)
|
||||
(box "A") (box "B"))))
|
||||
(let ((rendered (ebox-render (ebox-build dsl))))
|
||||
(should (= (ebox--string-max-pixel-width rendered) 100))
|
||||
(should (= (ebox-string-height rendered) 3))
|
||||
(should (string-match-p "A.*B" rendered)))))
|
||||
|
||||
(ert-deftest ebox-css-flex-column-fractional-bases-round-at-final-boundaries ()
|
||||
"Two percentage bases divide an odd height without fractional line errors."
|
||||
(let* ((rendered
|
||||
(ebox-render
|
||||
(ebox-build
|
||||
'(flex :flex-direction column :width (px 50) :height (lh 5)
|
||||
(box :min-height (lh 0) :flex-basis (% 50) "A")
|
||||
(box :min-height (lh 0) :flex-basis (% 50) "B")))))
|
||||
(lines (ebox-string-lines rendered)))
|
||||
(should (= (length lines) 5))
|
||||
(should (string-match-p "A" (nth 0 lines)))
|
||||
(should (string-match-p "B" (nth 2 lines)))))
|
||||
|
||||
(ert-deftest ebox-css-size-quantize-preserves-extents-and-zero ()
|
||||
"Boundary rounding preserves zero sizes and handles floating-point residue."
|
||||
(should (equal (ebox-size-quantize '(0 0.5 0 0.5 0)) '(0 0 0 1 0)))
|
||||
(should (equal (ebox-size-quantize '(100.49999999999999 100.49999999999999))
|
||||
'(100 101)))
|
||||
(should-error (ebox-size-quantize '(2 -1))))
|
||||
|
||||
(ert-deftest ebox-css-flex-automatic-minimum-respects-overflow-and-explicit-zero ()
|
||||
"Automatic minima are content-based only for non-scrollable overflow."
|
||||
(dolist (case '((visible auto t) (scroll auto nil) (hidden auto nil)
|
||||
(visible (px 0) nil)))
|
||||
(let* ((input (ebox-build `(box :overflow ,(nth 0 case)
|
||||
:min-width ,(nth 1 case)
|
||||
:wrap-mode none "UNBREAKABLE")))
|
||||
(node (car (ebox-canonical-input--nodes input)))
|
||||
(rendered (ebox-render input))
|
||||
(minimum (ebox--flex-box-min-main node rendered 'row)))
|
||||
(if (nth 2 case)
|
||||
(should (= minimum (ebox--string-pixel-width "UNBREAKABLE")))
|
||||
(should (= minimum 0))))))
|
||||
|
||||
(ert-deftest ebox-css-flex-column-automatic-minimum-preserves-visible-content ()
|
||||
"Column auto minima retain content height when overflow is visible."
|
||||
(let* ((input (ebox-build '(box :overflow visible :min-height auto "A\nB\nC")))
|
||||
(node (car (ebox-canonical-input--nodes input))))
|
||||
(should (= (ebox--flex-box-min-main node (ebox-render input) 'column) 3))))
|
||||
|
||||
(ert-deftest ebox-css-grid-fractions-freeze-content-minima-before-sharing-space ()
|
||||
"Flexible tracks honor automatic minima and still fill remaining space."
|
||||
(let* ((first-input (ebox-build '(box :min-width auto :overflow visible
|
||||
:wrap-mode none "abcdefgh")))
|
||||
(second-input (ebox-build '(box :min-width auto :overflow visible
|
||||
:wrap-mode none "X")))
|
||||
(first (car (ebox-canonical-input--nodes first-input)))
|
||||
(second (car (ebox-canonical-input--nodes second-input)))
|
||||
(entries (list (list :node first :column 1 :column-span 1)
|
||||
(list :node second :column 2 :column-span 1)))
|
||||
(rendered (make-hash-table :test 'eq))
|
||||
(tracks (ebox-grid--normalize-tracks '((fr 1) (fr 1)) 'columns))
|
||||
(minimum (ebox--string-pixel-width "abcdefgh"))
|
||||
(available (+ minimum 2)))
|
||||
(puthash first (ebox-render first-input) rendered)
|
||||
(puthash second (ebox-render second-input) rendered)
|
||||
(should (equal (ebox-grid--resolve-sizes
|
||||
tracks 2 'columns entries rendered 0 available)
|
||||
(list minimum 2)))
|
||||
(should-not (ebox-grid--content-independent-columns-p
|
||||
tracks 2 available (list first second)))))
|
||||
|
||||
(ert-deftest ebox-css-grid-percent-child-sizes-use-final-cell ()
|
||||
"Relative children re-render once the grid area has definite dimensions."
|
||||
(let ((original (symbol-function 'ebox--render-with-cache)) sizes)
|
||||
(cl-letf (((symbol-function 'ebox--render-with-cache)
|
||||
(lambda (node &rest arguments)
|
||||
(let ((result (apply original node arguments)))
|
||||
(when (and (equal (plist-get node :width) '(% 50))
|
||||
(equal (plist-get node :height) '(% 50)))
|
||||
(push (list (ebox--string-max-pixel-width result)
|
||||
(ebox-string-height result)) sizes))
|
||||
result))))
|
||||
(ebox-render
|
||||
(ebox-build '(grid :width (px 200) :height (lh 12)
|
||||
:grid-template-columns ((px 100) (px 100))
|
||||
:grid-template-rows ((lh 6))
|
||||
:align-content start
|
||||
(box :width (% 50) :height (% 50) "A")
|
||||
(box "B")))))
|
||||
(should (= (caar sizes) 50))
|
||||
(should (= (cadar sizes) 3))))
|
||||
|
||||
(ert-deftest ebox-css-grid-indefinite-fractions-size-from-content ()
|
||||
"An intrinsic Grid width does not collapse fractional tracks to zero."
|
||||
(let ((rendered (make-hash-table :test 'eq))
|
||||
(tracks (ebox-grid--normalize-tracks '((fr 1) (fr 1)) 'columns))
|
||||
(entries '((:node first :column 1 :column-span 1)
|
||||
(:node second :column 2 :column-span 1))))
|
||||
(puthash 'first "abcdefgh" rendered)
|
||||
(puthash 'second "X" rendered)
|
||||
(let ((width (ebox--string-pixel-width "abcdefgh")))
|
||||
(should (equal (ebox-grid--resolve-sizes
|
||||
tracks 2 'columns entries rendered 0 nil)
|
||||
(list width width))))))
|
||||
|
||||
(ert-deftest ebox-css-grid-cyclic-percentage-gap-resolves-after-intrinsic-size ()
|
||||
"A cyclic Grid gap uses the intrinsic extent and can overflow it."
|
||||
(let ((columns (ebox-grid--final-content-layout
|
||||
'(40 40) '(% 25) 'width nil 'start nil))
|
||||
(rows (ebox-grid--final-content-layout
|
||||
'(4 4) '(% 25) 'height nil 'start nil)))
|
||||
(should (equal (plist-get columns :sizes) '(40 40)))
|
||||
(should (= (plist-get columns :between) 20))
|
||||
(should (= (plist-get columns :target) 80))
|
||||
(should (= (plist-get rows :between) 2))
|
||||
(should (= (plist-get rows :target) 8))))
|
||||
|
||||
(ert-deftest ebox-css-flex-percentage-edges-use-container-before-item-allocation ()
|
||||
"Assigned item widths do not become percentage padding and margin bases."
|
||||
(let* ((input (ebox-build
|
||||
'(flex :width (px 200)
|
||||
(box :flex-basis (px 60)
|
||||
:padding ((lh 0) (% 10))
|
||||
:margin ((lh 0) (% 5)) "X"))))
|
||||
(child (car (ebox-tree-layout-children
|
||||
(car (ebox-canonical-input--nodes input)))))
|
||||
(rendered (ebox-render input))
|
||||
(position (string-match "X" rendered)))
|
||||
(should position)
|
||||
(should (= (ebox--string-pixel-width (substring rendered 0 position)) 30))
|
||||
(should (equal (plist-get child :padding-left-pixel) '(% 10)))
|
||||
(should (equal (plist-get child :margin-left-pixel) '(% 5)))))
|
||||
|
||||
(ert-deftest ebox-css-grid-cyclic-gap-overflows-intrinsic-box-without-growing-it ()
|
||||
"Cyclic gaps overflow the auto-sized box while hidden overflow clips them."
|
||||
(dolist (overflow '(hidden visible))
|
||||
(let* ((vertical
|
||||
(ebox-render
|
||||
(ebox-build
|
||||
`(grid :width (px 40) :overflow ,overflow
|
||||
:grid-template-columns ((px 40))
|
||||
:grid-template-rows ((lh 4) (lh 4))
|
||||
:row-gap (% 25) (box "A") (box "B")))))
|
||||
(horizontal
|
||||
(ebox-render
|
||||
(ebox-build
|
||||
`(grid :width max-content :overflow ,overflow
|
||||
:grid-template-columns ((px 40) (px 40))
|
||||
:column-gap (% 25) (box "A") (box "B"))))))
|
||||
(should (= (ebox-string-height vertical) (if (eq overflow 'hidden) 8 10)))
|
||||
(should (= (ebox--rendered-intrinsic-size horizontal :width) 80))
|
||||
(should (= (ebox--string-max-pixel-width horizontal)
|
||||
(if (eq overflow 'hidden) 80 100))))))
|
||||
|
||||
(ert-deftest ebox-css-flex-preserves-nested-grid-intrinsic-extents ()
|
||||
"A Grid child's cyclic gap spill does not enlarge its Flex layout size."
|
||||
(let* ((input
|
||||
(ebox-build
|
||||
'(flex :width (px 40) :flex-direction column :overflow visible
|
||||
(grid :overflow visible
|
||||
:grid-template-columns ((px 40))
|
||||
:grid-template-rows ((lh 4) (lh 4))
|
||||
:row-gap (% 25) (box "A") (box "B"))
|
||||
(box "Z"))))
|
||||
(rendered (ebox-render input)))
|
||||
(should (= (ebox--rendered-intrinsic-size rendered :height) 9))
|
||||
(should (= (ebox-string-height rendered) 11))))
|
||||
|
||||
(provide 'ebox-css-flex-grid-tests)
|
||||
;;; ebox-css-flex-grid-tests.el ends here
|
||||
565
tests/ebox-css-layout-tests.el
Normal file
565
tests/ebox-css-layout-tests.el
Normal file
@ -0,0 +1,565 @@
|
||||
;;; ebox-css-layout-tests.el --- CSS used-size regression tests -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Exercise the boundary between pure CSS lengths and buffer layout geometry.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
(require 'ebox-fixtures)
|
||||
|
||||
(defun ebox-css-layout-test--glyph-width (string)
|
||||
"Measure STRING with seven-pixel glyphs and Emacs's per-space truncation."
|
||||
(cl-loop for index below (or (string-match "\n" string) (length string))
|
||||
for display = (get-text-property index 'display string)
|
||||
for width = (and (consp display) (eq (car display) 'space)
|
||||
(plist-get (cdr display) :width))
|
||||
sum (cond ((and (consp width) (numberp (car width)))
|
||||
(floor (car width)))
|
||||
((numberp width) (floor (* 7 width)))
|
||||
(t 7))))
|
||||
|
||||
(defmacro ebox-css-layout-test--with-pixel-display (&rest body)
|
||||
"Run BODY with an independent whole-pixel display oracle."
|
||||
(declare (indent 0) (debug t))
|
||||
`(let ((ebox-runtime-idle-prewarm nil)
|
||||
(ebox-runtime-idle-reflow-cache-prewarm nil))
|
||||
(cl-letf (((symbol-function 'string-pixel-width)
|
||||
#'ebox-css-layout-test--glyph-width)
|
||||
((symbol-function 'ebox-native-reflow-layout-ready-p)
|
||||
(lambda () nil)))
|
||||
(ebox-clear-cache)
|
||||
(unwind-protect (progn ,@body)
|
||||
(ebox-clear-cache)))))
|
||||
|
||||
(defun ebox-css-layout-test--display (string)
|
||||
"Return STRING text and display properties without retained identities."
|
||||
(list (substring-no-properties string)
|
||||
(cl-loop for index below (length string)
|
||||
collect (get-text-property index 'display string))))
|
||||
|
||||
(defun ebox-css-layout-test--assert-pixel-lines (output width)
|
||||
"Assert every OUTPUT line measures WIDTH in the model and real glyph oracle."
|
||||
(dolist (line (ebox-string-lines output))
|
||||
(should (= (ebox--string-pixel-width line) width))
|
||||
(should (= (ebox-css-layout-test--glyph-width line) width))))
|
||||
|
||||
(ert-deftest ebox-css-layout-percentage-text-and-blank-share-pixel-boundaries ()
|
||||
"Centered text and blank fill consume the same final percentage width."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(let* ((ebox-viewport-width 207)
|
||||
(input (ebox-build
|
||||
'(box :width (% 50) :height (lh 2) :border "red"
|
||||
:text-align center "X")))
|
||||
(output (ebox-render input)))
|
||||
(should (= (ebox-size-resolve '(% 50) '(:percent-base 207)) 103.5))
|
||||
(should (= (ebox-string-height output) 2))
|
||||
(ebox-css-layout-test--assert-pixel-lines output 103))))
|
||||
|
||||
(ert-deftest ebox-css-layout-percentage-siblings-share-the-remaining-pixel ()
|
||||
"Two 50-percent Row children divide an odd extent into 50 and 51 pixels."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(let* ((ebox-viewport-width 101)
|
||||
(output
|
||||
(ebox-render
|
||||
(ebox-build
|
||||
'(row :width (px 101)
|
||||
(box :width (% 50) :height (lh 2) :border "red"
|
||||
:text-align center "A")
|
||||
(box :width (% 50) :height (lh 2) :border "blue"
|
||||
:text-align center "B"))))))
|
||||
(ebox-css-layout-test--assert-pixel-lines output 101)
|
||||
(dolist (line (ebox-string-lines output))
|
||||
(let ((offsets
|
||||
(cl-loop for index below (length line)
|
||||
when (get-text-property index 'ebox-bl line)
|
||||
collect (ebox-css-layout-test--glyph-width
|
||||
(substring line 0 index)))))
|
||||
(should (equal offsets '(0 50))))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-fractional-chrome-shares-the-content-boundary ()
|
||||
"Margins, padding and centered content share one cumulative pixel grid."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(ebox-css-layout-test--assert-pixel-lines
|
||||
(ebox-render
|
||||
(ebox-build
|
||||
'(box :width (px 20) :height (lh 2) :box-sizing border-box
|
||||
:padding-inline (px 0.5) :margin-inline (px 0.5)
|
||||
:border "red" :text-align center "X")))
|
||||
21)))
|
||||
|
||||
(ert-deftest ebox-css-layout-pixel-quantization-preserves-private-input ()
|
||||
"Distinct fractional display glyphs retain ownership and line boundaries."
|
||||
(let* ((display '(space :width (0.5) :height 1))
|
||||
;; Each replacement needs its own EQ identity. One shared display
|
||||
;; object over several characters represents only one Emacs glyph.
|
||||
(source (mapconcat (lambda (text)
|
||||
(if (equal text "\n") text
|
||||
(propertize text 'display (copy-tree display)
|
||||
'ebox-content 17)))
|
||||
'(" " " " "\n" " " " ") ""))
|
||||
(before (copy-sequence source))
|
||||
(result (ebox--quantize-pixel-spaces source)))
|
||||
(should (equal-including-properties source before))
|
||||
(should (equal display '(space :width (0.5) :height 1)))
|
||||
(should (equal (mapcar (lambda (index)
|
||||
(plist-get (cdr (get-text-property index 'display
|
||||
result)) :width))
|
||||
'(0 1 3 4))
|
||||
'((0) (1) (0) (1))))
|
||||
(dolist (index '(0 1 3 4))
|
||||
(should (= (get-text-property index 'ebox-content result) 17))
|
||||
(should (= (plist-get (cdr (get-text-property index 'display result))
|
||||
:height)
|
||||
1)))
|
||||
(should (eq result (ebox--quantize-pixel-spaces result)))
|
||||
(let ((integer (ebox-pixel-space 12)))
|
||||
(should (eq integer (ebox--quantize-pixel-spaces integer))))))
|
||||
|
||||
(defun ebox-css-layout-test--retained-label (label &optional color)
|
||||
"Build a stable percentage LABEL whose text parity can introduce fractions."
|
||||
(ebox-build
|
||||
`(column :key root :width (vw 100)
|
||||
(box :key label :width (% 100) :height (lh 2) :border "red"
|
||||
:text-align center :bgcolor ,(or color "#FFFFFF") ,label))))
|
||||
|
||||
(ert-deftest ebox-css-layout-retained-new-fractions-and-paint-match-fresh ()
|
||||
"A local text edit can introduce a half-pixel, even with unchanged sizes."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(let ((ebox-viewport-width 101)
|
||||
(ebox-viewport-height 20))
|
||||
(with-temp-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-css-layout-test--retained-label "A"))
|
||||
(should-not (plist-get (ebox--buffer-render-state (current-buffer))
|
||||
:fractional-pixel-output-p))
|
||||
;; The first update introduces fractions into an integer-only prior
|
||||
;; frame. Paint must preserve the flag; later integer output clears it.
|
||||
(dolist (case '(("AA" nil t) ("AA" "#DDEEFF" t)
|
||||
("AAA" "#DDEEFF" nil) ("AAAA" "#DDEEFF" t)
|
||||
("A" "#DDEEFF" nil)))
|
||||
(let* ((input (ebox-css-layout-test--retained-label
|
||||
(nth 0 case) (nth 1 case)))
|
||||
(_report (ebox-commit (current-buffer) input))
|
||||
(actual (buffer-string))
|
||||
(fresh (ebox-render input)))
|
||||
(should (eq (plist-get (ebox--buffer-render-state (current-buffer))
|
||||
:fractional-pixel-output-p)
|
||||
(nth 2 case)))
|
||||
(ebox-css-layout-test--assert-pixel-lines actual 101)
|
||||
(should (equal (ebox-css-layout-test--display actual)
|
||||
(ebox-css-layout-test--display fresh)))))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-retained-fractional-sibling-origin-matches-fresh ()
|
||||
"Sequential edits never quantize the second fractional sibling at origin zero."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(let ((ebox-viewport-width 101)
|
||||
(ebox-viewport-height 20))
|
||||
(cl-labels ((input (width right)
|
||||
(ebox-build
|
||||
`(row :key root :width (px 101)
|
||||
(box :key left :width ,width :height (lh 2)
|
||||
:border "red" :text-align center "A")
|
||||
(box :key right :width ,width :height (lh 2)
|
||||
:border "blue" :text-align center ,right)))))
|
||||
(with-temp-buffer
|
||||
(ebox-render-to-buffer (current-buffer) (input '(% 50) "B"))
|
||||
(dolist (case '(((% 50) "BB" t) ((% 50) "BBB" t)
|
||||
((px 50) "BBB" nil) ((px 50) "B" nil)
|
||||
((% 50) "BB" t)))
|
||||
(let* ((next (input (nth 0 case) (nth 1 case)))
|
||||
(_report (ebox-commit (current-buffer) next))
|
||||
(actual (buffer-string)))
|
||||
(should (eq (plist-get (ebox--buffer-render-state (current-buffer))
|
||||
:fractional-pixel-output-p)
|
||||
(nth 2 case)))
|
||||
(ebox-css-layout-test--assert-pixel-lines actual 101)
|
||||
(should (equal (ebox-css-layout-test--display actual)
|
||||
(ebox-css-layout-test--display
|
||||
(ebox-render next)))))))))))
|
||||
|
||||
(defun ebox-css-layout-test--fractional-scroll (offset &optional boundary mixed)
|
||||
"Build a centered percentage scroll at OFFSET with optional BOUNDARY.
|
||||
MIXED starts with three integral rows before the fractional cached rows."
|
||||
(let ((scroll
|
||||
(apply #'ebox-test-box
|
||||
:key 'scroll :width '(% 100) :height 3 :overflow 'scroll
|
||||
:text-align 'center :scroll-offset offset
|
||||
(append
|
||||
(and (eq boundary 'chrome) (list :border "red"))
|
||||
(list
|
||||
(ebox-test-text
|
||||
(mapconcat (lambda (index)
|
||||
(format (if (and mixed (< index 3)) "%03d" "%02d")
|
||||
index))
|
||||
(number-sequence 0 31) "\n")))))))
|
||||
(if (eq boundary 'nested)
|
||||
(ebox-test-box :width '(101) scroll)
|
||||
scroll)))
|
||||
|
||||
(ert-deftest ebox-css-layout-fractional-root-scroll-reuses-complete-pixel-lines ()
|
||||
"Cached full physical rows scroll without layout or projection work."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(let ((ebox-viewport-width 101)
|
||||
(ebox-viewport-height 3))
|
||||
(with-temp-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-css-layout-test--fractional-scroll 10))
|
||||
(let* ((state (ebox--buffer-render-state (current-buffer)))
|
||||
(region (car (plist-get state :scroll-region-ids)))
|
||||
(scroll (gethash region (plist-get state :scroll-state-table)))
|
||||
(raw (plist-get scroll :content-lines))
|
||||
(rendered (plist-get scroll :rendered-content-lines))
|
||||
(before (copy-tree (mapcar #'ebox-css-layout-test--display
|
||||
rendered)))
|
||||
(offset 10))
|
||||
(should (plist-get state :fractional-pixel-output-p))
|
||||
(should (ebox--scroll-state-retained-window-ready-p scroll))
|
||||
(dolist (next '(11 12 11 16 10))
|
||||
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
|
||||
(lambda (&rest _) (ert-fail "cached scroll rendered root")))
|
||||
((symbol-function 'ebox-surface--render-candidate-node)
|
||||
(lambda (&rest _) (ert-fail "cached scroll reran layout")))
|
||||
((symbol-function 'ebox-surface--complete-projection)
|
||||
(lambda (&rest _) (ert-fail "cached scroll reprojected styles"))))
|
||||
(should (= (ebox--scroll-region-by region (- next offset) 1)
|
||||
(- next offset))))
|
||||
(setq offset next
|
||||
state (ebox--buffer-render-state (current-buffer))
|
||||
scroll (gethash region (plist-get state :scroll-state-table)))
|
||||
(let ((lines (plist-get (plist-get state :surface-fragments)
|
||||
:scroll-lines)))
|
||||
(should lines)
|
||||
(should-not (cl-some #'ebox--fractional-pixel-spaces-p lines))
|
||||
(should (equal (ebox-css-layout-test--display (ebox-lines-join lines))
|
||||
(ebox-css-layout-test--display (buffer-string)))))
|
||||
(should (eq (plist-get (ebox-buffer-update-report (current-buffer))
|
||||
:projection-kind)
|
||||
'scroll-patch))
|
||||
(should (plist-get state :fractional-pixel-output-p))
|
||||
(should (eq raw (plist-get scroll :content-lines)))
|
||||
(should (eq rendered (plist-get scroll :rendered-content-lines)))
|
||||
(should (equal before
|
||||
(mapcar #'ebox-css-layout-test--display rendered)))
|
||||
(ebox-css-layout-test--assert-pixel-lines (buffer-string) 101)
|
||||
(should (equal (ebox-css-layout-test--display (buffer-string))
|
||||
(ebox-css-layout-test--display
|
||||
(ebox-render
|
||||
(ebox-css-layout-test--fractional-scroll offset)))))))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-fractional-root-scroll-retains-new-raw-fraction-flag ()
|
||||
"A newly visible fractional row keeps its raw flag after returning to integers."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(let ((ebox-viewport-width 101)
|
||||
(ebox-viewport-height 3))
|
||||
(with-temp-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-css-layout-test--fractional-scroll 0 nil t))
|
||||
(let* ((state (ebox--buffer-render-state (current-buffer)))
|
||||
(region (car (plist-get state :scroll-region-ids)))
|
||||
(raw (plist-get (gethash region (plist-get state :scroll-state-table))
|
||||
:rendered-content-lines)))
|
||||
(should-not (plist-get state :fractional-pixel-output-p))
|
||||
(dolist (delta '(1 -1))
|
||||
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
|
||||
(lambda (&rest _) (ert-fail "fractional row rerendered root"))))
|
||||
(should (= (ebox--scroll-region-by region delta 1) delta)))
|
||||
(setq state (ebox--buffer-render-state (current-buffer)))
|
||||
(should (plist-get state :fractional-pixel-output-p))
|
||||
(ebox-css-layout-test--assert-pixel-lines (buffer-string) 101))
|
||||
;; Integral line strings keep their existing ownership-template keys.
|
||||
(let ((lines (plist-get (plist-get state :surface-fragments) :scroll-lines)))
|
||||
(cl-mapc (lambda (actual source) (should (eq actual source)))
|
||||
lines (seq-take raw 3))))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-fractional-scroll-keeps-wrapper-fallbacks ()
|
||||
"Nested or chrome-bearing fractional scrolls retain full-line projection."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(let ((ebox-viewport-width 101)
|
||||
(ebox-viewport-height 3))
|
||||
(dolist (boundary '(nested chrome))
|
||||
(with-temp-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-css-layout-test--fractional-scroll 10 boundary))
|
||||
(let* ((state (ebox--buffer-render-state (current-buffer)))
|
||||
(region (car (plist-get state :scroll-region-ids)))
|
||||
(original (symbol-function 'ebox-surface--render-candidate))
|
||||
(calls 0))
|
||||
(should (plist-get state :fractional-pixel-output-p))
|
||||
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
|
||||
(lambda (&rest args)
|
||||
(cl-incf calls)
|
||||
(apply original args))))
|
||||
(should (= (ebox--scroll-region-by region 1 1) 1)))
|
||||
(should (= calls 1))
|
||||
(should (equal (ebox-css-layout-test--display (buffer-string))
|
||||
(ebox-css-layout-test--display
|
||||
(ebox-render
|
||||
(ebox-css-layout-test--fractional-scroll
|
||||
11 boundary)))))))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-fractional-scroll-rolls-back-normalized-output ()
|
||||
"Failed publication preserves raw caches and retries the cached scroll path."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(let ((ebox-viewport-width 101)
|
||||
(ebox-viewport-height 3))
|
||||
(with-temp-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-css-layout-test--fractional-scroll 10))
|
||||
(let* ((surface ebox-surface--buffer-surface)
|
||||
(state (tp-surface-client-state surface))
|
||||
(revision (tp-surface-revision surface))
|
||||
(region (car (plist-get state :scroll-region-ids)))
|
||||
(scroll (gethash region (plist-get state :scroll-state-table)))
|
||||
(raw (plist-get scroll :rendered-content-lines))
|
||||
(before (copy-tree (mapcar #'ebox-css-layout-test--display raw)))
|
||||
(contents (buffer-string)))
|
||||
(let ((tp--surface-publication-step-function
|
||||
(lambda (step _surface)
|
||||
(when (eq step 'client-state)
|
||||
(error "reject normalized scroll publication")))))
|
||||
(should-error (ebox--scroll-region-by region 1 1)))
|
||||
(should (= (tp-surface-revision surface) revision))
|
||||
(should (eq (tp-surface-client-state surface) state))
|
||||
(should (= (plist-get (ebox-scroll-state region) :scroll-offset) 10))
|
||||
(should (eq raw (plist-get (ebox-scroll-state region) :rendered-content-lines)))
|
||||
(should (equal before (mapcar #'ebox-css-layout-test--display raw)))
|
||||
(should (equal-including-properties contents (buffer-string)))
|
||||
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
|
||||
(lambda (&rest _) (ert-fail "retry rerendered root"))))
|
||||
(should (= (ebox--scroll-region-by region 1 1) 1)))
|
||||
(ebox-css-layout-test--assert-pixel-lines (buffer-string) 101))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-percent-and-viewport-use-distinct-bases ()
|
||||
(let* ((ebox-viewport-width 800)
|
||||
(ebox-viewport-height 40)
|
||||
(ebox--css-viewport-width 800)
|
||||
(ebox--css-viewport-height 800)
|
||||
(ebox--css-containing-height 800)
|
||||
(ebox--css-size-context-active-p t)
|
||||
(box (ebox-test-root
|
||||
(ebox-build '(box :width (px 200) :height (vh 50) "x")))))
|
||||
(ebox--call-with-box-content-context
|
||||
box
|
||||
(lambda ()
|
||||
(should (= (ebox--size-pixels '(% 50) nil 'width) 100))
|
||||
(should (= (ebox--size-pixels '(vw 50) nil 'width) 400))
|
||||
(should (= (ebox--size-pixels '(% 50) nil 'height) 200))
|
||||
(should (= (ebox--size-pixels '(vh 50) nil 'height) 400))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-auto-height-does-not-resolve-child-percent ()
|
||||
(let* ((ebox-viewport-width 800)
|
||||
(ebox--css-containing-height 800)
|
||||
(box (ebox-test-root
|
||||
(ebox-build '(box :width (px 200) "x")))))
|
||||
(ebox--call-with-box-content-context
|
||||
box
|
||||
(lambda ()
|
||||
(should-not (ebox--size-pixels '(% 50) nil 'height))
|
||||
(should (= (ebox--resolve-size-content-height
|
||||
box '(% 50) 3)
|
||||
3))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-explicit-indefinite-percent-basis ()
|
||||
(let ((ebox-viewport-width 400)
|
||||
(ebox--css-containing-height 600))
|
||||
(should-not (ebox--size-pixels '(% 100) nil 'width nil))
|
||||
(should-not (ebox--size-pixels '(% 100) nil 'height nil))
|
||||
(should (= (ebox--size-pixels '(% 100) nil 'width 0) 0))))
|
||||
|
||||
(ert-deftest ebox-css-layout-definite-parent-height-applies-constraints ()
|
||||
(let ((ebox-viewport-width 400)
|
||||
(ebox--css-size-context-active-p t)
|
||||
(ebox--css-viewport-height 400)
|
||||
(box (ebox-test-root
|
||||
(ebox-build '(box :height (vh 50) :min-height (vh 12.5)
|
||||
:max-height (vh 25) "x")))))
|
||||
(ebox--call-with-box-content-context
|
||||
box
|
||||
(lambda ()
|
||||
(should (= (ebox--size-pixels '(% 50) nil 'height) 50))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-font-units-use-projected-zero-and-line ()
|
||||
(let* ((fact (ebox-font--make-fact
|
||||
:measurement-metrics '(:zero-width 9 :height 24)))
|
||||
(box (list :ebox-font-fact fact)))
|
||||
(should (= (ebox--size-pixels '(ch 10) box) 90))
|
||||
(should (= (ebox--size-pixels '(lh 3) box 'height) 72))
|
||||
(should (= (ebox--resolve-height-expression-lines '(lh 3) box) 3))
|
||||
(let ((ebox--css-font-fact fact))
|
||||
(should (= (ebox--size-pixels '(ch 10)) 90)))))
|
||||
|
||||
(ert-deftest ebox-css-layout-rounds-height-after-complete-expression ()
|
||||
(let* ((fact (ebox-font--make-fact
|
||||
:measurement-metrics '(:zero-width 10 :height 20)))
|
||||
(box (ebox-test-root
|
||||
(ebox-build
|
||||
'(box :height (calc (- (vh 50) (lh 0.4))) "x"))))
|
||||
(ebox--css-size-context-active-p t)
|
||||
(ebox--css-viewport-height 490))
|
||||
(setq box (plist-put box :ebox-font-fact fact))
|
||||
(should (< (abs (- (ebox--resolve-height-expression-lines
|
||||
'(calc (- (vh 50) (lh 0.4))) box)
|
||||
11.85))
|
||||
1e-9))
|
||||
(should (= (ebox--content-height box 1) 11))))
|
||||
|
||||
(ert-deftest ebox-css-layout-vertical-padding-percent-refers-to-width ()
|
||||
(let ((ebox-viewport-width 200)
|
||||
(ebox--css-containing-height 1000)
|
||||
(box (list :ebox-font-fact
|
||||
(ebox-font--make-fact
|
||||
:measurement-metrics '(:zero-width 10 :height 20)))))
|
||||
(should (= (ebox--edge-used-value box :padding-top-height '(% 10)) 1))
|
||||
(should (= (ebox--edge-used-value box :padding-left-pixel '(% 10)) 20))))
|
||||
|
||||
(ert-deftest ebox-css-layout-height-keywords-and-minimum-precedence ()
|
||||
(let ((ebox--css-containing-height 10))
|
||||
(dolist (keyword '(min-content max-content fit-content))
|
||||
(let ((box (ebox-test-root
|
||||
(ebox-build `(box :height ,keyword "x")))))
|
||||
(should (= (ebox--content-height box 4) 4))))
|
||||
(let ((box (ebox-test-root
|
||||
(ebox-build '(box :height (lh 2)
|
||||
:min-height (lh 5)
|
||||
:max-height (lh 3) "x")))))
|
||||
(should (= (ebox--content-height box 1) 5)))))
|
||||
|
||||
(ert-deftest ebox-css-layout-inline-content-wraps-at-final-used-width ()
|
||||
"Intrinsic keywords and bounds size the text flow as well as its paint."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(let ((ebox-viewport-width 56))
|
||||
(dolist (kind '(single multiple column))
|
||||
(dolist (case '(((:width min-content)
|
||||
28 ("red" "ink" "on" "blue"))
|
||||
((:width fit-content)
|
||||
56 ("red ink" "on blue"))
|
||||
((:width max-content)
|
||||
105 ("red ink on blue"))
|
||||
((:width (px 7) :min-width min-content)
|
||||
28 ("red" "ink" "on" "blue"))
|
||||
((:width (px 7) :min-width fit-content)
|
||||
56 ("red ink" "on blue"))
|
||||
((:width (px 7) :min-width max-content)
|
||||
105 ("red ink on blue"))
|
||||
((:width (px 140) :max-width min-content)
|
||||
28 ("red" "ink" "on" "blue"))
|
||||
((:width (px 140) :max-width fit-content)
|
||||
56 ("red ink" "on blue"))
|
||||
((:width (px 140) :max-width max-content)
|
||||
105 ("red ink on blue"))
|
||||
((:width max-content :max-width (px 56))
|
||||
56 ("red ink" "on blue"))
|
||||
((:width min-content :min-width (px 56))
|
||||
56 ("red ink" "on blue"))
|
||||
((:width (px 7) :min-width (px 56)
|
||||
:max-width (px 28))
|
||||
56 ("red ink" "on blue"))))
|
||||
(ert-info ((format "%S flow, declarations %S" kind (car case)))
|
||||
(let* ((form
|
||||
(append
|
||||
(list (if (eq kind 'column) 'column 'box))
|
||||
(car case)
|
||||
(pcase kind
|
||||
('single '("red ink on blue"))
|
||||
('multiple '((text "red ink ") (text "on blue")))
|
||||
('column '((text "red ink on blue"))))))
|
||||
(output (ebox-render (ebox-build form))))
|
||||
(ebox-css-layout-test--assert-pixel-lines output (nth 1 case))
|
||||
(should (equal (mapcar #'string-trim (ebox-string-lines output))
|
||||
(nth 2 case))))))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-intrinsic-text-viewport-keeps-nested-contexts ()
|
||||
"Intrinsic probes stay unwrapped; final nested percentages use the used width."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(dolist (form '((box :width fit-content
|
||||
(column (text "red ink on blue")))
|
||||
(column :width fit-content
|
||||
(box :width (% 100) "red ink on blue"))))
|
||||
(let* ((input (ebox-build form))
|
||||
(node (ebox-test-root input)))
|
||||
(dolist (width '(56 140 14 84 28 56))
|
||||
(let* ((ebox-viewport-width width)
|
||||
(output (ebox-render input)))
|
||||
(ebox-css-layout-test--assert-pixel-lines
|
||||
output (max 28 (min 105 width)))
|
||||
(should (equal (mapcar #'string-trim (ebox-string-lines output))
|
||||
(pcase width
|
||||
((or 14 28) '("red" "ink" "on" "blue"))
|
||||
(56 '("red ink" "on blue"))
|
||||
(84 '("red ink on" "blue"))
|
||||
(140 '("red ink on blue")))))
|
||||
(should (eq (ebox-get node :width) 'fit-content))
|
||||
(should (eq (ebox-get node :wrap-mode) 'word))))))
|
||||
(let ((ebox-viewport-width 56))
|
||||
(dolist (kind '(box column))
|
||||
(let ((output (ebox-render
|
||||
(ebox-build
|
||||
`(,kind :width min-content :wrap-mode none
|
||||
(text "red ink on blue"))))))
|
||||
(ebox-css-layout-test--assert-pixel-lines output 105)
|
||||
(should (equal (substring-no-properties output)
|
||||
"red ink on blue")))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-intrinsic-parent-keeps-child-width-constraints ()
|
||||
"Measuring a parent must retain each nested child's independent used width."
|
||||
(ebox-css-layout-test--with-pixel-display
|
||||
(let ((ebox-viewport-width 56))
|
||||
(dolist (parent '(box column))
|
||||
(dolist (child '(box column))
|
||||
(dolist (properties '((:width min-content)
|
||||
(:width (px 7) :min-width min-content)
|
||||
(:width (px 140) :max-width min-content)))
|
||||
(ert-info ((format "%S parent, %S child %S"
|
||||
parent child properties))
|
||||
(let ((output
|
||||
(ebox-render
|
||||
(ebox-build
|
||||
`(,parent :width max-content
|
||||
(,child ,@properties
|
||||
(text "red ink on blue")))))))
|
||||
(ebox-css-layout-test--assert-pixel-lines output 28)
|
||||
(should (equal (mapcar #'string-trim (ebox-string-lines output))
|
||||
'("red" "ink" "on" "blue")))))))))))
|
||||
|
||||
(ert-deftest ebox-css-layout-used-edge-ranges-are-explicit ()
|
||||
(should-error
|
||||
(ebox--edge-used-value nil :margin-left-pixel
|
||||
'(calc (- (px 1) (px 2)))))
|
||||
(should (= (ebox--edge-used-value nil :padding-left-pixel
|
||||
'(calc (- (px 1) (px 2))))
|
||||
0)))
|
||||
|
||||
(ert-deftest ebox-css-layout-equivalent-pixels-have-identical-display ()
|
||||
(should (equal-including-properties (ebox-pixel-space 214)
|
||||
(ebox-pixel-space 214.0)))
|
||||
(should (equal (get-text-property 0 'display (ebox-pixel-space 2.5))
|
||||
'(space :width (2.5)))))
|
||||
|
||||
(ert-deftest ebox-css-layout-cyclic-grid-gaps-overflow-intrinsic-box ()
|
||||
(dolist
|
||||
(case
|
||||
'(((grid :width max-content
|
||||
:grid-template-columns ((px 10) (px 10))
|
||||
:column-gap (% 50) :overflow visible
|
||||
(box "A") (box "B"))
|
||||
20 1 30 1)
|
||||
((grid :width (px 10)
|
||||
:grid-template-columns ((px 10))
|
||||
:grid-template-rows ((lh 2) (lh 2))
|
||||
:row-gap (% 50) :overflow visible
|
||||
(box "A") (box "B"))
|
||||
10 4 10 6)))
|
||||
(let* ((input (ebox-build (car case)))
|
||||
(node (ebox-test-root input))
|
||||
(ebox--render-source-index (ebox-test-source-index input))
|
||||
(ebox--surface-materialization-active t)
|
||||
(rendered (ebox--render-layout node)))
|
||||
(should (= (ebox--content-pixel node) (nth 1 case)))
|
||||
(should (= (ebox--total-height node) (nth 2 case)))
|
||||
(should (= (ebox--string-max-pixel-width rendered) (nth 3 case)))
|
||||
(should (= (ebox-string-height rendered) (nth 4 case))))))
|
||||
|
||||
(provide 'ebox-css-layout-tests)
|
||||
;;; ebox-css-layout-tests.el ends here
|
||||
147
tests/ebox-css-viewport-tests.el
Normal file
147
tests/ebox-css-viewport-tests.el
Normal file
@ -0,0 +1,147 @@
|
||||
;;; ebox-css-viewport-tests.el --- CSS viewport retention contracts -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Public DSL construction and retained rendering must preserve viewport
|
||||
;; dependencies through fixed containing blocks and retained updates.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'ert)
|
||||
(require 'ebox)
|
||||
|
||||
(defun ebox-css-viewport-test--root (input)
|
||||
"Return the canonical root owned by INPUT."
|
||||
(ebox-canonical-input--single-root input "CSS viewport test"))
|
||||
|
||||
(defun ebox-css-viewport-test--geometry (output)
|
||||
"Return OUTPUT's line count and line pixel widths without runtime IDs."
|
||||
(list (ebox-string-height output)
|
||||
(mapcar #'ebox--string-pixel-width (ebox-string-lines output))))
|
||||
|
||||
(ert-deftest ebox-css-viewport-own-dependencies-follow-valid-axis-units ()
|
||||
(dolist
|
||||
(case
|
||||
'(((box :width (vw 50) :height (lh 1) "x") t nil)
|
||||
((box :width (px 20) :height (vh 20) "x") nil t)
|
||||
((box :width (px 20) :height (lh 1)
|
||||
:padding ((vh 2) (px 0)) "x") nil t)
|
||||
((box :width (px 20) :height (lh 1)
|
||||
:margin-top (calc (+ (vh 2) (lh 1))) "x") nil t)
|
||||
((flex :width (px 20) :height (lh 1)
|
||||
:row-gap (max (lh 0) (vh 2)) :column-gap (px 0)
|
||||
(box "x")) nil t)
|
||||
((grid :width (px 20) :height (lh 1)
|
||||
:grid-template-columns ((min (ch 8) (vw 50)) (fr 1))
|
||||
:grid-template-rows ((lh 1))
|
||||
(box "x")) t nil)
|
||||
((box :width (ch 20) :height (lh 3)
|
||||
:padding ((lh 1) (px 1)) "x") nil nil)))
|
||||
(ert-info ((format "DSL: %S" (car case)))
|
||||
(let ((node (ebox-css-viewport-test--root (ebox-build (car case)))))
|
||||
(should (eq (not (null (ebox--node-size-viewport-dependent-p
|
||||
node 'viewport-width)))
|
||||
(nth 1 case)))
|
||||
(should (eq (not (null (ebox--node-size-viewport-dependent-p
|
||||
node 'viewport-height)))
|
||||
(nth 2 case)))))))
|
||||
|
||||
(ert-deftest ebox-css-viewport-width-id-survives-a-fixed-containing-block ()
|
||||
(let* ((input (ebox-build
|
||||
'(box :width (px 80)
|
||||
(box :width (vw 20) :height (lh 1) "x"))))
|
||||
(root (ebox-css-viewport-test--root input))
|
||||
(child (car (ebox--node-children root)))
|
||||
(axes (ebox--viewport-dependent-node-id-axes root)))
|
||||
(should (ebox--node-size-viewport-dependent-p child 'viewport-width))
|
||||
(should (memq (plist-get child :node-id) (car axes)))))
|
||||
|
||||
(ert-deftest ebox-css-viewport-both-axes-retained-resize-matches-fresh-render ()
|
||||
(let ((buffer (generate-new-buffer " *ebox-css-viewport*"))
|
||||
(ebox-viewport-width 100)
|
||||
(ebox-viewport-height 20)
|
||||
(ebox-runtime-idle-prewarm nil)
|
||||
(ebox-runtime-idle-reflow-cache-prewarm nil)
|
||||
(input (ebox-build
|
||||
'(box :width (vw 50)
|
||||
:height (calc (- (vh 20) (lh 1))) "x"))))
|
||||
(unwind-protect
|
||||
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
|
||||
(lambda () nil)))
|
||||
(ebox-render-to-buffer buffer input)
|
||||
(let* ((first (with-current-buffer buffer (buffer-string)))
|
||||
(first-geometry (ebox-css-viewport-test--geometry first)))
|
||||
(ebox-rerender-buffer-with-context buffer 200 20)
|
||||
(let* ((second (with-current-buffer buffer (buffer-string)))
|
||||
(second-geometry (ebox-css-viewport-test--geometry second))
|
||||
(ebox-viewport-width 200)
|
||||
(fresh (ebox-render input)))
|
||||
(should (eq (plist-get (ebox-buffer-update-report buffer)
|
||||
:viewport-axes)
|
||||
'width))
|
||||
(should (= (car second-geometry) (car first-geometry)))
|
||||
(should (< (car (cadr first-geometry))
|
||||
(car (cadr second-geometry))))
|
||||
(should (equal second-geometry
|
||||
(ebox-css-viewport-test--geometry fresh)))
|
||||
(ebox-rerender-buffer-with-context buffer 200 40)
|
||||
(let* ((third (with-current-buffer buffer (buffer-string)))
|
||||
(third-geometry (ebox-css-viewport-test--geometry third))
|
||||
(ebox-viewport-height 40))
|
||||
(should (eq (plist-get (ebox-buffer-update-report buffer)
|
||||
:viewport-axes)
|
||||
'height))
|
||||
(should (< (car second-geometry) (car third-geometry)))
|
||||
(should (= (car (cadr third-geometry))
|
||||
(car (cadr second-geometry))))
|
||||
(should (equal third-geometry
|
||||
(ebox-css-viewport-test--geometry
|
||||
(ebox-render input))))))))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest ebox-css-viewport-nested-percent-and-viewport-use-different-bases ()
|
||||
(let* ((input (ebox-build
|
||||
'(box :width (px 80)
|
||||
(box :width (vw 10) "viewport")
|
||||
(box :width (% 50) "percent"))))
|
||||
(resolve (symbol-function 'ebox-size-resolve))
|
||||
(ebox-viewport-height 20))
|
||||
(dolist (width '(200 400))
|
||||
(let ((ebox-viewport-width width)
|
||||
observations)
|
||||
(cl-letf (((symbol-function 'ebox-size-resolve)
|
||||
(lambda (value context)
|
||||
(let ((result (funcall resolve value context)))
|
||||
(when (member value '((vw 10) (% 50)))
|
||||
(push (list value result) observations))
|
||||
result))))
|
||||
(ebox-render input))
|
||||
(should (member (list '(vw 10) (/ width 10.0)) observations))
|
||||
(should (member '((% 50) 40.0) observations))))))
|
||||
|
||||
(ert-deftest ebox-css-viewport-nested-fixed-parent-rerenders-viewport-child ()
|
||||
(let ((buffer (generate-new-buffer " *ebox-css-nested-viewport*"))
|
||||
(ebox-viewport-width 100)
|
||||
(ebox-viewport-height 20)
|
||||
(ebox-runtime-idle-prewarm nil)
|
||||
(ebox-runtime-idle-reflow-cache-prewarm nil)
|
||||
(input (ebox-build
|
||||
'(box :width (px 80)
|
||||
(box :width (px 20) :height (vh 20) "x")))))
|
||||
(unwind-protect
|
||||
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
|
||||
(lambda () nil)))
|
||||
(ebox-render-to-buffer buffer input)
|
||||
(let ((before (with-current-buffer buffer
|
||||
(ebox-string-height (buffer-string)))))
|
||||
(ebox-rerender-buffer-with-context buffer 100 40)
|
||||
(let* ((ebox-viewport-height 40)
|
||||
(actual (with-current-buffer buffer (buffer-string)))
|
||||
(fresh (ebox-render input)))
|
||||
(should (> (ebox-string-height fresh) before))
|
||||
(should (equal (ebox-css-viewport-test--geometry actual)
|
||||
(ebox-css-viewport-test--geometry fresh))))))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(provide 'ebox-css-viewport-tests)
|
||||
;;; ebox-css-viewport-tests.el ends here
|
||||
73
tests/ebox-display-tests.el
Normal file
73
tests/ebox-display-tests.el
Normal file
@ -0,0 +1,73 @@
|
||||
;;; ebox-display-tests.el --- Display integration tests -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'ert)
|
||||
(require 'ebox)
|
||||
|
||||
(ert-deftest ebox-display-invalid-input-preserves-windows ()
|
||||
"Invalid input must fail before changing the caller's window layout."
|
||||
(save-window-excursion
|
||||
(split-window-right)
|
||||
(let ((before (current-window-configuration))
|
||||
(buffer (generate-new-buffer " *ebox-invalid-display*")))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(should-error (ebox-display-buffer buffer 'invalid-input))
|
||||
(should (compare-window-configurations
|
||||
before (current-window-configuration))))
|
||||
(kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest ebox-display-honors-native-display-action ()
|
||||
"The caller chooses the display window without changing selection."
|
||||
(save-window-excursion
|
||||
(let* ((selected (selected-window))
|
||||
(other (split-window-right))
|
||||
(buffer (generate-new-buffer " *ebox-display-action*"))
|
||||
(input (ebox-build '(box "Ready"))))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(should
|
||||
(eq buffer
|
||||
(ebox-display-buffer
|
||||
buffer input
|
||||
(list (lambda (target alist)
|
||||
(let ((window (alist-get 'window alist)))
|
||||
(set-window-buffer window target)
|
||||
window))
|
||||
(cons 'window other)))))
|
||||
(should (eq selected (selected-window)))
|
||||
(should (= 2 (length (window-list))))
|
||||
(should (eq buffer (window-buffer other)))
|
||||
(with-current-buffer buffer
|
||||
(should (string-match-p "Ready" (buffer-string)))
|
||||
(should ebox-buffer-mode)))
|
||||
(kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest ebox-display-honors-display-buffer-alist ()
|
||||
"Native display rules also apply when no explicit action is supplied."
|
||||
(let ((buffer (generate-new-buffer " *ebox-display-rule*")) called)
|
||||
(unwind-protect
|
||||
(let ((display-buffer-alist
|
||||
(list (list (regexp-quote (buffer-name buffer))
|
||||
(lambda (target _action)
|
||||
(setq called target)
|
||||
(selected-window))))))
|
||||
(should (eq buffer (ebox-display-buffer buffer (ebox-build "Ready"))))
|
||||
(should (eq buffer called)))
|
||||
(kill-buffer buffer))))
|
||||
|
||||
(ert-deftest ebox-display-delivers-the-target-viewport-intent ()
|
||||
"A newly displayed buffer uses the ordinary deferred viewport bridge."
|
||||
(let ((buffer (generate-new-buffer " *ebox-display-viewport*")) frame)
|
||||
(unwind-protect
|
||||
(cl-letf (((symbol-function 'display-buffer)
|
||||
(lambda (_buffer _action) (selected-window)))
|
||||
((symbol-function 'ebox--window-size-change)
|
||||
(lambda (target) (setq frame target))))
|
||||
(ebox-display-buffer buffer (ebox-build "Ready"))
|
||||
(should (eq frame (selected-frame))))
|
||||
(kill-buffer buffer))))
|
||||
|
||||
(provide 'ebox-display-tests)
|
||||
;;; ebox-display-tests.el ends here
|
||||
@ -23,15 +23,22 @@
|
||||
"Public documents that must describe only the canonical author grammar.")
|
||||
|
||||
(defconst ebox-docs-test--active-files
|
||||
'("Makefile" ".github/workflows/ci.yml"
|
||||
"ebox.el" "ebox-cache.el" "ebox-state-contract.el" "ebox-style.el"
|
||||
'("Makefile" ".github/workflows/ci.yml" "LICENSE" "release-dependencies.json"
|
||||
"ebox.el" "ebox-cache.el" "ebox-runtime-index.el" "ebox-interaction.el"
|
||||
"ebox-state-contract.el" "ebox-size.el" "ebox-style.el"
|
||||
"ebox-child-range.el" "ebox-tree.el"
|
||||
"ebox-measure.el" "ebox-fragment.el" "ebox-render-context.el"
|
||||
"ebox-layout.el" "ebox-flex.el" "ebox-grid.el"
|
||||
"ebox-buffer-backend.el" "ebox-patch-plan.el" "ebox-incremental.el"
|
||||
"ebox-surface.el" "ebox-dsl.el" "ebox-spi.el"
|
||||
"ebox-selector.el" "ebox-native-reflow.el"
|
||||
"tests/ebox-core-render-tests.el" "tests/ebox-state-contract-tests.el"
|
||||
"tests/ebox-size-tests.el" "tests/ebox-size-style-tests.el"
|
||||
"tests/ebox-interaction-tests.el" "tests/ebox-display-tests.el"
|
||||
"tests/ebox-size-native-tests.el" "tests/ebox-zero-height-tests.el"
|
||||
"tests/ebox-css-layout-tests.el" "tests/ebox-css-flex-grid-tests.el"
|
||||
"tests/ebox-css-viewport-tests.el"
|
||||
"tests/ebox-core-render-tests.el" "tests/ebox-runtime-index-tests.el"
|
||||
"tests/ebox-state-contract-tests.el"
|
||||
"tests/ebox-layout-boundary-tests.el"
|
||||
"tests/ebox-patch-plan-tests.el"
|
||||
"tests/ebox-style-schema-tests.el"
|
||||
@ -46,17 +53,24 @@
|
||||
"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"
|
||||
"scripts/ebox-performance-evaluator.el")
|
||||
"scripts/ebox-performance-evaluator.el"
|
||||
"scripts/ebox-release.py" "scripts/ebox-release-smoke.el"
|
||||
"scripts/test_ebox_release.py"
|
||||
"scripts/migrate-css-sizes.py" "scripts/test_migrate_css_sizes.py")
|
||||
"Active Ebox files that the maintainer map must cover.")
|
||||
|
||||
(defconst ebox-docs-test--targets
|
||||
'("check" "ci" "load" "compile" "core-tests" "child-range-tests" "grid-tests"
|
||||
'("check" "ci" "load" "compile" "size-tests" "size-migration-tests"
|
||||
"core-tests" "runtime-index-tests" "interaction-tests" "display-tests"
|
||||
"release-deps" "release-build" "release-check" "release-tool-tests"
|
||||
"native-integration"
|
||||
"child-range-tests" "grid-tests"
|
||||
"ebox-commit-tests" "surface-tests" "visual-check-tests" "package-tests"
|
||||
"selector-tests" "dsl-tests" "flex-tests" "state-contract-tests"
|
||||
"layout-boundary-tests" "layout-boundary-performance"
|
||||
"patch-plan-tests" "patch-plan-performance"
|
||||
"style-schema-tests" "style-schema-performance"
|
||||
"spi-tests" "spi-performance"
|
||||
"spi-tests" "spi-performance" "c1b-contract-tests"
|
||||
"docs-contract-tests" "ci-contract-tests" "performance-evaluator"
|
||||
"visual-check"
|
||||
"native-rust-tests" "native-build" "package-lint" "diff-check")
|
||||
|
||||
@ -20,7 +20,8 @@
|
||||
(cl-loop for (key value) on plist by #'cddr
|
||||
when (memq key style-keys) append (list key value)))
|
||||
(declarations
|
||||
(and style-props (ebox-style-compile-form tag style-props)))
|
||||
(and style-props (ebox-style-compile-form
|
||||
tag (ebox-test--explicit-sizes tag style-props))))
|
||||
(plist
|
||||
(cl-loop for (key value) on plist by #'cddr
|
||||
unless (memq key style-keys) append (list key value)))
|
||||
@ -72,8 +73,7 @@
|
||||
(input (ebox-dsl-test--constructor-input tag plist))
|
||||
(builder (car input))
|
||||
(node
|
||||
(let ((ebox-canonical--source-builder builder))
|
||||
(apply #'ebox-box-create (cdr input))))
|
||||
(apply #'ebox-box-create :source-builder builder (cdr input)))
|
||||
(index (ebox-source-builder-finish builder)))
|
||||
(ebox-canonical-input-create (list node) index)))
|
||||
|
||||
@ -116,15 +116,15 @@
|
||||
"Approved aliases and shorthands should produce canonical longhands."
|
||||
(let ((style (ebox-style-compute
|
||||
(list :bgcolor "#ffffff"
|
||||
:padding '(1 2)
|
||||
:margin-left 3
|
||||
:padding '((lh 1) (ch 2))
|
||||
:margin-left '(ch 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-left) 3))
|
||||
(should (equal (plist-get style :padding-block-start) '(lh 1)))
|
||||
(should (equal (plist-get style :padding-inline-end) '(ch 2)))
|
||||
(should (equal (plist-get style :padding-block-end) '(lh 1)))
|
||||
(should (equal (plist-get style :padding-inline-start) '(ch 2)))
|
||||
(should (equal (plist-get style :margin-left) '(ch 3)))
|
||||
(should (equal (plist-get style :border-color) "#333333"))))
|
||||
|
||||
(ert-deftest ebox-style-classifies-incremental-effects ()
|
||||
@ -154,10 +154,10 @@
|
||||
(ert-deftest ebox-style-unrelated-facts-preserve-typed-layout-config ()
|
||||
"Unrelated computed facts must not replace direct typed layout values."
|
||||
(dolist (case '((flex
|
||||
(flex :flex-direction column :column-gap (6) "A"))
|
||||
(flex :flex-direction column :column-gap (px 6) "A"))
|
||||
(grid
|
||||
(grid :grid-template-columns ((40) (60))
|
||||
:column-gap (4) "A"))))
|
||||
(grid :grid-template-columns ((px 40) (px 60))
|
||||
:column-gap (px 4) "A"))))
|
||||
(let* ((kind (car case))
|
||||
(input (ebox-build (cadr case)))
|
||||
(node (ebox-dsl-test--root input))
|
||||
@ -234,27 +234,27 @@
|
||||
"Logical and physical edges conflict locally and cascade across sources."
|
||||
(should-error
|
||||
(ebox-style-compile-declarations
|
||||
'(:padding-left (4) :padding-inline-start (8)) t)
|
||||
'(:padding-left (px 4) :padding-inline-start (px 8)) t)
|
||||
:type 'ecss-invalid-declaration)
|
||||
(let* ((base (ebox-style-compile-declarations '(:padding (0 (8))) t))
|
||||
(let* ((base (ebox-style-compile-declarations '(:padding ((lh 0) (px 8))) t))
|
||||
(override
|
||||
(ebox-style-compile-declarations '(:padding-left (24)) t))
|
||||
(ebox-style-compile-declarations '(:padding-left (px 24)) t))
|
||||
(style
|
||||
(ebox-style-compute-subject
|
||||
(ecss-subject-create :type "box")
|
||||
(ebox-style-merge-declarations base override))))
|
||||
(should (equal (plist-get (ebox-style--box-values style) :padding-left)
|
||||
'(24))))
|
||||
'(px 24))))
|
||||
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
|
||||
(ebox-style-add-rule ".card" '(:margin-inline-start (2)))
|
||||
(ebox-style-add-rule "#target" '(:margin-left (6)))
|
||||
(ebox-style-add-rule ".card" '(:margin-inline-start (px 2)))
|
||||
(ebox-style-add-rule "#target" '(:margin-left (px 6)))
|
||||
(let ((style
|
||||
(ebox-style-compute-subject
|
||||
(ecss-subject-create
|
||||
:type "box" :id "target" :classes '("card"))
|
||||
nil)))
|
||||
(should (equal (plist-get (ebox-style--box-values style) :margin-left)
|
||||
'(6))))))
|
||||
'(px 6))))))
|
||||
|
||||
(ert-deftest ebox-style-rejects-canonical-and-alias-duplicates ()
|
||||
"One declaration source should not spell one property twice."
|
||||
@ -270,9 +270,9 @@
|
||||
(ert-deftest ebox-author-declaration-is-one-atomic-normalization-group ()
|
||||
"One author group rejects overlaps while later cascade groups may override."
|
||||
(dolist (declarations
|
||||
'((:padding 1 :padding-left 2)
|
||||
'((:padding ((lh 1) (ch 1)) :padding-left (ch 2))
|
||||
(:border "red" :border-top none)
|
||||
(:gap 1 :row-gap 2)
|
||||
(:gap ((lh 1) (ch 1)) :row-gap (lh 2))
|
||||
(:flex-flow (row wrap) :flex-wrap nowrap)
|
||||
(:flex (1 1 auto) :flex-grow 2)
|
||||
(:border-top-p t :border-top-style none)))
|
||||
@ -282,9 +282,9 @@
|
||||
(should
|
||||
(equal
|
||||
(ebox-style-merge-declarations
|
||||
(ebox-style-compile-declarations '(:gap 1) t)
|
||||
(ebox-style-compile-declarations '(:row-gap 2) t))
|
||||
'(ebox/row-gap 2 ebox/column-gap 1))))
|
||||
(ebox-style-compile-declarations '(:gap ((lh 1) (ch 1))) t)
|
||||
(ebox-style-compile-declarations '(:row-gap (lh 2)) t))
|
||||
'(ebox/row-gap (lh 2) ebox/column-gap (ch 1)))))
|
||||
|
||||
(ert-deftest ebox-style-property-registry-rejects-alias-collisions ()
|
||||
"An alias should never shadow another canonical property."
|
||||
@ -317,24 +317,24 @@
|
||||
"The concise border boolean should expand to canonical width and style."
|
||||
(should
|
||||
(equal (ebox-style-compile-declarations '(:border-top-p t) t)
|
||||
'(ebox/border-top-width 1 ebox/border-top-style solid)))
|
||||
'(ebox/border-top-width (px 1) ebox/border-top-style solid)))
|
||||
(should
|
||||
(equal (ebox-style-compile-declarations '(:border-top-p nil) t)
|
||||
'(ebox/border-top-width 0 ebox/border-top-style none)))
|
||||
'(ebox/border-top-width (px 0) ebox/border-top-style none)))
|
||||
(should (eq (ebox-style-canonical-name :border-top-p) :border-top-p)))
|
||||
|
||||
(ert-deftest ebox-border-width-unit-is-always-integer-pixels ()
|
||||
"Border width should not reuse Box edge column/pixel wrapper grammar."
|
||||
(ert-deftest ebox-border-width-requires-an-explicit-length ()
|
||||
"Border declarations retain explicit lengths and reject singleton syntax."
|
||||
(should
|
||||
(equal
|
||||
(ebox-style-compile-declarations '(:border (1 solid "#687386")) t)
|
||||
'(ebox/border-top-width 1 ebox/border-top-style solid
|
||||
(ebox-style-compile-declarations '(:border ((px 1) solid "#687386")) t)
|
||||
'(ebox/border-top-width (px 1) ebox/border-top-style solid
|
||||
ebox/border-top-color "#687386"
|
||||
ebox/border-right-width 1 ebox/border-right-style solid
|
||||
ebox/border-right-width (px 1) ebox/border-right-style solid
|
||||
ebox/border-right-color "#687386"
|
||||
ebox/border-bottom-width 1 ebox/border-bottom-style solid
|
||||
ebox/border-bottom-width (px 1) ebox/border-bottom-style solid
|
||||
ebox/border-bottom-color "#687386"
|
||||
ebox/border-left-width 1 ebox/border-left-style solid
|
||||
ebox/border-left-width (px 1) ebox/border-left-style solid
|
||||
ebox/border-left-color "#687386")))
|
||||
(should-error
|
||||
(ebox-style-compile-declarations
|
||||
@ -343,26 +343,31 @@
|
||||
|
||||
(ert-deftest ebox-border-engine-projection-keeps-exact-pixels ()
|
||||
"Border projection must not reinterpret pixels as character columns."
|
||||
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
|
||||
(should
|
||||
(equal
|
||||
(ebox-style--expand-engine-plist
|
||||
'(:border-left-width 1 :border-left-style solid
|
||||
:border-right-width 2 :border-right-style solid
|
||||
:padding-left 1 :margin-right (3)))
|
||||
'(:border-left-pixel 1 :border-left-style solid
|
||||
:border-right-pixel 2 :border-right-style solid
|
||||
:padding-left-pixel 7 :margin-right-pixel 3))))
|
||||
(cl-letf (((symbol-function 'ebox--size-character-width)
|
||||
(lambda (&optional _box) 7)))
|
||||
(let ((projected
|
||||
(ebox-style--expand-engine-plist
|
||||
'(:border-left-width (px 1) :border-left-style solid
|
||||
:border-right-width (px 2) :border-right-style solid
|
||||
:padding-left (ch 1) :margin-right (px 3)))))
|
||||
(should (equal projected
|
||||
'(:border-left-pixel (px 1) :border-left-style solid
|
||||
:border-right-pixel (px 2) :border-right-style solid
|
||||
:padding-left-pixel (ch 1) :margin-right-pixel (px 3))))
|
||||
(should (= (ebox-get projected :border-left-pixel) 1))
|
||||
(should (= (ebox-get projected :border-right-pixel) 2))
|
||||
(should (= (ebox-get projected :padding-left-pixel) 7))
|
||||
(should (= (ebox-get projected :margin-right-pixel) 3))))
|
||||
(should
|
||||
(equal
|
||||
(ebox-style--expand-engine-plist
|
||||
'(:border-left-width 2 :border-left-style none))
|
||||
'(:border-left-width (px 2) :border-left-style none))
|
||||
'(:border-left-pixel 0 :border-left-style none)))
|
||||
(should
|
||||
(equal
|
||||
(ebox-style--expand-engine-plist
|
||||
'(:border-top-width 2 :border-top-style solid))
|
||||
'(:border-top-pixel 2 :border-top-style solid))))
|
||||
'(:border-top-width (px 2) :border-top-style solid))
|
||||
'(:border-top-pixel (px 2) :border-top-style solid))))
|
||||
|
||||
(ert-deftest ebox-style-declaration-cache-expands-shorthand-once ()
|
||||
"A cached declaration should not expand its shorthand again."
|
||||
@ -373,15 +378,15 @@
|
||||
(lambda (value)
|
||||
(cl-incf calls)
|
||||
(funcall original value))))
|
||||
(ebox-style-compile-declarations '(:padding 1) t)
|
||||
(ebox-style-compile-declarations '(:padding 1) t))
|
||||
(ebox-style-compile-declarations '(:padding ((lh 1) (ch 1))) t)
|
||||
(ebox-style-compile-declarations '(:padding ((lh 1) (ch 1))) t))
|
||||
(should (= calls 1))))
|
||||
|
||||
(ert-deftest ebox-style-border-present-rejects-owned-longhand-conflicts ()
|
||||
"One source should not mix the boolean border shorthand with its outputs."
|
||||
(should-error
|
||||
(ebox-style-compile-declarations
|
||||
'(:border-top-p t :border-top-width 2) t)
|
||||
'(:border-top-p t :border-top-width (px 2)) t)
|
||||
:type 'error)
|
||||
(should-error
|
||||
(ebox-style-compile-declarations
|
||||
@ -389,11 +394,11 @@
|
||||
:type 'error)
|
||||
(should-error
|
||||
(ebox-style-compile-declarations
|
||||
'(:border-top-p t :border-top (2 dashed "red")) t)
|
||||
'(:border-top-p t :border-top ((px 2) dashed "red")) t)
|
||||
:type 'error)
|
||||
(should-error
|
||||
(ebox-style-compile-declarations
|
||||
'(:border-top-p t :border-width 2) t)
|
||||
'(:border-top-p t :border-width (px 2)) t)
|
||||
:type 'error)
|
||||
(should-error
|
||||
(ebox-style-compile-declarations
|
||||
@ -401,7 +406,7 @@
|
||||
:type 'error)
|
||||
(should-error
|
||||
(ebox-style-compile-declarations
|
||||
'(:border-bottom-p t :border (1 solid "red")) t)
|
||||
'(:border-bottom-p t :border ((px 1) solid "red")) t)
|
||||
:type 'error))
|
||||
|
||||
(ert-deftest ebox-canonical-text-constructor-is-renderable-and-typed ()
|
||||
@ -458,7 +463,7 @@
|
||||
(ert-deftest ebox-render-cache-signature-ignores-derived-layout-proofs ()
|
||||
"A render must not invalidate its own canonical node cache key."
|
||||
(let* ((node (ebox-dsl-test--root
|
||||
(ebox-build '(box :width '(80) "Stable"))))
|
||||
(ebox-build '(box :width '(px 80) "Stable"))))
|
||||
(before
|
||||
(let ((ebox--render-cache-signature-cache
|
||||
(make-hash-table :test 'eq)))
|
||||
@ -645,7 +650,7 @@
|
||||
(right (ebox-dsl-test--root right-input))
|
||||
(box-input
|
||||
(ebox-dsl-test--box-create
|
||||
:layout (ebox-row-layout-create :item-gap 5 :cross-align 'center)
|
||||
:layout (ebox-row-layout-create :item-gap '(px 5) :cross-align 'center)
|
||||
:children (list left-input right-input)
|
||||
:source-handle 'row))
|
||||
(box (ebox-dsl-test--root box-input)))
|
||||
@ -654,7 +659,7 @@
|
||||
'row))
|
||||
(should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'row))
|
||||
(should (equal (ebox-layout-config-props (ebox-box-node-layout box))
|
||||
'(:item-gap 5 :cross-align center)))
|
||||
'(:item-gap (px 5) :cross-align center)))
|
||||
(should (equal (ebox-box-node-children box) (list left right)))
|
||||
(should (eq (ebox-tree-node-children box)
|
||||
(ebox-box-node-children box)))
|
||||
@ -679,13 +684,13 @@
|
||||
(bottom (ebox-dsl-test--root bottom-input))
|
||||
(box-input
|
||||
(ebox-dsl-test--box-create
|
||||
:layout (ebox-column-layout-create :item-gap 1 :cross-align 'end)
|
||||
:layout (ebox-column-layout-create :item-gap '(lh 1) :cross-align 'end)
|
||||
:children (list top-input bottom-input)))
|
||||
(box (ebox-dsl-test--root box-input)))
|
||||
(should (ebox-box-node-p box))
|
||||
(should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'column))
|
||||
(should (equal (ebox-layout-config-props (ebox-box-node-layout box))
|
||||
'(:item-gap 1 :cross-align end)))
|
||||
'(:item-gap (lh 1) :cross-align end)))
|
||||
(should (equal (ebox-box-node-children box) (list top bottom)))
|
||||
(should (eq (ebox-tree-node-children box)
|
||||
(ebox-box-node-children box)))
|
||||
@ -703,7 +708,11 @@
|
||||
"Row/Column configs should distinguish defaults from invalid explicit values."
|
||||
(dolist (constructor '(ebox-row-layout-create ebox-column-layout-create))
|
||||
(should (equal (ebox-layout-config-props (funcall constructor))
|
||||
'(:item-gap 0 :cross-align stretch)))
|
||||
(list :item-gap
|
||||
(if (eq constructor 'ebox-column-layout-create)
|
||||
'(lh 0)
|
||||
'(px 0))
|
||||
:cross-align 'stretch)))
|
||||
(dolist (plist '((:item-gap nil) (:item-gap -1)
|
||||
(:cross-align nil) (:cross-align bogus)
|
||||
(:gap 1) (:item-gap 1 :item-gap 2)))
|
||||
@ -723,7 +732,7 @@
|
||||
(equal (ebox-layout-config-props layout)
|
||||
'(:flex-direction row :flex-wrap nowrap
|
||||
:justify-content flex-start :align-items stretch
|
||||
:align-content stretch :row-gap 0 :column-gap 0)))
|
||||
:align-content stretch :row-gap (lh 0) :column-gap (px 0))))
|
||||
(should-not (plist-member (ebox-layout-config-props layout) :width))
|
||||
(should-not (plist-member (ebox-layout-config-props layout) :height))
|
||||
(should (ebox-box-node-p box))
|
||||
@ -793,13 +802,13 @@
|
||||
:justify-content 'space-between
|
||||
:align-items 'center
|
||||
:align-content 'end
|
||||
:row-gap '(2)
|
||||
:column-gap '(8))))
|
||||
:row-gap '(lh 2)
|
||||
:column-gap '(px 8))))
|
||||
(should
|
||||
(equal (ebox-layout-config-props layout)
|
||||
'(:flex-direction column-reverse :flex-wrap wrap
|
||||
:justify-content space-between :align-items center
|
||||
:align-content end :row-gap 2 :column-gap 8)))
|
||||
:align-content end :row-gap (lh 2) :column-gap (px 8))))
|
||||
(should (ebox-flex-layout-config-props-p
|
||||
(ebox-layout-config-props layout)))
|
||||
(should (ebox-box-node-p
|
||||
@ -834,15 +843,16 @@
|
||||
(ert-deftest ebox-flex-config-validation-does-not-reparse-canonical-pixels ()
|
||||
"Boundary validation should preserve physical pixels under non-unit fonts."
|
||||
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8)))
|
||||
(let* ((layout (ebox-flex-layout-create :column-gap '(8)))
|
||||
(let* ((layout (ebox-flex-layout-create :column-gap '(px 8)))
|
||||
(box (ebox-dsl-test--root
|
||||
(ebox-dsl-test--box-create
|
||||
:layout layout :children nil))))
|
||||
(should (= (plist-get (ebox-layout-config-props layout) :column-gap) 8))
|
||||
(should (= (plist-get
|
||||
(ebox-layout-config-props (ebox-box-node-layout box))
|
||||
:column-gap)
|
||||
8)))))
|
||||
(should (equal (plist-get (ebox-layout-config-props layout) :column-gap)
|
||||
'(px 8)))
|
||||
(should (equal (plist-get
|
||||
(ebox-layout-config-props (ebox-box-node-layout box))
|
||||
:column-gap)
|
||||
'(px 8))))))
|
||||
|
||||
(ert-deftest ebox-canonical-grid-box-uses-one-box-identity-and-child-list ()
|
||||
"A GridConfig should select the Box algorithm without a Grid runtime node."
|
||||
@ -876,7 +886,8 @@
|
||||
"AB"))
|
||||
(should (equal (mapcar #'ebox--string-pixel-width
|
||||
(ebox-string-lines rendered))
|
||||
'(120 120 120)))))
|
||||
;; Two rows with the default zero gap occupy two lines.
|
||||
'(120 120)))))
|
||||
|
||||
(ert-deftest ebox-canonical-grid-consumes-direct-child-placement ()
|
||||
"Grid placement should stay on child Boxes without grid-item wrappers."
|
||||
@ -987,25 +998,25 @@
|
||||
"The typed Grid constructor should emit one canonical closed property set."
|
||||
(let* ((layout
|
||||
(ebox-grid-layout-create
|
||||
:grid-template-columns '((20) (fr 1))
|
||||
:grid-template-rows '(1 auto)
|
||||
:grid-auto-columns '((30))
|
||||
:grid-auto-rows '(2)
|
||||
:grid-template-columns '((px 20) (fr 1))
|
||||
:grid-template-rows '((lh 1) auto)
|
||||
:grid-auto-columns '((px 30))
|
||||
:grid-auto-rows '((lh 2))
|
||||
:grid-auto-flow 'column
|
||||
:row-gap '(2) :column-gap '(8)
|
||||
:row-gap '(lh 2) :column-gap '(px 8)
|
||||
:justify-items 'center :align-items 'end
|
||||
:justify-content 'space-between :align-content 'center))
|
||||
(props (ebox-layout-config-props layout)))
|
||||
(should (equal (plist-get props :grid-template-columns)
|
||||
'((:kind fixed :size 20) (:kind fr :factor 1))))
|
||||
'((:kind fixed :size (px 20)) (:kind fr :factor 1))))
|
||||
(should (equal (plist-get props :grid-template-rows)
|
||||
'((:kind fixed :size 1) (:kind auto :factor 0))))
|
||||
'((:kind fixed :size (lh 1)) (:kind auto :factor 0))))
|
||||
(should (equal (plist-get props :grid-auto-columns)
|
||||
'(:kind fixed :size 30)))
|
||||
'(:kind fixed :size (px 30))))
|
||||
(should (equal (plist-get props :grid-auto-rows)
|
||||
'(:kind fixed :size 2)))
|
||||
(should (= (plist-get props :row-gap) 2))
|
||||
(should (= (plist-get props :column-gap) 8))
|
||||
'(:kind fixed :size (lh 2))))
|
||||
(should (equal (plist-get props :row-gap) '(lh 2)))
|
||||
(should (equal (plist-get props :column-gap) '(px 8)))
|
||||
(should (ebox-grid-layout-config-props-p props))
|
||||
(should (ebox-box-node-p
|
||||
(ebox-dsl-test--root
|
||||
@ -1059,14 +1070,14 @@
|
||||
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8)))
|
||||
(let* ((layout
|
||||
(ebox-grid-layout-create
|
||||
:grid-template-columns '((20)) :column-gap '(8)))
|
||||
:grid-template-columns '((px 20)) :column-gap '(px 8)))
|
||||
(box (ebox-dsl-test--root
|
||||
(ebox-dsl-test--box-create :layout layout :children nil)))
|
||||
(props (ebox-layout-config-props (ebox-box-node-layout box))))
|
||||
(should (= (plist-get props :column-gap) 8))
|
||||
(should (= (plist-get (car (plist-get props :grid-template-columns))
|
||||
:size)
|
||||
20)))))
|
||||
(should (equal (plist-get props :column-gap) '(px 8)))
|
||||
(should (equal (plist-get (car (plist-get props :grid-template-columns))
|
||||
:size)
|
||||
'(px 20))))))
|
||||
|
||||
(ert-deftest ebox-grid-config-boundary-rejects-forged-minmax-roles ()
|
||||
"Trusted Grid validation should enforce minmax roles after mutation."
|
||||
@ -1250,14 +1261,14 @@
|
||||
"Layout config belongs to its form; item roles belong to direct child Boxes."
|
||||
(let* ((flex
|
||||
(ebox-build
|
||||
'(flex :width '(300) :gap '(1 (5))
|
||||
'(flex :width '(px 300) :gap '((lh 1) (px 5))
|
||||
(box :flex 1 "A")
|
||||
"B")))
|
||||
(flex-child (car (ebox-box-node-children
|
||||
(ebox-dsl-test--root flex))))
|
||||
(grid
|
||||
(ebox-build
|
||||
'(grid :grid-template-columns '((20) (20))
|
||||
'(grid :grid-template-columns '((px 20) (px 20))
|
||||
(box :grid-column '(1 :span 2) "Header"))))
|
||||
(grid-child (car (ebox-box-node-children
|
||||
(ebox-dsl-test--root grid)))))
|
||||
|
||||
@ -109,14 +109,94 @@ author identity; opaque source handles remain internal to SourceBuilder."
|
||||
(defun ebox-test--declarations (tag props)
|
||||
"Return canonical TAG declarations from evaluated fixture PROPS."
|
||||
(ebox-style-compile-form
|
||||
tag (ebox-test--without props ebox-test--non-style-fields)))
|
||||
tag (ebox-test--without (ebox-test--explicit-sizes tag props)
|
||||
ebox-test--non-style-fields)))
|
||||
|
||||
(defun ebox-test--explicit-length (value axis &optional track)
|
||||
"Express fixture VALUE in explicit units for AXIS.
|
||||
Fixture numbers are historical cell measurements; singleton lists are pixel
|
||||
measurements, except for vertical Grid TRACK values, which are line counts.
|
||||
This adapter belongs only to old backend characterization fixtures. Public
|
||||
DSL and style tests call the production API directly and reject these forms."
|
||||
(cond
|
||||
((numberp value) (list axis value))
|
||||
((memq value '(viewport viewport-height))
|
||||
(list (if (eq value 'viewport) 'vw 'vh) 100))
|
||||
((and (consp value) (null (cdr value)))
|
||||
(if (numberp (car value))
|
||||
(list (if (and track (eq axis 'lh)) 'lh 'px) (car value))
|
||||
(if (memq (car value) '(viewport viewport-height))
|
||||
(ebox-test--explicit-length (car value) axis)
|
||||
value)))
|
||||
((and track (consp value) (memq (car value) '(minmax repeat)))
|
||||
(cons (car value)
|
||||
(cl-loop for part in (cdr value) for index from 0
|
||||
collect (if (and (eq (car value) 'repeat) (= index 0))
|
||||
part
|
||||
(ebox-test--explicit-length part axis t)))))
|
||||
(t value)))
|
||||
|
||||
(defun ebox-test--explicit-sizes (tag props)
|
||||
"Convert old backend fixture PROPS to explicit sizes for TAG."
|
||||
(cl-loop
|
||||
for (property value) on props by #'cddr
|
||||
for name = (symbol-name property)
|
||||
append
|
||||
(list
|
||||
property
|
||||
(cond
|
||||
((memq property '(:padding :margin :gap :padding-inline :padding-block
|
||||
:margin-inline :margin-block :border-width))
|
||||
(if (ebox-size-value-p value t) value
|
||||
(let* ((axes (cond ((eq property :border-width) '(px px px px))
|
||||
((string-suffix-p "-inline" name) '(ch ch))
|
||||
((string-suffix-p "-block" name) '(lh lh))
|
||||
((eq property :gap) '(lh ch))
|
||||
(t '(lh ch lh ch))))
|
||||
(parts (if (listp value) value (list value))))
|
||||
(when (and (= (length parts) 1)
|
||||
(not (eq (car axes) (cadr axes))))
|
||||
(setq parts (list (car parts) (car parts))))
|
||||
(cl-loop for part in parts for axis in axes
|
||||
collect (ebox-test--explicit-length part axis)))))
|
||||
((memq property '(:border :border-top :border-right :border-bottom
|
||||
:border-left))
|
||||
(if (and (listp value) (> (length value) 1)
|
||||
(numberp (car value)))
|
||||
(cons (list 'px (car value)) (cdr value))
|
||||
(ebox-test--explicit-length value 'px)))
|
||||
((memq property '(:grid-template-columns :grid-template-rows
|
||||
:grid-auto-columns :grid-auto-rows))
|
||||
(let ((axis (if (string-suffix-p "rows" name) 'lh 'ch)))
|
||||
(if (and (listp value)
|
||||
(not (memq (car value)
|
||||
'(px % vw vh ch lh calc min max clamp fr minmax repeat))))
|
||||
(mapcar (lambda (part) (ebox-test--explicit-length part axis t)) value)
|
||||
(ebox-test--explicit-length value axis t))))
|
||||
((eq property :flex)
|
||||
(if (and (listp value) (= (length value) 3))
|
||||
(list (car value) (cadr value)
|
||||
(ebox-test--explicit-length (nth 2 value) 'ch))
|
||||
value))
|
||||
((or (memq property '(:width :height :min-width :min-height :max-width
|
||||
:max-height :flex-basis :row-gap :column-gap :item-gap))
|
||||
(string-match-p "\\`:\\(?:padding\\|margin\\)-" name)
|
||||
(string-match-p "\\`:\\(?:border-.*-width\\)\\'" name))
|
||||
(ebox-test--explicit-length
|
||||
value (cond ((string-prefix-p ":border-" name) 'px)
|
||||
((eq property :item-gap) (if (eq tag 'column) 'lh 'px))
|
||||
((string-match-p "height\\|top\\|bottom\\|block\\|row-gap" name) 'lh)
|
||||
(t 'ch))
|
||||
(eq property :row-gap)))
|
||||
(t value)))))
|
||||
|
||||
(defun ebox-test--layout (tag props declarations)
|
||||
"Return TAG LayoutConfig from PROPS and DECLARATIONS."
|
||||
(ebox-layout-config-for-form
|
||||
tag
|
||||
(if (memq tag '(row column))
|
||||
(ebox-test--keep props '(:item-gap :cross-align))
|
||||
(ebox-test--keep (ebox-test--explicit-sizes tag props)
|
||||
'(:item-gap :cross-align))
|
||||
(ebox-style-declaration-properties
|
||||
declarations
|
||||
(lambda (property)
|
||||
@ -160,15 +240,15 @@ author identity; opaque source handles remain internal to SourceBuilder."
|
||||
(ebox-test--normalize-child builder child))
|
||||
children))
|
||||
(node
|
||||
(let ((ebox-canonical--source-builder builder))
|
||||
(ebox-box-create
|
||||
(ebox-box-create
|
||||
:source-builder builder
|
||||
:layout (ebox-test--layout tag props declarations)
|
||||
:outer (or (plist-get props :outer) 'block)
|
||||
:children nodes
|
||||
:owned-facts
|
||||
(ebox-canonical-facts-from-declarations tag declarations)
|
||||
:source-handle
|
||||
(ebox-test--source-handle builder props declarations))))
|
||||
(ebox-test--source-handle builder props declarations)))
|
||||
(index (ebox-source-builder-finish builder)))
|
||||
(dolist (key ebox-test--direct-fields)
|
||||
(when (plist-member props key)
|
||||
|
||||
@ -4,11 +4,248 @@
|
||||
(require 'ert)
|
||||
|
||||
(load-file (expand-file-name "../ebox.el" (file-name-directory (or load-file-name buffer-file-name))))
|
||||
(require 'ebox-native-reflow)
|
||||
|
||||
(defun ebox-flex-test--render (node)
|
||||
"Render NODE once for flex tests."
|
||||
(ebox-render node))
|
||||
|
||||
(ert-deftest ebox-flex-resolved-constraints-preserve-pixels-and-unbounded-width ()
|
||||
"Resolved constraints do not reapply author units or ambient defaults."
|
||||
(let ((ebox-viewport-width 300)
|
||||
(config (ebox-layout-config-props (ebox-flex-layout-create))))
|
||||
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
|
||||
(should (= (ebox--string-max-pixel-width
|
||||
(ebox--render-flex-children
|
||||
config '(:width 50 :height nil) '("X")))
|
||||
50))
|
||||
(should (= (ebox--string-max-pixel-width
|
||||
(ebox--render-flex-children
|
||||
config '(:width nil :height nil) '("X")))
|
||||
(ebox--string-pixel-width "X")))
|
||||
;; The private legacy adapter still supplies its omitted-width default.
|
||||
(should (= (ebox--string-max-pixel-width
|
||||
(ebox--render-flex
|
||||
(list :ebox-type 'flex :props config :children '("X"))))
|
||||
300)))))
|
||||
|
||||
(defun ebox-flex-test--intrinsic-toolbar (color &optional grow)
|
||||
"Return a nested max-content Flex using COLOR and optional GROW."
|
||||
(ebox-test-flex :width '(240) :flex-wrap 'wrap
|
||||
(ebox-test-column :width '(40) :flex-grow (if grow 0 1)
|
||||
(ebox-test-text "Title"))
|
||||
(ebox-test-flex :id "toolbar" :width 'max-content
|
||||
:padding '(0 (3)) :margin '(0 (4))
|
||||
:column-gap '(2) :justify-content 'space-between
|
||||
:flex-grow (if grow 1 0) :bgcolor color
|
||||
(ebox-test-box :width '(20) (ebox-test-text "A"))
|
||||
(ebox-test-box :width '(30) (ebox-test-text "B")))))
|
||||
|
||||
(ert-deftest ebox-flex-nested-max-content-survives-paint-and-fresh-render ()
|
||||
"A paint change preserves the same single-line intrinsic Flex geometry."
|
||||
(let* ((ebox-viewport-width 240)
|
||||
(layout (ebox-flex-test--intrinsic-toolbar "white"))
|
||||
(buffer (generate-new-buffer " *ebox-intrinsic-toolbar*")))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(ebox-render-to-buffer buffer layout)
|
||||
(dolist (color '("white" "black" "white"))
|
||||
(ebox-region-update (ebox-region-resolve buffer "toolbar")
|
||||
:bgcolor color)
|
||||
(let ((retained (with-current-buffer buffer (buffer-string)))
|
||||
(fresh (ebox-render
|
||||
(ebox-flex-test--intrinsic-toolbar color))))
|
||||
(dolist (rendered (list retained fresh))
|
||||
(should (= (ebox-string-height rendered) 1))
|
||||
(should (= (ebox--string-max-pixel-width rendered) 240))
|
||||
;; 174px title + 4px margin + 3px padding, counted once.
|
||||
(should (= (ebox--string-pixel-width
|
||||
(substring rendered 0 (string-match "A" rendered)))
|
||||
181)))
|
||||
(should (equal (substring-no-properties retained)
|
||||
(substring-no-properties fresh))))))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest ebox-flex-max-content-respects-parent-allocation ()
|
||||
"Intrinsic measurement does not discard a later definite Flex allocation."
|
||||
(let* ((ebox-viewport-width 240)
|
||||
(rendered (ebox-render
|
||||
(ebox-flex-test--intrinsic-toolbar "white" t))))
|
||||
(should (= (ebox-string-height rendered) 1))
|
||||
(should (= (ebox--string-max-pixel-width rendered) 240))
|
||||
(should (= (ebox--string-pixel-width
|
||||
(substring rendered 0 (string-match "A" rendered)))
|
||||
47))
|
||||
(should (= (ebox--string-pixel-width
|
||||
(substring rendered 0 (string-match "B" rendered)))
|
||||
203))))
|
||||
|
||||
(ert-deftest ebox-flex-unbounded-constraint-reaches-nested-auto-flex ()
|
||||
"An auto Flex inside max-content must not recover the outer viewport."
|
||||
(let* ((ebox-viewport-width 300)
|
||||
(layout
|
||||
(ebox-test-flex :width 'max-content
|
||||
(ebox-test-flex
|
||||
(ebox-test-box :width '(20) (ebox-test-text "A"))
|
||||
(ebox-test-box :width '(30) (ebox-test-text "B"))))))
|
||||
(should (= (ebox--string-max-pixel-width (ebox-render layout)) 50))))
|
||||
|
||||
(ert-deftest ebox-flex-padded-lazy-children-use-content-viewport ()
|
||||
"Lazy responsive Flex items use the same content viewport as full layout."
|
||||
(let* ((ebox-viewport-width 300)
|
||||
(ebox-viewport-height 5)
|
||||
(layout
|
||||
(ebox-test-column :width '(viewport) :height '(viewport-height)
|
||||
(ebox-test-flex :width 'stretch :padding '(0 (10))
|
||||
:flex-wrap 'wrap
|
||||
(ebox-test-flex :width 'stretch :wrap-mode 'none
|
||||
(ebox-test-box :width '(20) (ebox-test-text "A"))
|
||||
(ebox-test-box :width '(30) (ebox-test-text "B"))))))
|
||||
(_ (ebox-region-ids (ebox-test-root layout)))
|
||||
(eager (let ((ebox--scroll-window-render-disabled t))
|
||||
(ebox-render layout)))
|
||||
(original (symbol-function 'ebox--render-flex-window-lines))
|
||||
(lazy-renders 0)
|
||||
lazy)
|
||||
(cl-letf (((symbol-function 'ebox--render-flex-window-lines)
|
||||
(lambda (node limit)
|
||||
(let ((result (funcall original node limit)))
|
||||
(when result (cl-incf lazy-renders))
|
||||
result))))
|
||||
(setq lazy (ebox-render layout)))
|
||||
(should (> lazy-renders 0))
|
||||
(should (= (ebox--string-max-pixel-width eager) 300))
|
||||
(should (= (ebox--string-max-pixel-width lazy) 300))
|
||||
(should (equal-including-properties lazy eager))))
|
||||
|
||||
(ert-deftest ebox-flex-normalized-gap-keeps-pixels-on-wide-fonts ()
|
||||
"Flex gaps use the zero advance and preserve explicit pixel units."
|
||||
(cl-letf (((symbol-function 'ebox--size-character-width) (lambda (&optional _) 7)))
|
||||
(dolist (case '(((ch 2) . 14) ((px 2) . 2)))
|
||||
(let* ((layout
|
||||
(ebox-test-flex :width '(100) :column-gap (car case)
|
||||
(ebox-test-text "A")
|
||||
(ebox-test-text "B")))
|
||||
(rendered (ebox-render layout))
|
||||
(left (string-match "A" rendered))
|
||||
(right (string-match "B" rendered)))
|
||||
(should left)
|
||||
(should right)
|
||||
(should (= (ebox--string-pixel-width
|
||||
(substring rendered (1+ left) right))
|
||||
(cdr case)))
|
||||
(should
|
||||
(= (plist-get
|
||||
(ebox-native-reflow--compile-flex-inner
|
||||
(ebox-layout-config-props
|
||||
(ebox-box-node-layout (ebox-test-root layout))) nil)
|
||||
:column-gap)
|
||||
(cdr case)))))))
|
||||
|
||||
(ert-deftest ebox-flex-panels-in-padded-viewport-column-match-eager-layout ()
|
||||
"Lazy Column layout must preserve nested panel geometry and borders."
|
||||
(dolist (padding '(0 (1 2)))
|
||||
(dolist (viewport-width '(140 96 140))
|
||||
(let* ((ebox-viewport-width viewport-width)
|
||||
(ebox-viewport-height 30)
|
||||
(layout
|
||||
(ebox-test-column
|
||||
:width '(viewport) :height '(viewport-height)
|
||||
:padding padding :bgcolor "white"
|
||||
(ebox-test-flex :width 'stretch :flex-wrap 'wrap :gap '(1 2)
|
||||
(ebox-test-column
|
||||
:flex-basis '(20) :flex-shrink 0
|
||||
:padding '(1 2) :border "gray"
|
||||
(ebox-test-text "Sidebar"))
|
||||
(ebox-test-column
|
||||
:flex-grow 1 :flex-basis '(48) :min-width 0
|
||||
:padding '(1 2) :border "gray"
|
||||
(ebox-test-text "Main"))
|
||||
(ebox-test-column
|
||||
:flex-grow 1 :flex-basis '(28) :min-width 0
|
||||
(ebox-test-column :padding '(1 2) :border "gray"
|
||||
(ebox-test-text "Details") (ebox-test-text "Body"))
|
||||
(ebox-test-column :padding '(1 2) :border "gray"
|
||||
(ebox-test-text "Service") (ebox-test-text "Ready"))))))
|
||||
(_ (ebox-region-ids (ebox-test-root layout)))
|
||||
(eager (let ((ebox--scroll-window-render-disabled t))
|
||||
(ebox-render layout)))
|
||||
(lazy (ebox-render layout)))
|
||||
(should (equal (substring-no-properties lazy)
|
||||
(substring-no-properties eager)))
|
||||
(should (equal (mapcar #'ebox--string-pixel-width
|
||||
(ebox-string-lines lazy))
|
||||
(mapcar #'ebox--string-pixel-width
|
||||
(ebox-string-lines eager))))
|
||||
(should (equal-including-properties lazy eager))))))
|
||||
|
||||
(ert-deftest ebox-flex-viewport-column-wraps-text-and-preserves-box-overflow ()
|
||||
"A lazy Column wraps raw Text but keeps a definite child's spatial output."
|
||||
(let* ((ebox-viewport-width 20)
|
||||
(ebox-viewport-height 10)
|
||||
(layout
|
||||
(ebox-test-column
|
||||
:width '(viewport) :height '(viewport-height) :padding '(0 (2))
|
||||
(ebox-test-text "one two three four five six")
|
||||
(ebox-test-box :width '(30) :wrap-mode 'none :border "blue"
|
||||
(ebox-test-text "wide child"))))
|
||||
(_ (ebox-region-ids (ebox-test-root layout)))
|
||||
(eager (let ((ebox--scroll-window-render-disabled t))
|
||||
(ebox-render layout)))
|
||||
(lazy (ebox-render layout)))
|
||||
(should (equal-including-properties lazy eager))))
|
||||
|
||||
(ert-deftest ebox-flex-padded-scroll-producers-retain-containing-viewport ()
|
||||
"Lazy extension and full materialization keep the initial content width."
|
||||
(let* ((ebox-viewport-width 100)
|
||||
(ebox-viewport-height 4)
|
||||
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
||||
(ebox-wheel-scroll-step 0)
|
||||
(ebox-scroll-lazy-idle-prefetch-delay 999)
|
||||
(layout
|
||||
(apply #'ebox-test-column
|
||||
(append
|
||||
'(:id "padded-scroll" :width (viewport)
|
||||
:height (viewport-height) :padding (0 (2)))
|
||||
(cl-loop for index below 30
|
||||
collect
|
||||
(ebox-test-column :border "gray"
|
||||
(ebox-test-text (format "row %02d" index)))))))
|
||||
(buffer (generate-new-buffer " *ebox-padded-scroll*"))
|
||||
root-id)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(ebox-render-to-buffer buffer layout)
|
||||
(setq root-id
|
||||
(plist-get (car (ebox-selector-query-buffer
|
||||
buffer "#padded-scroll")) :region-id))
|
||||
(let* ((state (ebox-scroll-state root-id))
|
||||
(prefix (plist-get state :render-content-prefix))
|
||||
(materialize (plist-get state :materialize-content-lines)))
|
||||
(should prefix)
|
||||
(should materialize)
|
||||
(let* ((extended
|
||||
(ebox--scroll-state-ensure-prefix-lines root-id state 12 t))
|
||||
(partial-lines (plist-get extended :content-lines))
|
||||
(complete
|
||||
(ebox--scroll-state-materialize-lines root-id extended))
|
||||
(full-lines (plist-get complete :content-lines)))
|
||||
(should (= (length partial-lines) 12))
|
||||
(should (= (length full-lines) 30))
|
||||
(dolist (line (append partial-lines full-lines))
|
||||
(should (= (ebox--string-pixel-width line) 96)))
|
||||
(should (cl-every #'identity
|
||||
(cl-mapcar
|
||||
(lambda (partial full)
|
||||
(equal-including-properties
|
||||
(ebox--strip-paint-origins!
|
||||
(copy-sequence partial))
|
||||
(ebox--strip-paint-origins!
|
||||
(copy-sequence full))))
|
||||
partial-lines (seq-take full-lines 12)))))))
|
||||
(when root-id (ebox--scroll-cancel-idle-prefetch root-id))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(defun ebox-flex-test--plain-lines (node)
|
||||
"Return rendered NODE as plain lines."
|
||||
(ebox-string-lines (substring-no-properties (ebox-flex-test--render node))))
|
||||
@ -127,16 +364,16 @@
|
||||
(ert-deftest ebox-flex-module-preserves-item-participation ()
|
||||
"Extracted flex code should preserve item participation semantics."
|
||||
(let* ((node (ebox-build
|
||||
'(flex :width (240)
|
||||
(box :flex-grow 1 :width (40) "Grow")
|
||||
(box :width (40) "Fixed"))))
|
||||
'(flex :width (px 240)
|
||||
(box :flex-grow 1 :width (px 40) "Grow")
|
||||
(box :width (px 40) "Fixed"))))
|
||||
(rendered (ebox-render node)))
|
||||
(should (string-match-p "Grow" rendered))
|
||||
(should (string-match-p "Fixed" rendered))))
|
||||
|
||||
(ert-deftest ebox-flex-measures-direct-text-without-box-geometry ()
|
||||
"A canonical Text child should participate as text, not a geometry Box."
|
||||
(let* ((node (ebox-build '(flex :width (120) "Direct text")))
|
||||
(let* ((node (ebox-build '(flex :width (px 120) "Direct text")))
|
||||
(rendered (ebox-render node)))
|
||||
(should (string-match-p "Direct text"
|
||||
(substring-no-properties rendered)))
|
||||
@ -163,7 +400,7 @@
|
||||
(should (= (ebox-get (cadr items) :flex-grow) 2)))
|
||||
(ebox-style-reset-rules)
|
||||
(ebox-region-update (ebox-region-resolve buffer "rule")
|
||||
:width '(41))
|
||||
:width '(px 41))
|
||||
(let ((items (plist-get (plist-get (ebox--buffer-render-state buffer)
|
||||
:root-node)
|
||||
:children)))
|
||||
@ -276,6 +513,79 @@
|
||||
(widths (ebox-flex-test--content-widths layout)))
|
||||
(should (equal widths (list max-content min-content)))))
|
||||
|
||||
(ert-deftest ebox-flex-definite-basis-does-not-materialize-fallback ()
|
||||
"A definite basis must not materialize a subtree for an unused fallback."
|
||||
(dolist (basis '((px 0) (px 40) (ch 40)))
|
||||
(let* ((input
|
||||
(ebox-test-box
|
||||
(ebox-test-column
|
||||
(ebox-test-text "nested content")
|
||||
(ebox-test-text "second line"))))
|
||||
(source (ebox-test-root input))
|
||||
(ebox--surface-materialization-active t)
|
||||
(expected (ebox--size-pixels basis source 'width))
|
||||
(original-max (symbol-function 'ebox--content-max-pixel))
|
||||
(original-render (symbol-function 'ebox--render-layout))
|
||||
(max-calls 0)
|
||||
(materializations 0))
|
||||
(cl-letf (((symbol-function 'ebox--content-max-pixel)
|
||||
(lambda (box)
|
||||
(cl-incf max-calls)
|
||||
(funcall original-max box)))
|
||||
((symbol-function 'ebox--render-layout)
|
||||
(lambda (node)
|
||||
(cl-incf materializations)
|
||||
(funcall original-render node))))
|
||||
(should (= expected (ebox--flex-basis-main source "" 'row basis))))
|
||||
(should (zerop max-calls))
|
||||
(should (zerop materializations)))))
|
||||
|
||||
(ert-deftest ebox-flex-basis-retains-required-intrinsic-fallback ()
|
||||
"Viewport-like basis values measure only when their viewport is absent."
|
||||
(dolist (viewport '(nil 120))
|
||||
(dolist (basis '((vw 100) stretch))
|
||||
(let* ((content "natural content")
|
||||
(source (ebox-test-root (ebox-test-box (ebox-test-text content))))
|
||||
(ebox--surface-materialization-active t)
|
||||
(ebox-viewport-width viewport)
|
||||
(original-max (symbol-function 'ebox--content-max-pixel))
|
||||
(max-calls 0))
|
||||
(cl-letf (((symbol-function 'ebox--content-max-pixel)
|
||||
(lambda (box)
|
||||
(cl-incf max-calls)
|
||||
(funcall original-max box))))
|
||||
(should (= (or viewport (ebox--string-pixel-width content))
|
||||
(ebox--flex-basis-main source "" 'row basis))))
|
||||
(should (= max-calls (if viewport 0 1)))))))
|
||||
|
||||
(ert-deftest ebox-flex-lazy-basis-fallback-preserves-public-dsl-output ()
|
||||
"Public Flex layout retains complete output when unused probes disappear."
|
||||
(let* ((input
|
||||
(ebox-build
|
||||
'(flex :width (px 140) :flex-wrap wrap :gap ((lh 1) (px 4))
|
||||
(box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
|
||||
:wrap-mode word :padding ((lh 1) (px 2))
|
||||
:color "#123456" :bgcolor "#ddeeff"
|
||||
(column
|
||||
(text "alpha beta gamma delta epsilon")
|
||||
(text "second line")))
|
||||
(box :flex-basis (px 20) :min-width (ch 0) :wrap-mode word
|
||||
(text "peer")))))
|
||||
(original-basis (symbol-function 'ebox--flex-basis-main))
|
||||
(fast (ebox-render input))
|
||||
eager)
|
||||
;; Reintroduce only the eager fallback's materialization, using the same
|
||||
;; public input and the unchanged resolver for the reference output.
|
||||
(cl-letf (((symbol-function 'ebox--flex-basis-main)
|
||||
(lambda (source rendered axis basis)
|
||||
(when (and (eq axis 'row)
|
||||
(ebox--flex-box-source-p source)
|
||||
(ebox--flex-fixed-basis-value-p basis))
|
||||
(ebox--content-max-pixel source))
|
||||
(funcall original-basis source rendered axis basis))))
|
||||
(setq eager (ebox-render input)))
|
||||
(should (equal-including-properties fast eager))))
|
||||
|
||||
(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
|
||||
@ -404,16 +714,16 @@
|
||||
"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)
|
||||
'(flex :flex-flow (row wrap) :column-gap (px 12) :width (px 800)
|
||||
(column
|
||||
(box :width (220) "one")
|
||||
(box :width (42) "A"))
|
||||
(box :width (px 220) "one")
|
||||
(box :width (px 42) "A"))
|
||||
(column
|
||||
(box :width (220) "two")
|
||||
(box :width (42) "B"))
|
||||
(box :width (px 220) "two")
|
||||
(box :width (px 42) "B"))
|
||||
(column
|
||||
(box :width (220) "three")
|
||||
(box :width (42) "C")))))
|
||||
(box :width (px 220) "three")
|
||||
(box :width (px 42) "C")))))
|
||||
(lines (ebox-flex-test--plain-lines layout)))
|
||||
(should (= (length lines) 2))
|
||||
(should (string-match-p "one.*two.*three" (car lines)))
|
||||
@ -423,19 +733,19 @@
|
||||
"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)
|
||||
'(flex :flex-flow (row wrap) :column-gap (px 12) :width (px 800)
|
||||
(column
|
||||
(box :id "title" :max-width (293)
|
||||
:padding (0 (10)) :border "#5E7F6A"
|
||||
(box :id "title" :max-width (px 293)
|
||||
:padding ((lh 0) (px 10)) :border "#5E7F6A"
|
||||
:text-align center "wrap")
|
||||
(flex :id "body" :max-width (293) :padding (0 (8))
|
||||
(flex :id "body" :max-width (px 293) :padding ((lh 0) (px 8))
|
||||
:border-right "#5E7F6A"
|
||||
:border-bottom "#5E7F6A"
|
||||
:border-left "#5E7F6A"
|
||||
:flex-wrap wrap :column-gap (8)
|
||||
(box :flex-shrink 0 :width (118) "A 118")
|
||||
(box :flex-shrink 0 :width (118) "B 118")
|
||||
(box :flex-shrink 0 :width (118) "C 118"))))))
|
||||
:flex-wrap wrap :column-gap (px 8)
|
||||
(box :flex-shrink 0 :width (px 118) "A 118")
|
||||
(box :flex-shrink 0 :width (px 118) "B 118")
|
||||
(box :flex-shrink 0 :width (px 118) "C 118"))))))
|
||||
(buffer (generate-new-buffer " *ebox-flex-viewport*")))
|
||||
(unwind-protect
|
||||
(progn
|
||||
@ -461,36 +771,36 @@
|
||||
(let* ((ebox-viewport-width 500)
|
||||
(layout
|
||||
(ebox-build
|
||||
'(flex :flex-flow (row wrap) :row-gap 1 :column-gap (12)
|
||||
:padding (0 (12))
|
||||
'(flex :flex-flow (row wrap) :row-gap (lh 1) :column-gap (px 12)
|
||||
:padding ((lh 0) (px 12))
|
||||
:border-right "#6F6A95"
|
||||
:border-bottom "#6F6A95"
|
||||
:border-left "#6F6A95"
|
||||
:bgcolor "#FBF9FF"
|
||||
(column
|
||||
(box :max-width (440)
|
||||
:padding (0 (10)) :border "#6F6A95"
|
||||
(box :max-width (px 440)
|
||||
:padding ((lh 0) (px 10)) :border "#6F6A95"
|
||||
:text-align center ":flex-flow (row wrap)")
|
||||
(flex :max-width (440) :padding (0 (8))
|
||||
(flex :max-width (px 440) :padding ((lh 0) (px 8))
|
||||
:border-right "#6F6A95"
|
||||
:border-bottom "#6F6A95"
|
||||
:border-left "#6F6A95"
|
||||
:flex-flow (row wrap) :gap (1 (12))
|
||||
(box :width (170) "A")
|
||||
(box :width (170) "B")
|
||||
(box :width (170) "C wraps")))
|
||||
:flex-flow (row wrap) :gap ((lh 1) (px 12))
|
||||
(box :width (px 170) "A")
|
||||
(box :width (px 170) "B")
|
||||
(box :width (px 170) "C wraps")))
|
||||
(column
|
||||
(box :max-width (440)
|
||||
:padding (0 (10)) :border "#6F6A95"
|
||||
(box :max-width (px 440)
|
||||
:padding ((lh 0) (px 10)) :border "#6F6A95"
|
||||
:text-align center ":flex-flow (column wrap)")
|
||||
(flex :max-width (440) :padding (0 (8))
|
||||
(flex :max-width (px 440) :padding ((lh 0) (px 8))
|
||||
:border-right "#6F6A95"
|
||||
:border-bottom "#6F6A95"
|
||||
:border-left "#6F6A95"
|
||||
:flex-flow (column wrap) :gap (1 (12))
|
||||
(box :width (140) "A")
|
||||
(box :width (140) "B")
|
||||
(box :width (140) "C new column"))))))
|
||||
:flex-flow (column wrap) :gap ((lh 1) (px 12))
|
||||
(box :width (px 140) "A")
|
||||
(box :width (px 140) "B")
|
||||
(box :width (px 140) "C new column"))))))
|
||||
(rendered (ebox-flex-test--render layout))
|
||||
(line-widths (ebox-flex-test--line-widths layout))
|
||||
(border-widths
|
||||
@ -670,6 +980,209 @@
|
||||
'(220 220 220 220)))
|
||||
(should (= calls 2))))
|
||||
|
||||
(defun ebox-flex-test--observe-sized-box (input &optional force-final-render)
|
||||
"Render INPUT and observe its first child's actual Box materializations.
|
||||
When FORCE-FINAL-RENDER is non-nil, discard only the child's natural sized
|
||||
entry before final allocation, providing the original rerendering reference."
|
||||
(let* ((source (car (ebox-tree-layout-children (ebox-test-root input))))
|
||||
(handle (ebox-node-source-handle source))
|
||||
(render-box (symbol-function 'ebox--render-box))
|
||||
(render-sized (symbol-function 'ebox--flex-render-sized-entry))
|
||||
allocation observations final-source scroll-state rendered)
|
||||
(cl-letf (((symbol-function 'ebox--render-box)
|
||||
(lambda (box)
|
||||
(when (and allocation
|
||||
(eq (ebox-node-source-handle box) handle))
|
||||
(push
|
||||
(list :allocation allocation
|
||||
:width (ebox--flex-definite-property
|
||||
box 'row (ebox-get box :width))
|
||||
:height (ebox--flex-definite-property
|
||||
box 'column (ebox-get box :height))
|
||||
:constraints
|
||||
(ebox--render-cache-plist-signature box)
|
||||
:context
|
||||
(list ebox-viewport-width ebox-viewport-height
|
||||
ebox--intrinsic-layout-measurement
|
||||
ebox--inline-auto-width-intrinsic-p
|
||||
ebox--containing-wrap-mode
|
||||
ebox--box-content-cache-purpose
|
||||
ebox--render-region-id
|
||||
ebox--render-root-parent-kind
|
||||
ebox--scroll-window-render-disabled))
|
||||
observations))
|
||||
(funcall render-box box)))
|
||||
((symbol-function 'ebox--flex-render-sized-entry)
|
||||
(lambda (item axis main cross align)
|
||||
(let* ((target (eq (ebox-node-source-handle
|
||||
(plist-get item :source))
|
||||
handle))
|
||||
(previous-allocation allocation))
|
||||
(when (and target cross force-final-render)
|
||||
(setq item (copy-sequence item))
|
||||
(plist-put item :render-cache nil))
|
||||
(setq allocation (and target (list axis main cross align)))
|
||||
(unwind-protect
|
||||
(prog1 (funcall render-sized item axis main cross align)
|
||||
(when (and target cross)
|
||||
(let ((region-id
|
||||
(ebox-get (plist-get item :source) :region-id)))
|
||||
(setq final-source
|
||||
(ebox-node-source-handle
|
||||
(gethash region-id ebox--region-box-table))
|
||||
scroll-state (ebox-scroll-state region-id)))))
|
||||
(setq allocation previous-allocation))))))
|
||||
(setq rendered (ebox-render input)))
|
||||
(list :rendered rendered :observations (nreverse observations)
|
||||
:source-handle final-source :scroll-state scroll-state)))
|
||||
|
||||
(ert-deftest ebox-flex-identical-cross-materializes-box-once ()
|
||||
"An already assigned numeric cross size must not materialize a Box twice."
|
||||
(let* ((input
|
||||
(ebox-build
|
||||
'(flex :width (px 120) :flex-wrap wrap
|
||||
(box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
|
||||
:height (lh 2) :wrap-mode word
|
||||
(text "short")))))
|
||||
(observed (ebox-flex-test--observe-sized-box input))
|
||||
(calls (plist-get observed :observations))
|
||||
(first (car calls)))
|
||||
(should (= (ebox-string-height (plist-get observed :rendered)) 2))
|
||||
(should (equal (mapcar #'ebox--string-pixel-width
|
||||
(ebox-string-lines (plist-get observed :rendered)))
|
||||
'(120 120)))
|
||||
(should (= (plist-get first :width) 120))
|
||||
(should (= (plist-get first :height) 2))
|
||||
;; Before the fix both actual Box renders have identical complete node
|
||||
;; declarations and dynamic contexts; only the caller's allocation differs.
|
||||
(dolist (call (cdr calls))
|
||||
(should (equal (plist-get first :constraints)
|
||||
(plist-get call :constraints)))
|
||||
(should (equal (plist-get first :context)
|
||||
(plist-get call :context))))
|
||||
(ert-info ((format "Actual Box allocations: %S"
|
||||
(mapcar (lambda (call) (plist-get call :allocation))
|
||||
calls)))
|
||||
(should (= (length calls) 1)))))
|
||||
|
||||
(ert-deftest ebox-flex-identical-cross-preserves-properties-and-source ()
|
||||
"Reused row/column output retains chrome, text properties, and source ids."
|
||||
(dolist (axis '(row column))
|
||||
(dolist (sizing '(border-box content-box))
|
||||
(let* ((ebox-viewport-width nil)
|
||||
(row-p (eq axis 'row))
|
||||
(label (propertize "alpha beta" 'help-echo "retained leaf"
|
||||
'mouse-face 'highlight))
|
||||
(input
|
||||
(ebox-build
|
||||
`(flex ,@(if row-p '(:width (px 140)) '(:height (lh 10)))
|
||||
:flex-direction ,axis :flex-wrap wrap
|
||||
(box :flex-basis ,(if row-p '(px 40) '(lh 2)) :flex-grow 1
|
||||
:min-width (ch 0) :wrap-mode word :overflow hidden
|
||||
,@(if row-p
|
||||
(list :height (list 'lh (if (eq sizing 'border-box) 6 2)))
|
||||
'(:width (px 60)))
|
||||
:box-sizing ,sizing :padding ((lh 1) (px 2)) :margin ((lh 1) (px 3))
|
||||
:border ((px 1) solid "#123456")
|
||||
:color "#123456" :bgcolor "#ddeeff"
|
||||
(column :width ,(if row-p '(px 100) '(px 40))
|
||||
(text ,label) (text "second"))))))
|
||||
(source (car (ebox-tree-layout-children (ebox-test-root input))))
|
||||
(handle (ebox-node-source-handle source))
|
||||
(fast (ebox-flex-test--observe-sized-box input))
|
||||
(reference (ebox-flex-test--observe-sized-box input t))
|
||||
(rendered (plist-get fast :rendered)))
|
||||
(ert-info ((format "axis=%S sizing=%S" axis sizing))
|
||||
(should (= (length (plist-get fast :observations)) 1))
|
||||
(should (= (length (plist-get reference :observations)) 2))
|
||||
(should (equal-including-properties
|
||||
rendered (plist-get reference :rendered)))
|
||||
(should (eq (plist-get fast :source-handle) handle))
|
||||
(should (eq (plist-get reference :source-handle) handle))
|
||||
(let ((position (string-match "alpha" rendered)))
|
||||
(should position)
|
||||
(should (equal (get-text-property position 'help-echo rendered)
|
||||
"retained leaf"))
|
||||
(should (eq (get-text-property position 'mouse-face rendered)
|
||||
'highlight))
|
||||
(should (get-text-property position 'face rendered))))))))
|
||||
|
||||
(ert-deftest ebox-flex-identical-cross-keeps-changing-constraints ()
|
||||
"Automatic, relative, clamped, and enlarged cross constraints rerender."
|
||||
(dolist (case '(nil
|
||||
(:height (vh 100))
|
||||
(:height (lh 1) :overflow visible)
|
||||
(:height (lh 2) :peer-height 4)
|
||||
(:height (lh 4) :max-height (lh 2))
|
||||
(:height (lh 2) :min-height (lh 3))))
|
||||
(let* ((ebox-viewport-height 2)
|
||||
(peer-height (plist-get case :peer-height))
|
||||
(input
|
||||
(ebox-build
|
||||
`(flex :width (px 120) :flex-wrap wrap
|
||||
(box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
|
||||
:wrap-mode word
|
||||
,@(ebox--plist-remove-keys case '(:peer-height))
|
||||
(text "A\nB"))
|
||||
,@(when peer-height
|
||||
`((box :width (px 20) :height (lh ,peer-height) "peer"))))))
|
||||
(observed (ebox-flex-test--observe-sized-box input)))
|
||||
(ert-info ((format "Cross constraint: %S" case))
|
||||
(should (= (length (plist-get observed :observations)) 2))
|
||||
(should (equal-including-properties
|
||||
(plist-get observed :rendered)
|
||||
(plist-get (ebox-flex-test--observe-sized-box input t)
|
||||
:rendered)))))))
|
||||
|
||||
(ert-deftest ebox-flex-identical-cross-keeps-dependent-column-viewport ()
|
||||
"A numeric cross declaration alone cannot override viewport dependence."
|
||||
(let* ((ebox-viewport-width nil)
|
||||
(input
|
||||
(ebox-build
|
||||
'(flex :height (lh 4) :flex-direction column :flex-wrap wrap
|
||||
(box :width (px 60) :flex-basis (lh 2) :flex-grow 1 :overflow hidden
|
||||
(box :width (vw 100) (text "responsive"))))))
|
||||
(observed (ebox-flex-test--observe-sized-box input))
|
||||
(calls (plist-get observed :observations)))
|
||||
(should (= (length calls) 2))
|
||||
(should (equal (mapcar (lambda (call) (plist-get call :width)) calls)
|
||||
'(60 60)))
|
||||
(should (equal (mapcar (lambda (call) (car (plist-get call :context))) calls)
|
||||
'(nil 60)))
|
||||
(should (equal-including-properties
|
||||
(plist-get observed :rendered)
|
||||
(plist-get (ebox-flex-test--observe-sized-box input t)
|
||||
:rendered)))))
|
||||
|
||||
(ert-deftest ebox-flex-identical-cross-retains-scroll-state ()
|
||||
"A reused fixed-height Box retains the already rendered scroll geometry."
|
||||
(let* ((input
|
||||
(ebox-build
|
||||
'(flex :width (px 120) :flex-wrap wrap
|
||||
(box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
|
||||
:height (lh 2) :wrap-mode word :overflow scroll
|
||||
(column (text "A") (text "B") (text "C") (text "D"))))))
|
||||
(fast (ebox-flex-test--observe-sized-box input))
|
||||
(reference (ebox-flex-test--observe-sized-box input t))
|
||||
(state (plist-get fast :scroll-state))
|
||||
(old-state (plist-get reference :scroll-state)))
|
||||
(should (= (length (plist-get fast :observations)) 1))
|
||||
(should (= (length (plist-get reference :observations)) 2))
|
||||
(should (equal-including-properties
|
||||
(plist-get fast :rendered) (plist-get reference :rendered)))
|
||||
(should state)
|
||||
(should (= (plist-get state :content-height) 2))
|
||||
(should (= (ebox-get (plist-get state :box) :width) 120))
|
||||
(should (= (ebox--flex-definite-property
|
||||
(plist-get state :box) 'column
|
||||
(ebox-get (plist-get state :box) :height)) 2))
|
||||
(dolist (property '(:scroll-offset :content-height
|
||||
:content-lines-complete-p))
|
||||
(should (equal (plist-get state property) (plist-get old-state property))))
|
||||
(should (equal-including-properties
|
||||
(ebox-lines-join (plist-get state :content-lines))
|
||||
(ebox-lines-join (plist-get old-state :content-lines))))))
|
||||
|
||||
(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-test-box (ebox-test-text "A\nB") :width '(80) :height 1
|
||||
@ -682,20 +1195,40 @@
|
||||
(nth 1 lines) region-id)
|
||||
120))))
|
||||
|
||||
(ert-deftest ebox-flex-stretch-padding-retains-child-allocation-owner ()
|
||||
"Cross-axis blank lines remain part of the stretched child fragment."
|
||||
(let* ((child (ebox-test-box (ebox-test-text "A") :width '(80)))
|
||||
(region-id (car (ebox-region-ids (ebox-test-root child))))
|
||||
(layout
|
||||
(ebox-test-flex :width '(120) :height 3 :align-items 'stretch
|
||||
child))
|
||||
(lines (ebox-string-lines (ebox-render layout))))
|
||||
(should (= (length lines) 3))
|
||||
(dolist (line lines)
|
||||
(should
|
||||
(text-property-not-all
|
||||
0 (length line) 'ebox-content-owners nil line))
|
||||
(should
|
||||
(cl-some
|
||||
(lambda (position)
|
||||
(memq region-id
|
||||
(get-text-property position 'ebox-content-owners line)))
|
||||
(number-sequence 0 (1- (length line))))))))
|
||||
|
||||
(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)
|
||||
'(flex :width (px 120)
|
||||
(box :id "root" :width (px 80) :height (vh 100)
|
||||
:overflow scroll :flex-grow 1
|
||||
(column :width (80)
|
||||
(box :height 1 "A")
|
||||
(box :height 1 "B")
|
||||
(box :height 1 "C")
|
||||
(box :height 1 "D"))))))
|
||||
(column :width (px 80)
|
||||
(box :height (lh 1) "A")
|
||||
(box :height (lh 1) "B")
|
||||
(box :height (lh 1) "C")
|
||||
(box :height (lh 1) "D"))))))
|
||||
(buffer (generate-new-buffer " *ebox-flex-scroll-state*")))
|
||||
(unwind-protect
|
||||
(progn
|
||||
@ -711,8 +1244,9 @@
|
||||
(ert-deftest ebox-flex-natural-row-padding-keeps-overflow-line-local ()
|
||||
"Visible overflow should widen only the line that actually overflows."
|
||||
(let* ((viewport-child
|
||||
(ebox-test-box (ebox-test-text "A") :width '(viewport)
|
||||
(ebox-test-box (ebox-test-text "A") :width '(% 100)
|
||||
:box-sizing 'content-box
|
||||
:overflow 'visible
|
||||
:padding-left '(10) :padding-right '(10)))
|
||||
(source
|
||||
(ebox-test-column viewport-child
|
||||
@ -795,6 +1329,84 @@
|
||||
(should (= signature-calls 1))
|
||||
(should (= lookup-calls 1))))
|
||||
|
||||
(ert-deftest ebox-flex-signature-prepares-identities-once-before-memoization ()
|
||||
"Cold and warm signatures stabilize nested identities through one owner."
|
||||
(let (observed)
|
||||
(dolist (region-memo-p '(nil t))
|
||||
(let* ((root
|
||||
(ebox-test-root
|
||||
(ebox-test-flex :width '(120)
|
||||
(ebox-test-box :width '(40) (ebox-test-text "left"))
|
||||
(ebox-test-column
|
||||
(ebox-test-text "right")
|
||||
(ebox-test-box (ebox-test-text "nested"))))))
|
||||
(ebox--render-cache-signature-cache (make-hash-table :test 'eq))
|
||||
(ebox--node-region-ids-cache
|
||||
(and region-memo-p (make-hash-table :test 'eq)))
|
||||
(runtime-ids (symbol-function 'ebox--runtime-node-ids))
|
||||
(region-ids (symbol-function 'ebox--node-all-region-ids))
|
||||
(body-signature (symbol-function 'ebox--render-cache-node-body-signature))
|
||||
(runtime-calls 0) (region-calls 0) (body-calls 0)
|
||||
nodes identity-snapshots signatures memoized-body)
|
||||
(cl-labels ((walk (node)
|
||||
(push node nodes)
|
||||
(mapc #'walk (ebox-tree-node-children node))))
|
||||
(walk root))
|
||||
(dolist (node nodes)
|
||||
(should-not (plist-get node :node-id))
|
||||
(should-not (plist-get node :region-id)))
|
||||
(cl-letf
|
||||
(((symbol-function 'ebox--runtime-node-ids)
|
||||
(lambda (node)
|
||||
;; Recursive child entries are real work, not extra root probes.
|
||||
(when (eq node root) (cl-incf runtime-calls))
|
||||
(funcall runtime-ids node)))
|
||||
((symbol-function 'ebox--node-all-region-ids)
|
||||
(lambda (node)
|
||||
(when (eq node root) (cl-incf region-calls))
|
||||
(funcall region-ids node)))
|
||||
((symbol-function 'ebox--render-cache-node-body-signature)
|
||||
(lambda (node)
|
||||
(when (eq node root)
|
||||
(cl-incf body-calls)
|
||||
;; Inspect before the real memoizing helper can read or write
|
||||
;; its cache; the first cached body must include every id.
|
||||
(push
|
||||
(mapcar
|
||||
(lambda (child)
|
||||
(should (integerp (plist-get child :node-id)))
|
||||
(when (eq (plist-get child :ebox-type) 'box)
|
||||
(should (integerp (plist-get child :region-id))))
|
||||
(list (plist-get child :node-id)
|
||||
(plist-get child :region-id)))
|
||||
nodes)
|
||||
identity-snapshots))
|
||||
(funcall body-signature node))))
|
||||
(dolist (phase '(cold warm))
|
||||
(setq runtime-calls 0 region-calls 0 body-calls 0)
|
||||
(when (eq phase 'warm)
|
||||
(should (eq memoized-body
|
||||
(gethash root ebox--render-cache-signature-cache))))
|
||||
(push (ebox--flex-render-cache-signature root) signatures)
|
||||
(push (list region-memo-p phase runtime-calls region-calls body-calls)
|
||||
observed)
|
||||
(if (eq phase 'cold)
|
||||
(setq memoized-body
|
||||
(gethash root ebox--render-cache-signature-cache))
|
||||
(should (eq memoized-body
|
||||
(gethash root ebox--render-cache-signature-cache))))))
|
||||
(should memoized-body)
|
||||
(should (equal (car signatures) (cadr signatures)))
|
||||
(should (equal (car signatures) (ebox--render-cache-node-signature root)))
|
||||
(should (equal (car identity-snapshots) (cadr identity-snapshots)))
|
||||
(when region-memo-p
|
||||
(should (gethash root ebox--node-region-ids-cache)))))
|
||||
(setq observed (nreverse observed))
|
||||
(message "Flex signature root entries (region-memo phase runtime region body): %S"
|
||||
observed)
|
||||
(should (equal observed '((nil cold 1 1 1) (nil warm 1 1 1)
|
||||
(t cold 1 1 1) (t warm 1 1 1))))))
|
||||
|
||||
(ert-deftest ebox-flex-reuses-direct-column-natural-measurement ()
|
||||
"A direct Column's nil-viewport natural size should survive parent resize."
|
||||
(let* ((source-input
|
||||
@ -877,7 +1489,7 @@
|
||||
(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-test-box (ebox-test-text "VP") :width '(viewport)))
|
||||
(ebox-test-box (ebox-test-text "VP") :width '(% 100)))
|
||||
(source-input
|
||||
(ebox-test-column
|
||||
viewport-child
|
||||
@ -889,7 +1501,7 @@
|
||||
(ebox-test-flex :height 4
|
||||
:flex-direction 'column
|
||||
:align-items 'stretch
|
||||
(ebox-test-flex-item source-input :flex-grow 1 :flex-basis 2)))
|
||||
(ebox-test-flex-item source-input :flex-grow 1 :flex-basis '(lh 2))))
|
||||
(rendered (ebox-render layout)))
|
||||
(should (ebox-box-node-p source))
|
||||
(should (eq (ebox-layout-config-kind (ebox-box-node-layout source))
|
||||
@ -1151,6 +1763,7 @@
|
||||
(ebox-test-box (ebox-test-text "wide no-wrap label")
|
||||
:width '(150)
|
||||
:box-sizing 'border-box
|
||||
:overflow 'visible :min-width 'auto
|
||||
:wrap-mode 'none)
|
||||
:flex 1)
|
||||
(ebox-test-box (ebox-test-text "B") :width '(120))))
|
||||
@ -1200,5 +1813,53 @@
|
||||
(ebox-test-box (ebox-test-text "B") :width '(60) :height 1)
|
||||
(ebox-test-box (ebox-test-text "C") :width '(60) :height 1)))))))
|
||||
|
||||
(ert-deftest ebox-flex-allocation-content-keeps-warm-child-scroll-clears ()
|
||||
"Distinct allocation copies retain a warm child's A-to-B-to-A clears."
|
||||
(let* ((child (ebox-test-root
|
||||
(ebox-test-box
|
||||
(ebox-test-text (propertize "first\nsecond" 'help-echo "kept"))
|
||||
:width '(80) :height 3 :overflow 'scroll)))
|
||||
(source (ebox-test-root
|
||||
(ebox-test-box (ebox-test-text "parent") :width 'stretch)))
|
||||
(ebox--surface-materialization-active t)
|
||||
(ebox--render-cache-table (make-hash-table :test 'equal))
|
||||
(ebox--box-content-render-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--scroll-idle-prefetch-timers (make-hash-table :test 'equal))
|
||||
(ebox--render-cache-scroll-state-region-ids (make-hash-table :test 'equal))
|
||||
;; Normal bounded eviction removes the old child entry after B.
|
||||
(ebox-render-cache-max-entries 1)
|
||||
(warm (ebox--render-with-cache child))
|
||||
(region-id (plist-get child :region-id))
|
||||
(calls 0) first-admitted actual final-state)
|
||||
(should (plist-member (ebox--render-cache-probe child) :rendered))
|
||||
(should-not (gethash region-id ebox--scroll-global-state))
|
||||
(cl-labels ((render-parent (width renderer)
|
||||
(ebox--render-box-content-cached
|
||||
(ebox--flex-copy-node-for-size source 'row width nil t)
|
||||
renderer))
|
||||
(render-a ()
|
||||
(cl-incf calls)
|
||||
(ebox--render-with-cache child)))
|
||||
(should (equal-including-properties
|
||||
warm (render-parent 120 #'render-a)))
|
||||
(setq first-admitted (and (gethash source ebox--box-content-render-cache) t))
|
||||
(render-parent
|
||||
150
|
||||
(lambda ()
|
||||
(ebox--render-with-cache
|
||||
(ebox--flex-copy-node-for-size child 'row 80 1 t))))
|
||||
(should (gethash region-id ebox--scroll-global-state))
|
||||
(should-not (plist-member (ebox--render-cache-probe child) :rendered))
|
||||
(setq actual (render-parent 120 #'render-a)
|
||||
final-state (gethash region-id ebox--scroll-global-state))
|
||||
(let ((ebox--box-content-render-cache nil))
|
||||
(should (equal-including-properties actual (ebox--render-with-cache child)))))
|
||||
(should (equal-including-properties warm actual))
|
||||
(should-not final-state)
|
||||
(should-not first-admitted)
|
||||
(should (= calls 2))))
|
||||
|
||||
(provide 'ebox-flex-tests)
|
||||
;;; ebox-flex-tests.el ends here
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
(ebox-render-to-buffer
|
||||
buffer
|
||||
(ebox-build
|
||||
'(box :width (viewport)
|
||||
'(box :width (vw 100)
|
||||
(text :font-size 16 :font-style italic "A"))))
|
||||
(let* ((state-a (ebox--buffer-render-state buffer))
|
||||
(node-a
|
||||
@ -148,5 +148,44 @@
|
||||
(when (buffer-live-p buffer)
|
||||
(kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest ebox-measure-display-replacements-own-one-run ()
|
||||
"A display replacement is one unit even across face and metadata changes."
|
||||
(dolist (display (list '(space :width (15)) "X"))
|
||||
(let ((text (propertize "abc" 'display display)))
|
||||
(should (= (ebox-string-pixel-width text) (string-pixel-width text)))
|
||||
(put-text-property 1 2 'face 'bold text)
|
||||
(put-text-property 2 3 'help-echo "last" text)
|
||||
(should (= (ebox-string-pixel-width text) (string-pixel-width text)))
|
||||
(should (= (ebox-string-pixel-width (concat text "Z"))
|
||||
(string-pixel-width (concat text "Z")))))))
|
||||
|
||||
(ert-deftest ebox-measure-distinguishes-adjacent-display-identities ()
|
||||
"Equal but distinct display values are two replacements, including cache hits."
|
||||
(let* ((one (propertize "ab" 'display (list 'space :width (list 15))))
|
||||
(two (concat (propertize "a" 'display (list 'space :width (list 15)))
|
||||
(propertize "b" 'display (list 'space :width (list 15))))))
|
||||
(dotimes (_ 2)
|
||||
(should (= (ebox-string-pixel-width one) 15))
|
||||
(should (= (ebox-string-pixel-width two) 30)))))
|
||||
|
||||
(ert-deftest ebox-measure-quantizes-display-runs-without-splitting-them ()
|
||||
"Fractional replacements round once per EQ run and retain source properties."
|
||||
(let* ((first (propertize "abc" 'display (list 'space :width (list 3.5))
|
||||
'help-echo "first"))
|
||||
(second (propertize "de" 'display (list 'space :width (list 3.5))))
|
||||
(source (concat first second))
|
||||
(result (ebox--quantize-pixel-spaces source)))
|
||||
(should (= (ebox-string-pixel-width source) 7))
|
||||
(should (= (ebox-string-pixel-width result) 7))
|
||||
(should (eq (get-text-property 0 'display result)
|
||||
(get-text-property 2 'display result)))
|
||||
(should (eq (get-text-property 3 'display result)
|
||||
(get-text-property 4 'display result)))
|
||||
(should-not (eq (get-text-property 2 'display result)
|
||||
(get-text-property 3 'display result)))
|
||||
(should (equal (get-text-property 2 'help-echo result) "first"))
|
||||
(should (= (ebox--fixed-pixel-space-width (get-text-property 0 'display source))
|
||||
3.5))))
|
||||
|
||||
(provide 'ebox-font-tests)
|
||||
;;; ebox-font-tests.el ends here
|
||||
|
||||
@ -109,6 +109,317 @@
|
||||
entry-left rendered 100 '(:justify-items stretch))))
|
||||
(should (= calls 1)))))
|
||||
|
||||
(defun ebox-grid-test--background-widths (rendered color)
|
||||
"Return the width painted COLOR on each line of RENDERED."
|
||||
(mapcar
|
||||
(lambda (line)
|
||||
(let ((start 0) (width 0))
|
||||
(while (< start (length line))
|
||||
(let ((end (next-single-property-change
|
||||
start 'face line (length line))))
|
||||
(when (equal (plist-get (get-text-property start 'face line)
|
||||
:background)
|
||||
color)
|
||||
(cl-incf width (ebox--string-pixel-width
|
||||
(substring line start end))))
|
||||
(setq start end)))
|
||||
width))
|
||||
(ebox-string-lines rendered)))
|
||||
|
||||
(ert-deftest ebox-grid-block-alignment-stretches-the-item-background ()
|
||||
"Default and explicit stretch paint the item; other alignments position it."
|
||||
(dolist (case '((nil (40 40 40)) (normal (40 40 40))
|
||||
(stretch (40 40 40)) (start (40 0 0))
|
||||
(center (0 40 0)) (end (0 0 40))
|
||||
(flex-start (40 0 0)) (flex-end (0 0 40))))
|
||||
(dolist (height '(nil auto))
|
||||
(let* ((form `(grid :width (px 40)
|
||||
:grid-template-columns ((fr 1))
|
||||
:grid-template-rows ((lh 3))
|
||||
,@(when (car case) (list :align-items (car case)))
|
||||
(box :bgcolor "#ddeeff"
|
||||
,@(when height (list :height height)) "A")))
|
||||
(rendered (ebox-render (ebox-build form))))
|
||||
(ert-info ((format "Grid alignment %S, height %S" (car case) height))
|
||||
(should (equal (ebox-grid-test--background-widths rendered "#ddeeff")
|
||||
(cadr case))))))))
|
||||
|
||||
(ert-deftest ebox-grid-stretch-preserves-height-constraints-and-edges ()
|
||||
"Track stretch respects explicit heights, min/max constraints and margins."
|
||||
(dolist (case '((nil (40 40 40 40 40))
|
||||
((:height (lh 1)) (40 0 0 0 0))
|
||||
((:height (% 40)) (40 40 0 0 0))
|
||||
((:height min-content) (40 0 0 0 0))
|
||||
((:height max-content) (40 0 0 0 0))
|
||||
((:height fit-content) (40 0 0 0 0))
|
||||
((:min-height (lh 2)) (40 40 40 40 40))
|
||||
((:max-height (lh 2)) (40 40 0 0 0))
|
||||
((:min-height (lh 3) :max-height (lh 2)) (40 40 40 0 0))
|
||||
((:margin ((lh 1) (px 0))) (0 40 40 40 0))
|
||||
((:padding ((lh 1) (px 0))) (40 40 40 40 40))
|
||||
((:box-sizing border-box :padding ((lh 1) (px 0)))
|
||||
(40 40 40 40 40))))
|
||||
(let* ((rendered
|
||||
(ebox-render
|
||||
(ebox-build
|
||||
`(grid :width (px 40) :grid-template-columns ((fr 1))
|
||||
:grid-template-rows ((lh 5))
|
||||
(box :bgcolor "#ddeeff" ,@(car case) "A"))))))
|
||||
(ert-info ((format "Grid item properties %S" (car case)))
|
||||
(should (equal (ebox-grid-test--background-widths rendered "#ddeeff")
|
||||
(cadr case)))))))
|
||||
|
||||
(ert-deftest ebox-grid-align-self-overrides-container-block-alignment ()
|
||||
"The child's align-self controls both used height and vertical position."
|
||||
(dolist (case '((start stretch (40 40 40))
|
||||
(start normal (40 40 40))
|
||||
(stretch start (40 0 0))
|
||||
(stretch center (0 40 0))
|
||||
(stretch end (0 0 40))
|
||||
(end auto (0 0 40))))
|
||||
(let ((rendered
|
||||
(ebox-render
|
||||
(ebox-build
|
||||
`(grid :width (px 40) :grid-template-columns ((fr 1))
|
||||
:grid-template-rows ((lh 3)) :align-items ,(nth 0 case)
|
||||
(box :bgcolor "#ddeeff" :align-self ,(nth 1 case) "A"))))))
|
||||
(should (equal (ebox-grid-test--background-widths rendered "#ddeeff")
|
||||
(nth 2 case))))))
|
||||
|
||||
(ert-deftest ebox-grid-stretched-height-preserves-inline-alignment ()
|
||||
"Stretching the block axis must preserve a naturally sized inline axis."
|
||||
(dolist (justify '(start center end))
|
||||
(let* ((rendered
|
||||
(ebox-render
|
||||
(ebox-build
|
||||
`(grid :width (px 40) :grid-template-columns ((fr 1))
|
||||
:grid-template-rows ((lh 3)) :justify-items ,justify
|
||||
(box :bgcolor "#ddeeff" "A")))))
|
||||
(width (ebox--string-pixel-width "A"))
|
||||
(offset (pcase justify ('center (/ (- 40 width) 2))
|
||||
('end (- 40 width)) (_ 0)))
|
||||
(first (car (ebox-string-lines rendered))))
|
||||
(should (equal (ebox-grid-test--background-widths rendered "#ddeeff")
|
||||
(make-list 3 width)))
|
||||
(should (= (ebox--string-pixel-width
|
||||
(substring first 0 (string-match "A" first)))
|
||||
offset)))))
|
||||
|
||||
(ert-deftest ebox-grid-stretch-paints-composites-across-row-spans ()
|
||||
"Nested layouts stretch across the full row span including intervening gaps."
|
||||
(dolist (kind '(box column row flex grid))
|
||||
(let ((rendered
|
||||
(ebox-render
|
||||
(ebox-build
|
||||
`(grid :width (px 40) :grid-template-columns ((fr 1))
|
||||
:grid-template-rows ((lh 2) (lh 2)) :row-gap (lh 1)
|
||||
(,kind :grid-row (1 :span 2) :bgcolor "#ddeeff"
|
||||
(box "A")))))))
|
||||
(ert-info ((format "Stretched Grid item layout %S" kind))
|
||||
(should (equal (ebox-grid-test--background-widths rendered "#ddeeff")
|
||||
'(40 40 40 40 40)))))))
|
||||
|
||||
(ert-deftest ebox-grid-stretched-item-commits-preserve-source-geometry ()
|
||||
"Retained updates recompute stretch without retaining its temporary Box copy."
|
||||
(with-temp-buffer
|
||||
(dolist (case '((40 3 stretch) (60 5 stretch)
|
||||
(60 5 start) (40 3 stretch)))
|
||||
(let ((input
|
||||
(ebox-build
|
||||
`(grid :key root :width (px ,(nth 0 case))
|
||||
:grid-template-columns ((fr 1))
|
||||
:grid-template-rows ((lh ,(nth 1 case)))
|
||||
:align-items ,(nth 2 case)
|
||||
(box :key item :bgcolor "#ddeeff" "A")))))
|
||||
(if (ebox--buffer-render-state (current-buffer))
|
||||
(ebox-commit (current-buffer) input)
|
||||
(ebox-render-to-buffer (current-buffer) input))
|
||||
(let* ((state (ebox--buffer-render-state (current-buffer)))
|
||||
(child (car (ebox-tree-node-children
|
||||
(plist-get state :root-node))))
|
||||
(actual (buffer-string))
|
||||
(fresh (ebox-render input)))
|
||||
(should (memq (ebox-get child :height) '(nil auto)))
|
||||
(should (eq child (gethash (ebox-get child :region-id)
|
||||
(plist-get state :region-box-table))))
|
||||
(should (equal (ebox-grid-test--background-widths actual "#ddeeff")
|
||||
(ebox-grid-test--background-widths fresh "#ddeeff")))
|
||||
(should (equal (substring-no-properties actual)
|
||||
(substring-no-properties fresh))))))))
|
||||
|
||||
(ert-deftest ebox-grid-renders-assigned-cell-width-once ()
|
||||
"Final Grid assembly must reuse the width-sized cell's complete output."
|
||||
(let* ((cell-region 91001)
|
||||
(input
|
||||
(ebox-test-grid
|
||||
:width '(80)
|
||||
:grid-template-columns '((minmax (0) (fr 1)) (fr 1))
|
||||
:column-gap '(4)
|
||||
(ebox-test-box
|
||||
:region-id cell-region :width 'stretch :wrap-mode 'word
|
||||
:color "#112233" :bgcolor "#ddeeff"
|
||||
(ebox-test-text "alpha beta gamma delta epsilon zeta eta theta"))
|
||||
(ebox-test-box (ebox-test-text "peer") :width 'stretch)))
|
||||
(original (symbol-function 'ebox--render-box))
|
||||
contexts
|
||||
sized-output
|
||||
output)
|
||||
(cl-letf (((symbol-function 'ebox--render-box)
|
||||
(lambda (box)
|
||||
(let ((rendered (funcall original box)))
|
||||
(when (eq (ebox-get box :region-id) cell-region)
|
||||
(push (list ebox-viewport-width
|
||||
ebox--intrinsic-layout-measurement)
|
||||
contexts)
|
||||
(when (and (equal ebox-viewport-width 38)
|
||||
(not ebox--intrinsic-layout-measurement))
|
||||
(setq sized-output rendered)))
|
||||
rendered))))
|
||||
(setq output (ebox-render input)))
|
||||
;; The intrinsic pass is still required by the general Grid algorithm;
|
||||
;; one final render establishes both row height and the published cell.
|
||||
(should (= 1 (cl-count '(nil t) contexts :test #'equal)))
|
||||
(should (= 1 (cl-count '(38 nil) contexts :test #'equal)))
|
||||
(let ((face (get-text-property
|
||||
(string-match "alpha" sized-output) 'face sized-output)))
|
||||
(should face)
|
||||
(should (equal face (get-text-property
|
||||
(string-match "alpha" output) 'face output))))
|
||||
(let ((lines (ebox-string-lines output)))
|
||||
(should (> (length lines) 1))
|
||||
(should (equal (mapcar #'ebox--string-pixel-width lines)
|
||||
(make-list (length lines) 80))))))
|
||||
|
||||
(ert-deftest ebox-grid-definite-stretched-cells-skip-unused-intrinsic-output ()
|
||||
"Definite fixed/fr columns need only the stretched cell's final output."
|
||||
(dolist (width '(80 120))
|
||||
(dolist (cell-width '(nil auto stretch))
|
||||
(let* ((cell-region 91002)
|
||||
(input
|
||||
(ebox-test-grid
|
||||
:width (list width)
|
||||
:grid-template-columns '((fr 1) (28)) :column-gap '(4)
|
||||
(apply #'ebox-test-box
|
||||
(append
|
||||
(list :region-id cell-region :wrap-mode 'word
|
||||
:color "#112233" :bgcolor "#ddeeff")
|
||||
(and cell-width (list :width cell-width))
|
||||
(list
|
||||
(ebox-test-text
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota kappa"))))
|
||||
(ebox-test-box (ebox-test-text "peer") :width 'stretch)))
|
||||
(original (symbol-function 'ebox--render-box))
|
||||
(intrinsic-count 0)
|
||||
(assigned-count 0)
|
||||
fast slow)
|
||||
(cl-letf (((symbol-function 'ebox--render-box)
|
||||
(lambda (box)
|
||||
(when (eq (ebox-get box :region-id) cell-region)
|
||||
(if (and (null ebox-viewport-width)
|
||||
ebox--intrinsic-layout-measurement)
|
||||
(cl-incf intrinsic-count)
|
||||
(when (equal ebox-viewport-width (- width 32))
|
||||
(cl-incf assigned-count))))
|
||||
(funcall original box))))
|
||||
(setq fast (ebox-render input)))
|
||||
(should (zerop intrinsic-count))
|
||||
(should (= assigned-count 1))
|
||||
(cl-letf (((symbol-function 'ebox-grid--content-independent-columns-p)
|
||||
(lambda (&rest _) nil)))
|
||||
(setq slow (ebox-render input)))
|
||||
(should (equal-including-properties fast slow))))))
|
||||
|
||||
(ert-deftest ebox-grid-intrinsic-elision-preserves-spans-nesting-and-row-height ()
|
||||
"Reusing final cell output preserves wrapped rows, spans, and paint."
|
||||
(dolist (alignment '(start center end stretch))
|
||||
(let* ((input
|
||||
(ebox-test-grid
|
||||
:width '(96) :grid-template-columns '((fr 1) (fr 1))
|
||||
:grid-template-rows '(auto auto auto)
|
||||
:column-gap '(4) :row-gap 1 :align-items alignment
|
||||
(ebox-test-column
|
||||
:grid-column '(1 :span 2) :width 'stretch
|
||||
:color "#123456" :bgcolor "#ddeeff"
|
||||
(ebox-test-text "spanning header")
|
||||
(ebox-test-row
|
||||
(ebox-test-box (ebox-test-text "control") :padding '(0 (2)))
|
||||
(ebox-test-box (ebox-test-text "action") :padding '(0 (2)))))
|
||||
(ebox-test-box
|
||||
:grid-row '(2 :span 2) :grid-column 1
|
||||
:width 'stretch :wrap-mode 'word :min-width 'min-content
|
||||
(ebox-test-text
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota kappa"))
|
||||
(ebox-test-box :grid-row 2 :grid-column 2 :width 'stretch
|
||||
(ebox-test-text "peer"))
|
||||
(ebox-test-box :grid-row 3 :grid-column 2 :width 'stretch
|
||||
(ebox-test-text "tail"))))
|
||||
(fast (ebox-render input))
|
||||
slow)
|
||||
(cl-letf (((symbol-function 'ebox-grid--content-independent-columns-p)
|
||||
(lambda (&rest _) nil)))
|
||||
(setq slow (ebox-render input)))
|
||||
(should (equal-including-properties fast slow))
|
||||
(should (> (ebox-string-height fast) 3)))))
|
||||
|
||||
(ert-deftest ebox-grid-keeps-consumed-intrinsic-output ()
|
||||
"Content tracks, natural alignment, and indefinite sizes keep measurement."
|
||||
(dolist (case
|
||||
'((:width (80) :columns (auto (fr 1)))
|
||||
(:width (80) :columns (min-content (fr 1)))
|
||||
(:width (80) :columns (max-content (fr 1)))
|
||||
(:width (80) :columns ((minmax (0) (fr 1)) (fr 1)))
|
||||
(:width (80) :columns ((40)) :implicit t)
|
||||
(:width max-content :columns ((fr 1) (fr 1)))
|
||||
(:width (0) :columns ((fr 1) (fr 1)))
|
||||
(:width (80) :columns ((fr 1) (fr 1)) :justify start)
|
||||
(:width (80) :columns ((fr 1) (fr 1)) :justify center)
|
||||
(:width (80) :columns ((fr 1) (fr 1)) :justify end)
|
||||
(:width (80) :columns ((fr 1) (fr 1)) :cell-width (20))))
|
||||
(let* ((cell-region 91003)
|
||||
(input
|
||||
(ebox-test-grid
|
||||
:width (plist-get case :width)
|
||||
:grid-template-columns (plist-get case :columns)
|
||||
:grid-auto-flow (if (plist-get case :implicit) 'column 'row)
|
||||
:justify-items (or (plist-get case :justify) 'stretch)
|
||||
(ebox-test-box
|
||||
:region-id cell-region
|
||||
:width (or (plist-get case :cell-width) 'stretch)
|
||||
(ebox-test-text "natural content"))
|
||||
(ebox-test-box (ebox-test-text "peer") :width 'stretch)))
|
||||
(original (symbol-function 'ebox--render-box))
|
||||
(intrinsic-count 0))
|
||||
(cl-letf (((symbol-function 'ebox--render-box)
|
||||
(lambda (box)
|
||||
(when (and (eq (ebox-get box :region-id) cell-region)
|
||||
(null ebox-viewport-width)
|
||||
ebox--intrinsic-layout-measurement)
|
||||
(cl-incf intrinsic-count))
|
||||
(funcall original box))))
|
||||
(ebox-render input))
|
||||
(should (> intrinsic-count 0)))))
|
||||
|
||||
(ert-deftest ebox-grid-missing-intrinsic-output-requires-unconditional-stretch ()
|
||||
"Only unconditional width-sized rendering may consume an unmeasured cell."
|
||||
(let* ((node (ebox-test-root
|
||||
(ebox-test-box (ebox-test-text "cell") :width 'stretch)))
|
||||
(entry (list :node node))
|
||||
(rendered (make-hash-table :test #'eq))
|
||||
(calls 0))
|
||||
(cl-letf (((symbol-function 'ebox--render-with-cache)
|
||||
(lambda (&rest _)
|
||||
(cl-incf calls)
|
||||
"assigned")))
|
||||
(should (equal "assigned"
|
||||
(ebox-grid--entry-source
|
||||
entry rendered 40 '(:justify-items stretch))))
|
||||
(should-error
|
||||
(ebox-grid--entry-source entry rendered 40 '(:justify-items start)))
|
||||
(plist-put node :width '(20))
|
||||
(should-error
|
||||
(ebox-grid--entry-source entry rendered 40 '(:justify-items stretch)))
|
||||
(should (= calls 1)))))
|
||||
|
||||
(ert-deftest ebox-grid-constrains-auto-width-children-to-track-size ()
|
||||
"Auto-width grid children should render within their assigned track."
|
||||
(let* ((ebox-viewport-width 120)
|
||||
@ -216,6 +527,147 @@
|
||||
(should (string-match-p "Header" plain))
|
||||
(should (string-match-p "Left.*Right" plain))))
|
||||
|
||||
(ert-deftest ebox-grid-grown-rows-keep-independent-occupancy ()
|
||||
"Growing several rows must allocate a distinct occupancy vector per row."
|
||||
(let* ((original (ebox-grid--occupancy 1 2))
|
||||
(grown (ebox-grid--grow original 3 2)))
|
||||
(should (eq (aref original 0) (aref grown 0)))
|
||||
(should-not (eq (aref grown 1) (aref grown 2)))
|
||||
(aset (aref grown 1) 1 'occupied)
|
||||
(should-not (aref (aref grown 0) 1))
|
||||
(should-not (aref (aref grown 2) 1)))
|
||||
(let* ((input
|
||||
(ebox-test-grid
|
||||
:grid-template-columns '((20) (20)) :row-gap 0
|
||||
(ebox-test-box :grid-row 1 :grid-column '(1 :span 2)
|
||||
(ebox-test-text "header"))
|
||||
(ebox-test-box :grid-row '(2 :span 2) :grid-column 1
|
||||
(ebox-test-text "spanned"))
|
||||
(ebox-test-box :grid-row 2 :grid-column 2
|
||||
(ebox-test-text "middle"))
|
||||
(ebox-test-box :grid-row 3 :grid-column 2
|
||||
(ebox-test-text "last"))))
|
||||
(lines (seq-remove
|
||||
#'string-blank-p
|
||||
(mapcar #'substring-no-properties
|
||||
(ebox-string-lines (ebox-render input))))))
|
||||
(should (= (length lines) 3))
|
||||
(should (string-match-p "header" (nth 0 lines)))
|
||||
(should (string-match-p "spanned.*middle" (nth 1 lines)))
|
||||
(should (string-match-p "last" (nth 2 lines)))))
|
||||
|
||||
(ert-deftest ebox-grid-zero-row-gap-adds-no-display-line ()
|
||||
"Row gaps contribute exactly their declared line count, including zero."
|
||||
(dolist (gap '(0 1 2))
|
||||
(let* ((input
|
||||
(ebox-test-grid
|
||||
:grid-template-columns '((20) (20))
|
||||
:grid-template-rows '(1 2) :row-gap gap
|
||||
(ebox-test-box :grid-row 1 :grid-column '(1 :span 2)
|
||||
(ebox-test-text "header"))
|
||||
(ebox-test-box :grid-row 2 :grid-column 1
|
||||
(ebox-test-text "body\ntail"))
|
||||
(ebox-test-box :grid-row 2 :grid-column 2
|
||||
(ebox-test-text ""))))
|
||||
(lines (ebox-string-lines (ebox-render input))))
|
||||
(should (= (length lines) (+ 3 gap)))
|
||||
(should (string-match-p "header" (nth 0 lines)))
|
||||
(dotimes (index gap)
|
||||
(should (string-blank-p (nth (1+ index) lines))))
|
||||
(should (string-match-p "body" (nth (1+ gap) lines)))
|
||||
(should (string-match-p "tail" (nth (+ 2 gap) lines))))))
|
||||
|
||||
(ert-deftest ebox-grid-row-spans-preserve-text-across-gaps ()
|
||||
"A spanning cell retains text and paint across rows and positive gaps."
|
||||
(dolist (gap '(0 1 2))
|
||||
(dolist (alignment '(start center end stretch))
|
||||
(let* ((input
|
||||
(ebox-test-grid
|
||||
:grid-template-columns '((20) (20))
|
||||
:grid-template-rows '(2 2) :row-gap gap :align-items alignment
|
||||
(ebox-test-box
|
||||
:grid-row '(1 :span 2) :grid-column 1
|
||||
:color "#123456"
|
||||
:surface-properties '(help-echo "spanning-help")
|
||||
(ebox-test-text "spanned\ncontinued"))
|
||||
(ebox-test-box :grid-row 1 :grid-column 2
|
||||
(ebox-test-text "top"))
|
||||
(ebox-test-box :grid-row 2 :grid-column 2
|
||||
(ebox-test-text "bottom"))))
|
||||
(rendered (ebox-render input))
|
||||
(lines (ebox-string-lines rendered))
|
||||
(start (pcase alignment
|
||||
('center (/ (+ 2 gap) 2))
|
||||
('end (+ 2 gap))
|
||||
(_ 0))))
|
||||
(should (= (length lines) (+ 4 gap)))
|
||||
(should (string-match-p "spanned" (nth start lines)))
|
||||
(should (string-match-p "continued" (nth (1+ start) lines)))
|
||||
(dotimes (index gap)
|
||||
(should-not (string-match-p "top\\|bottom" (nth (+ 2 index) lines))))
|
||||
(let ((face (get-text-property (string-match "spanned" rendered)
|
||||
'face rendered)))
|
||||
(should face)
|
||||
(should (equal face (get-text-property
|
||||
(string-match "continued" rendered)
|
||||
'face rendered)))
|
||||
(dolist (label '("spanned" "continued"))
|
||||
(should (equal "spanning-help"
|
||||
(get-text-property (string-match label rendered)
|
||||
'help-echo rendered)))))))))
|
||||
|
||||
(ert-deftest ebox-grid-zero-height-track-contributes-only-its-gap ()
|
||||
"A zero-height track adds no text line, while declared gaps remain real."
|
||||
(dolist (gap '(0 1 2))
|
||||
(dolist (rows '((0 1) (1 0)))
|
||||
(let* ((leading-zero-p (zerop (car rows)))
|
||||
(input
|
||||
(ebox-build
|
||||
`(grid :width (px 40) :grid-template-columns ((px 40))
|
||||
:grid-template-rows ,(mapcar (lambda (n) (list 'lh n)) rows)
|
||||
:row-gap (lh ,gap)
|
||||
(text ,(if leading-zero-p "hidden" "visible"))
|
||||
(text ,(if leading-zero-p "visible" "hidden")))))
|
||||
(rendered (ebox-render input))
|
||||
(lines (ebox-string-lines rendered)))
|
||||
(should (= (length lines) (1+ gap)))
|
||||
(should-not (string-match-p "hidden" rendered))
|
||||
(should (string-match-p "visible"
|
||||
(nth (if leading-zero-p gap 0) lines)))
|
||||
(dolist (line (if leading-zero-p (butlast lines) (cdr lines)))
|
||||
(should (string-blank-p line)))))))
|
||||
|
||||
(ert-deftest ebox-grid-row-span-preserves-lines-across-zero-height-track ()
|
||||
"A zero-height row within a span changes offsets only through its gaps."
|
||||
(dolist (gap '(0 1 2))
|
||||
(let* ((height (+ 2 (* 2 gap)))
|
||||
(labels (cl-loop for index below height
|
||||
collect (format "line-%d" index)))
|
||||
(input
|
||||
(ebox-test-grid
|
||||
:width '(40) :grid-template-columns '((20) (20))
|
||||
:grid-template-rows '(1 0 1) :row-gap gap
|
||||
(ebox-test-box
|
||||
:grid-row '(1 :span 3) :grid-column 1
|
||||
:surface-properties '(help-echo "spanning-help")
|
||||
(ebox-test-text (mapconcat #'identity labels "\n")))
|
||||
(ebox-test-box :grid-row 1 :grid-column 2
|
||||
(ebox-test-text "top"))
|
||||
(ebox-test-box :grid-row 2 :grid-column 2
|
||||
(ebox-test-text "hidden"))
|
||||
(ebox-test-box :grid-row 3 :grid-column 2
|
||||
(ebox-test-text "bottom"))))
|
||||
(rendered (ebox-render input))
|
||||
(lines (ebox-string-lines rendered)))
|
||||
(should (= (length lines) height))
|
||||
(should-not (string-match-p "hidden" rendered))
|
||||
(cl-loop for label in labels
|
||||
for line in lines
|
||||
do (should (string-match-p label line))
|
||||
do (should (equal "spanning-help"
|
||||
(get-text-property (string-match label line)
|
||||
'help-echo line)))))))
|
||||
|
||||
(ert-deftest ebox-grid-uses-implicit-track-templates ()
|
||||
"Implicit columns should use `:grid-auto-columns' when they grow."
|
||||
(let* ((node (ebox-test-grid
|
||||
@ -308,7 +760,7 @@
|
||||
"The public Ebox DSL should compile a grid node."
|
||||
(let* ((input
|
||||
(ebox-build
|
||||
'(grid :grid-template-columns ((20) (20))
|
||||
'(grid :grid-template-columns ((px 20) (px 20))
|
||||
(box "A")
|
||||
(box "B"))))
|
||||
(node (ebox-test-root input)))
|
||||
@ -321,7 +773,7 @@
|
||||
"The Ebox DSL should preserve direct child Box placement metadata."
|
||||
(let* ((input
|
||||
(ebox-build
|
||||
'(grid :grid-template-columns ((20) (20))
|
||||
'(grid :grid-template-columns ((px 20) (px 20))
|
||||
(box :grid-column (1 :span 2) "Header"))))
|
||||
(node (ebox-test-root input)))
|
||||
(should (string-match-p "Header" (ebox-grid-test--plain input)))
|
||||
|
||||
1195
tests/ebox-interaction-tests.el
Normal file
1195
tests/ebox-interaction-tests.el
Normal file
File diff suppressed because it is too large
Load Diff
323
tests/ebox-layer-publication-tests.el
Normal file
323
tests/ebox-layer-publication-tests.el
Normal file
@ -0,0 +1,323 @@
|
||||
;;; ebox-layer-publication-tests.el --- Retained layer updates -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Exercise layer composition through public mounted update APIs. Hidden
|
||||
;; source objects remain logical inputs, while only visible roles are mounted.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'ert)
|
||||
(require 'ebox)
|
||||
(require 'ebox-selector)
|
||||
|
||||
(defmacro ebox-layer-publication-test--with-buffer (&rest body)
|
||||
"Run BODY with an isolated buffer and the explicit Elisp backend."
|
||||
(declare (indent 0) (debug t))
|
||||
`(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
|
||||
(lambda () nil)))
|
||||
(let ((ebox-viewport-width 160) (ebox-viewport-height 10))
|
||||
(with-temp-buffer ,@body))))
|
||||
|
||||
(defun ebox-layer-publication-test--input (&optional lower upper-visible)
|
||||
"Build a nested fixed host with LOWER text and UPPER-VISIBLE overlay."
|
||||
(ebox-build
|
||||
`(column :id "root" :width (ch 12) :height (lh 5)
|
||||
(box :id "before" "Before")
|
||||
(box :id "host" :width (ch 8) :height (lh 2)
|
||||
(box :id "lower" :width (ch 8) :height (lh 1)
|
||||
,(or lower "LOWER001"))
|
||||
(box :id "upper" :position absolute :left (px 0) :top (lh 0)
|
||||
:width (ch 8) :height (lh 1) :z-index 1
|
||||
:visibility ,(if upper-visible 'visible 'hidden)
|
||||
"UPPER001"))
|
||||
(box :id "after" "After"))))
|
||||
|
||||
(defun ebox-layer-publication-test--assert-fresh-render ()
|
||||
"Check visible text against a fresh render of the committed logical input."
|
||||
(should
|
||||
(equal (buffer-substring-no-properties (point-min) (point-max))
|
||||
(substring-no-properties
|
||||
(ebox-render
|
||||
(plist-get (ebox-surface-buffer-snapshot (current-buffer))
|
||||
:input))))))
|
||||
|
||||
(ert-deftest ebox-layer-publication-updates-every-placement-property ()
|
||||
"Each layer declaration reaches the mounted runtime through a region update."
|
||||
(dolist (properties '((:position relative) (:left (ch 2)) (:top (lh 1))
|
||||
(:z-index -1) (:layer root) (:anchor "lower")
|
||||
(:placement top-end)))
|
||||
(ert-info ((format "Update %S" properties))
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-layer-publication-test--input nil t))
|
||||
(let* ((handle (ebox-region-resolve (current-buffer) "upper"))
|
||||
(region-id (cdr (ebox-selector--region-target handle)))
|
||||
(revision (ebox-surface-buffer-revision (current-buffer))))
|
||||
(should (apply #'ebox-region-update handle properties))
|
||||
(should (> (ebox-surface-buffer-revision (current-buffer)) revision))
|
||||
(should (equal (plist-get
|
||||
(ebox--buffer-region-render-owner-node
|
||||
(current-buffer) region-id)
|
||||
(car properties))
|
||||
(cadr properties)))
|
||||
(ebox-layer-publication-test--assert-fresh-render))))))
|
||||
|
||||
(ert-deftest ebox-layer-publication-hidden-updates-reveal-current-input ()
|
||||
"Hidden content and paint updates survive without painting over upper text."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-layer-publication-test--input nil t))
|
||||
(let ((render (symbol-function 'ebox-surface--render-candidate))
|
||||
(full-renders 0))
|
||||
(should (string-match-p "UPPER001" (buffer-string)))
|
||||
(should-not (string-match-p "LOWER001" (buffer-string)))
|
||||
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
|
||||
(lambda (&rest arguments)
|
||||
(cl-incf full-renders)
|
||||
(apply render arguments))))
|
||||
(ebox-region-update "lower" :content "LOWER002")
|
||||
(ebox-selector-update-buffer
|
||||
(current-buffer) "#lower" :color "#ff0000")
|
||||
(should (string-match-p "UPPER001" (buffer-string)))
|
||||
(should-not (string-match-p "LOWER002" (buffer-string)))
|
||||
(ebox-region-update "upper" :visibility 'hidden)
|
||||
(should (string-match-p "LOWER002" (buffer-string)))
|
||||
(should-not (string-match-p "UPPER001" (buffer-string)))
|
||||
(let* ((start (string-match "LOWER002" (buffer-string)))
|
||||
(face (get-text-property (+ (point-min) start) 'face)))
|
||||
(should (string-match-p "#ff0000" (prin1-to-string face)))))
|
||||
(should (= full-renders 0))
|
||||
(ebox-layer-publication-test--assert-fresh-render))))
|
||||
|
||||
(ert-deftest ebox-layer-publication-rejected-hidden-commit-rolls-back ()
|
||||
"Rejected hidden updates leave retained input, revision and text unchanged."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-layer-publication-test--input nil t))
|
||||
(let ((before (buffer-string))
|
||||
(revision (ebox-surface-buffer-revision (current-buffer))))
|
||||
(should-error
|
||||
(ebox-commit
|
||||
(current-buffer) (ebox-layer-publication-test--input "REJECTED" t)
|
||||
(lambda (_report) (error "Reject hidden layer candidate"))))
|
||||
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
|
||||
(should (equal-including-properties before (buffer-string)))
|
||||
(ebox-region-update "upper" :visibility 'hidden)
|
||||
(should (string-match-p "LOWER001" (buffer-string)))
|
||||
(should-not (string-match-p "REJECTED" (buffer-string)))
|
||||
(ebox-layer-publication-test--assert-fresh-render))))
|
||||
|
||||
(ert-deftest ebox-layer-publication-footprint-change-falls-back ()
|
||||
"Host geometry changes preserve siblings through the existing root fallback."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-layer-publication-test--input nil t))
|
||||
(let ((render (symbol-function 'ebox-surface--render-candidate))
|
||||
(full-renders 0))
|
||||
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
|
||||
(lambda (&rest arguments)
|
||||
(cl-incf full-renders)
|
||||
(apply render arguments))))
|
||||
(ebox-region-update "host" :height '(lh 3)))
|
||||
(should (> full-renders 0))
|
||||
(should (string-match-p "Before" (buffer-string)))
|
||||
(should (string-match-p "After" (buffer-string)))
|
||||
(ebox-layer-publication-test--assert-fresh-render))))
|
||||
|
||||
(ert-deftest ebox-layer-publication-ordinary-update-skips-layer-walk ()
|
||||
"A non-layer tree's owner planner does not scan on content changes."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer)
|
||||
(ebox-build '(column :width (ch 12) :height (lh 3)
|
||||
(box :id "text" :width (ch 8) :height (lh 1) "BEFORE00"))))
|
||||
(let ((plan (symbol-function 'ebox-incremental--layer-owner-plan)))
|
||||
(cl-letf (((symbol-function 'ebox-incremental--layer-owner-plan)
|
||||
(lambda (&rest arguments)
|
||||
(cl-letf (((symbol-function 'ebox-layer-host-p)
|
||||
(lambda (&rest _)
|
||||
(ert-fail "Unexpected layer host walk")))
|
||||
((symbol-function 'ebox-layer-subtree-p)
|
||||
(lambda (&rest _)
|
||||
(ert-fail "Unexpected layer subtree scan"))))
|
||||
(apply plan arguments)))))
|
||||
(ebox-region-update "text" :content "AFTER000")))
|
||||
(should (string-match-p "AFTER000" (buffer-string)))))
|
||||
|
||||
(ert-deftest ebox-layer-publication-declines-unsupported-native-layout ()
|
||||
"A retained layer tree is ineligible for both native layout entry points."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-layer-publication-test--input nil t))
|
||||
(let* ((state (ebox--buffer-render-state (current-buffer)))
|
||||
(root (plist-get state :root-node)))
|
||||
(should-not (ebox-native-reflow--native-node-supported-p root))
|
||||
(should-not (ebox-native-commit--runtime-types-supported-p state))
|
||||
(should-not (plist-get state :native-render-p)))
|
||||
(ebox-region-update "lower" :content "LOWER002")
|
||||
(ebox-region-update "upper" :visibility 'hidden)
|
||||
(ebox-layer-publication-test--assert-fresh-render)))
|
||||
|
||||
(ert-deftest ebox-layer-publication-visible-interactions-follow-occlusion ()
|
||||
"Only the visible layer owns interaction properties after hidden updates."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-layer-publication-test--input nil t))
|
||||
(ebox-region-update "lower" :keymap '(keymap (13 . backward-char))
|
||||
:help-echo "lower-old")
|
||||
(ebox-region-update "upper" :keymap '(keymap (13 . forward-char))
|
||||
:help-echo "upper")
|
||||
(ebox-region-update "lower" :keymap '(keymap (13 . ignore))
|
||||
:help-echo "lower-new")
|
||||
(let ((position (+ (point-min) (string-match "UPPER001" (buffer-string)))))
|
||||
(should (eq (lookup-key (get-text-property position 'keymap) (kbd "RET"))
|
||||
#'forward-char))
|
||||
(should (equal (get-text-property position 'help-echo) "upper")))
|
||||
(ebox-region-update "upper" :visibility 'hidden)
|
||||
(let ((position (+ (point-min) (string-match "LOWER001" (buffer-string)))))
|
||||
(should (eq (lookup-key (get-text-property position 'keymap) (kbd "RET"))
|
||||
#'ignore))
|
||||
(should (equal (get-text-property position 'help-echo) "lower-new")))
|
||||
(ebox-layer-publication-test--assert-fresh-render)))
|
||||
|
||||
(ert-deftest ebox-layer-publication-ancestor-paint-survives-recomposition ()
|
||||
"A detached host retains enclosing paint and interaction contributions."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-layer-publication-test--input nil t))
|
||||
(ebox-region-update "root" :color "#00ff00" :background-color "#000080"
|
||||
:help-echo "ancestor")
|
||||
(ebox-region-update "lower" :content "LOWER002")
|
||||
(ebox-region-update "upper" :visibility 'hidden)
|
||||
(let* ((actual (buffer-string))
|
||||
(expected (ebox-render
|
||||
(plist-get (ebox-surface-buffer-snapshot (current-buffer))
|
||||
:input)))
|
||||
(actual-position (string-match "LOWER002" actual))
|
||||
(expected-position (string-match "LOWER002" expected)))
|
||||
(should (equal (get-text-property actual-position 'face actual)
|
||||
(get-text-property expected-position 'face expected)))
|
||||
(should (equal (get-text-property actual-position 'help-echo actual)
|
||||
"ancestor")))
|
||||
(ebox-layer-publication-test--assert-fresh-render)))
|
||||
|
||||
(ert-deftest ebox-layer-publication-batch-retains-disjoint-owner-updates ()
|
||||
"A hidden update and an ordinary sibling update publish one current input."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer) (ebox-layer-publication-test--input nil t))
|
||||
(ebox-incremental-begin-batch (current-buffer))
|
||||
(ebox-region-update "lower" :content "LOWER002")
|
||||
(ebox-region-update "after" :content "Later")
|
||||
(ebox-incremental-flush (current-buffer))
|
||||
(should (string-match-p "UPPER001" (buffer-string)))
|
||||
(should (string-match-p "Later" (buffer-string)))
|
||||
(should-not (string-match-p "LOWER002" (buffer-string)))
|
||||
(ebox-region-update "upper" :visibility 'hidden)
|
||||
(should (string-match-p "LOWER002" (buffer-string)))
|
||||
(ebox-layer-publication-test--assert-fresh-render)))
|
||||
|
||||
(ert-deftest ebox-layer-publication-root-portal-occludes-unrelated-owner ()
|
||||
"Updates outside a root portal's local host cannot paint over the portal."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer)
|
||||
(ebox-build
|
||||
'(column :id "root" :width (ch 12) :height (lh 5)
|
||||
(box :id "body" :width (ch 8) :height (lh 1) "BODY0001")
|
||||
(box :id "host" :width (ch 4) :height (lh 1)
|
||||
(box :id "portal" :position absolute :layer root
|
||||
:left (px 0) :top (lh 0) :width (ch 8) :height (lh 1)
|
||||
:z-index 1 "PORTAL01")))))
|
||||
(ebox-region-update "body" :content "BODY0002")
|
||||
(should (string-match-p "PORTAL01" (buffer-string)))
|
||||
(should-not (string-match-p "BODY0002" (buffer-string)))
|
||||
(ebox-region-update "portal" :visibility 'hidden)
|
||||
(should (string-match-p "BODY0002" (buffer-string)))
|
||||
(ebox-layer-publication-test--assert-fresh-render)))
|
||||
|
||||
(ert-deftest ebox-layer-publication-root-portal-survives-cached-branch ()
|
||||
"An unchanged cached branch still collects its root portal on later renders."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer)
|
||||
(ebox-build
|
||||
'(column :id "root" :width (ch 12) :height (lh 5)
|
||||
(box :id "body" :width (ch 12) :height (lh 1) "BODY0001")
|
||||
(box :id "host" :width (ch 4) :height (lh 1)
|
||||
(box :id "local" :width (ch 4) :height (lh 1) "HOST")
|
||||
(box :id "portal" :position absolute :layer root
|
||||
:left (ch 4) :top (lh 3) :width (ch 6) :height (lh 1)
|
||||
"PORTAL")))))
|
||||
(dotimes (index 3)
|
||||
(ebox-region-update "body" :content (format "BODY000%d" (+ index 2)))
|
||||
(should (string-match-p "PORTAL" (buffer-string)))
|
||||
(ebox-layer-publication-test--assert-fresh-render))
|
||||
(ebox-region-update "portal" :visibility 'hidden)
|
||||
(should-not (string-match-p "PORTAL" (buffer-string)))
|
||||
(ebox-region-update "portal" :content "LATEST")
|
||||
(ebox-region-update "portal" :visibility 'visible)
|
||||
(should (string-match-p "LATEST" (buffer-string)))
|
||||
(ebox-layer-publication-test--assert-fresh-render)))
|
||||
|
||||
(ert-deftest ebox-layer-publication-revealed-anchor-uses-candidate-source ()
|
||||
"A revealed portal resolves its semantic anchor against current input."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer)
|
||||
(ebox-build
|
||||
'(column :id "root" :width (ch 12) :height (lh 5)
|
||||
(box :id "trigger" :width (ch 8) :height (lh 1) "ANCHOR01")
|
||||
(box :id "host" :width (ch 4) :height (lh 1)
|
||||
(box :id "portal" :position absolute :layer root
|
||||
:anchor "trigger" :width (ch 6) :height (lh 1)
|
||||
:visibility hidden "PORTAL")))))
|
||||
(ebox-region-update "portal" :visibility 'visible)
|
||||
(should (string-match-p "PORTAL" (buffer-string)))
|
||||
(ebox-layer-publication-test--assert-fresh-render)
|
||||
(ebox-region-update "trigger" :position 'relative :left '(ch 2))
|
||||
(should (string-match-p "PORTAL" (buffer-string)))
|
||||
(ebox-layer-publication-test--assert-fresh-render)))
|
||||
|
||||
(ert-deftest ebox-layer-publication-scroll-under-overlay-and-rollback ()
|
||||
"Scrolling lower content recomposes its host and rolls back with TP."
|
||||
(ebox-layer-publication-test--with-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer)
|
||||
(ebox-build
|
||||
'(column :id "root" :width (ch 12) :height (lh 5)
|
||||
(box :id "before" "Before")
|
||||
(box :id "host" :width (ch 8) :height (lh 2)
|
||||
(box :id "scroll" :width (ch 8) :height (lh 2) :overflow scroll
|
||||
"ZERO0000\nONE00000\nTWO00000\nTHREE000")
|
||||
(box :id "upper" :position absolute :width (ch 8) :height (lh 1)
|
||||
"UPPER001"))
|
||||
(box :id "after" "After"))))
|
||||
(let* ((region-id (plist-get
|
||||
(car (ebox-selector-query-buffer (current-buffer) "#scroll"))
|
||||
:region-id))
|
||||
(revision (ebox-surface-buffer-revision (current-buffer)))
|
||||
(before (buffer-string)))
|
||||
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
|
||||
(lambda (&rest _)
|
||||
(ert-fail "Fixed local layer scroll rendered the root"))))
|
||||
(let ((tp--surface-publication-step-function
|
||||
(lambda (step _surface)
|
||||
(when (eq step 'client-state) (error "Reject layer scroll")))))
|
||||
(should-error (ebox--scroll-region-by region-id 1 1)))
|
||||
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
|
||||
(should (equal-including-properties before (buffer-string)))
|
||||
(should (= 0 (plist-get (ebox-scroll-state region-id) :scroll-offset)))
|
||||
(should (= 1 (ebox--scroll-region-by region-id 1 1)))
|
||||
(should (string-match-p "UPPER001" (buffer-string)))
|
||||
(should (string-match-p "TWO00000" (buffer-string)))
|
||||
(should-not (string-match-p "ONE00000" (buffer-string)))
|
||||
(ebox-region-update "upper" :visibility 'hidden)
|
||||
(should (string-match-p "ONE00000" (buffer-string)))
|
||||
(should (string-match-p "TWO00000" (buffer-string)))
|
||||
(should (string-match-p "Before" (buffer-string)))
|
||||
(should (string-match-p "After" (buffer-string)))))))
|
||||
|
||||
(provide 'ebox-layer-publication-tests)
|
||||
;;; ebox-layer-publication-tests.el ends here
|
||||
236
tests/ebox-layer-style-tests.el
Normal file
236
tests/ebox-layer-style-tests.el
Normal file
@ -0,0 +1,236 @@
|
||||
;;; ebox-layer-style-tests.el --- Layer style boundary tests -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'ert)
|
||||
(require 'ebox-style)
|
||||
(require 'ebox)
|
||||
|
||||
(defconst ebox-layer-style-test--defaults
|
||||
'(:position static :left (px 0) :top (lh 0)
|
||||
:z-index 0 :layer local :anchor nil :placement bottom-start)
|
||||
"Initial values of the public layer style properties.")
|
||||
|
||||
(defconst ebox-layer-style-test--values
|
||||
'(:position absolute :left (ch -1.25) :top (lh -0.5)
|
||||
:z-index -2 :layer root :anchor menu-button :placement top-end)
|
||||
"Nondefault layer declarations covering every public layer property.")
|
||||
|
||||
(ert-deftest ebox-layer-style-schema-owns-geometry-facts ()
|
||||
"Layer properties use box geometry projections without item participation."
|
||||
(cl-loop for (name initial) on ebox-layer-style-test--defaults by #'cddr
|
||||
for property = (ebox-style-property name)
|
||||
do (should (equal (plist-get property :initial) initial))
|
||||
do (should (equal (plist-get property :contexts)
|
||||
'(box row column flex grid)))
|
||||
do (should-not (plist-get property :inherits))
|
||||
do (should (eq (plist-get property :signature) 'layout))
|
||||
do (should (eq (ebox-style-dirty-kind name) 'geometry))
|
||||
do (should (equal (plist-get property :impacts) '(geometry)))
|
||||
do (should (equal (plist-get property :projections) '(layout)))
|
||||
do (should (equal (plist-get property :engine-targets) (list name)))
|
||||
do (should (eq (plist-get property :owner) 'ebox-box-frame)))
|
||||
(dolist (name '(:layer :anchor :placement))
|
||||
(should (eq (plist-get (ebox-style-property name) :namespace) 'ebox))))
|
||||
|
||||
(ert-deftest ebox-layer-style-accepts-existing-box-layouts-only ()
|
||||
"Every box layout accepts layer facts, while Text rejects every property."
|
||||
(dolist (tag '(box row column flex grid))
|
||||
(let ((compiled (ebox-style-compile-form
|
||||
tag ebox-layer-style-test--values)))
|
||||
(cl-loop for (name value) on ebox-layer-style-test--values by #'cddr
|
||||
do (should (equal (plist-get compiled (ebox-style-schema-id name))
|
||||
value)))))
|
||||
(cl-loop for (name value) on ebox-layer-style-test--defaults by #'cddr
|
||||
do (should-error (ebox-style-compile-form 'text (list name value)))))
|
||||
|
||||
(ert-deftest ebox-layer-style-enums-and-anchor-grammar ()
|
||||
"Public enums, signed integer depth, and optional semantic anchors compile."
|
||||
(dolist (case '((:position static relative absolute)
|
||||
(:z-index -100 0 100)
|
||||
(:layer local root)
|
||||
(:anchor nil menu-button "menu-button")
|
||||
(:placement bottom-start bottom-end top-start top-end)))
|
||||
(dolist (value (cdr case))
|
||||
(should (equal (ebox-style-compile-form 'box (list (car case) value))
|
||||
(list (ebox-style-schema-id (car case)) value))))))
|
||||
|
||||
(ert-deftest ebox-layer-style-rejects-invalid-static-declarations ()
|
||||
"Invalid layer values signal at both DSL and stylesheet boundaries."
|
||||
(dolist (case '((:position nil t fixed sticky normal "absolute" (absolute))
|
||||
(:z-index nil t auto 1.0 -1.5 "2" (px 2) (lambda () 2))
|
||||
(:layer nil t absolute global "root" (root))
|
||||
(:anchor 1 1.5 (target) (:id target) [target])
|
||||
(:placement nil t bottom center left "top-end" (top-end))))
|
||||
(dolist (value (cdr case))
|
||||
(ert-info ((format "%S rejects %S" (car case) value))
|
||||
(should-error (ebox-style-compile-form 'box (list (car case) value)))
|
||||
(should-error (ebox-style-compile-declarations
|
||||
(list (car case) value) t))))))
|
||||
|
||||
(ert-deftest ebox-layer-style-signed-offset-axis-matrix ()
|
||||
"Signed offsets accept only typed lengths from their rendering axis."
|
||||
(dolist (case '((:left inline) (:top block)))
|
||||
(dolist (unit '(px ch vw lh vh %))
|
||||
(dolist (amount '(-2 -0.25 0 0.25 2))
|
||||
(let ((declarations (list (car case) (list unit amount))))
|
||||
(ert-info ((format "%S" declarations))
|
||||
(if (memq unit (cdr (assq (cadr case) ebox-size-axis-units)))
|
||||
(should (ebox-style-compile-form 'box declarations))
|
||||
(should-error (ebox-style-compile-form 'box declarations)
|
||||
:type 'ebox-size-error)))))))
|
||||
(dolist (name '(:left :top))
|
||||
(dolist (value '(nil auto none 0 -2 0.25 (2) (px) (lh 1 2)
|
||||
(em 1) (px "1") (lh -1.0e+INF) (px 0.0e+NaN)
|
||||
(calc 2) (calc (/ (% 2) 0))))
|
||||
(should-error (ebox-style-compile-form 'box (list name value))))))
|
||||
|
||||
(ert-deftest ebox-layer-style-offset-expressions-preserve-axis-and-sign ()
|
||||
"Expression operands keep axis validation even when they cancel out."
|
||||
(dolist (case '((:left (calc (- (ch 1) (% 50))))
|
||||
(:left (min (px -2) (vw -10)))
|
||||
(:left (clamp (ch -3) (% -20) (px 4)))
|
||||
(:top (calc (+ (lh -0.5) (vh 10))))
|
||||
(:top (max (lh -2) (% -30)))
|
||||
(:top (clamp (lh -3) (% -20) (vh 4)))))
|
||||
(should (equal (ebox-style-compile-form 'box case)
|
||||
(list (ebox-style-schema-id (car case)) (cadr case)))))
|
||||
(dolist (case '((:left (calc (* 0 (lh -1))))
|
||||
(:left (min (ch -1) (vh 0)))
|
||||
(:top (calc (* 0 (px -1))))
|
||||
(:top (max (lh -1) (vw 0)))))
|
||||
(should-error (ebox-style-compile-form 'box case) :type 'ebox-size-error)))
|
||||
|
||||
(ert-deftest ebox-layer-style-defaults-do-not-inherit ()
|
||||
"A child starts with layer defaults despite its parent's layer facts."
|
||||
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
|
||||
(subject (ecss-subject-create :type "box"))
|
||||
(parent (ebox-style-compute-subject
|
||||
subject (ebox-style-compile-form
|
||||
'box ebox-layer-style-test--values)))
|
||||
(child (ebox-style-compute-subject subject nil parent)))
|
||||
(cl-loop for (name initial) on ebox-layer-style-test--defaults by #'cddr
|
||||
do (should (equal (ebox-style-computed-value child name)
|
||||
initial)))))
|
||||
|
||||
(ert-deftest ebox-layer-style-projection-preserves-contextual-offsets ()
|
||||
"Projection retains signed fractional lengths for final used placement."
|
||||
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
|
||||
(style (ebox-style-compute-subject
|
||||
(ecss-subject-create :type "box")
|
||||
(ebox-style-compile-form
|
||||
'box ebox-layer-style-test--values)))
|
||||
(snapshot (ebox-style--computed-snapshot style))
|
||||
(projected (ebox-style--expand-engine-plist
|
||||
(ebox-style--box-values style snapshot)))
|
||||
(text (ebox-style--text-values style snapshot))
|
||||
(item (ebox-style--context-values style 'item)))
|
||||
(cl-loop for (name value) on ebox-layer-style-test--values by #'cddr
|
||||
do (should (equal (plist-get projected name) value))
|
||||
do (should-not (plist-member text name))
|
||||
do (should-not (plist-member item name)))))
|
||||
|
||||
(ert-deftest ebox-layer-style-changes-exclude-paint-only-proof ()
|
||||
"A change to any layer declaration changes the layout signature."
|
||||
(cl-loop for (name value) on ebox-layer-style-test--values by #'cddr
|
||||
for old = (ebox-style-compile-form
|
||||
'box (list name (plist-get
|
||||
ebox-layer-style-test--defaults name)))
|
||||
for new = (ebox-style-compile-form 'box (list name value))
|
||||
do (should-not (ebox-style--paint-declarations-equivalent-p old new))
|
||||
do (should-not (equal (ebox-style-signature old '(layout))
|
||||
(ebox-style-signature new '(layout))))
|
||||
do (should-not (ebox-style-signature new '(paint)))))
|
||||
|
||||
(ert-deftest ebox-layer-style-runtime-removal-restores-defaults ()
|
||||
"Removing or resetting declarations clears every old box layer fact."
|
||||
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
|
||||
(subject (ecss-subject-create :type "box"))
|
||||
(positioned (ebox-style-compute-subject
|
||||
subject (ebox-style-compile-form
|
||||
'box ebox-layer-style-test--values))))
|
||||
(dolist (tag '(box row column flex grid))
|
||||
(dolist (declarations (list nil ebox-layer-style-test--defaults))
|
||||
(let* ((node (ebox-canonical-input--single-root
|
||||
(ebox-build (list tag "Unchanged")) "layer style test"))
|
||||
(children (ebox-tree-node-children node))
|
||||
(config (plist-get node :ebox-layout-config))
|
||||
(reset (ebox-style-compute-subject
|
||||
subject (ebox-style-compile-form 'box declarations))))
|
||||
(ebox-style-apply-computed node positioned)
|
||||
(ebox-style-apply-computed node reset)
|
||||
(cl-loop for (name initial) on ebox-layer-style-test--defaults by #'cddr
|
||||
do (should (equal (plist-get node name) initial)))
|
||||
(should (equal (ebox-tree-node-children node) children))
|
||||
(should (equal (plist-get node :ebox-layout-config) config)))))))
|
||||
|
||||
(ert-deftest ebox-layer-style-runtime-transfer-preserves-current-projection ()
|
||||
"Source-stable transfers copy layer values and restore cleared defaults."
|
||||
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
|
||||
(subject (ecss-subject-create :type "box")))
|
||||
(dolist (tag '(box row column flex grid))
|
||||
(dolist (declarations (list ebox-layer-style-test--values nil))
|
||||
(let* ((old (ebox-canonical-input--single-root
|
||||
(ebox-build (list tag "Old")) "layer style test"))
|
||||
(new (ebox-canonical-input--single-root
|
||||
(ebox-build (list tag "New")) "layer style test"))
|
||||
(children (ebox-tree-node-children new))
|
||||
(style (ebox-style-compute-subject
|
||||
subject (ebox-style-compile-form 'box declarations))))
|
||||
;; Start both nodes with stale nondefaults, so copying a cleared
|
||||
;; projection must overwrite them as well as retaining active facts.
|
||||
(ebox-style-apply-computed
|
||||
old (ebox-style-compute-subject
|
||||
subject (ebox-style-compile-form
|
||||
'box ebox-layer-style-test--values)))
|
||||
(ebox-style-apply-computed old style)
|
||||
(ebox-style-apply-computed
|
||||
new (ebox-style-compute-subject
|
||||
subject (ebox-style-compile-form
|
||||
'box (if declarations
|
||||
ebox-layer-style-test--defaults
|
||||
ebox-layer-style-test--values))))
|
||||
(ebox-style--transfer-computed-projection old new style)
|
||||
(cl-loop for (name value)
|
||||
on (or declarations ebox-layer-style-test--defaults) by #'cddr
|
||||
do (should (equal (plist-get new name) value)))
|
||||
(should (equal (ebox-tree-node-children new) children)))))))
|
||||
|
||||
(ert-deftest ebox-layer-style-stylesheet-updates-reset-runtime-projection ()
|
||||
"Changing and removing a rule updates the same canonical node's fields."
|
||||
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
|
||||
(node (ebox-canonical-input--single-root
|
||||
(ebox-build '(box :class "panel" "Panel")) "layer style test"))
|
||||
(subject (ecss-subject-create :type "box" :classes '("panel"))))
|
||||
(dolist (values (list ebox-layer-style-test--values
|
||||
'(:position relative :left (px 3)
|
||||
:top (lh 2) :z-index 10 :layer local :anchor nil
|
||||
:placement bottom-end)
|
||||
ebox-layer-style-test--defaults))
|
||||
(ebox-style-add-rule ".panel" values)
|
||||
(ebox-style-apply-computed
|
||||
node (ebox-style-compute-subject subject nil))
|
||||
(cl-loop for (name value) on values by #'cddr
|
||||
do (should (equal (plist-get node name) value))))
|
||||
(ebox-style-add-rule ".panel" ebox-layer-style-test--values)
|
||||
(ebox-style-apply-computed node (ebox-style-compute-subject subject nil))
|
||||
(setq ebox-style-stylesheet (ecss-stylesheet-create))
|
||||
(ebox-style-apply-computed node (ebox-style-compute-subject subject nil))
|
||||
(cl-loop for (name value) on ebox-layer-style-test--defaults by #'cddr
|
||||
do (should (equal (plist-get node name) value)))))
|
||||
|
||||
(ert-deftest ebox-layer-style-runtime-defaults-stay-off-text ()
|
||||
"Applying a style with layer facts does not materialize them onto Text."
|
||||
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
|
||||
(node (ebox-canonical-input--single-root
|
||||
(ebox-build '(text "Text")) "layer style test"))
|
||||
(style (ebox-style-compute-subject
|
||||
(ecss-subject-create :type "text")
|
||||
(ebox-style-compile-form
|
||||
'box ebox-layer-style-test--values))))
|
||||
(ebox-style-apply-computed node style)
|
||||
(cl-loop for (name _value) on ebox-layer-style-test--defaults by #'cddr
|
||||
do (should-not (plist-member node name)))))
|
||||
|
||||
(provide 'ebox-layer-style-tests)
|
||||
;;; ebox-layer-style-tests.el ends here
|
||||
164
tests/ebox-layer-tests.el
Normal file
164
tests/ebox-layer-tests.el
Normal file
@ -0,0 +1,164 @@
|
||||
;;; ebox-layer-tests.el --- Box composition contracts -*- lexical-binding: t; -*-
|
||||
|
||||
(require 'ert)
|
||||
(require 'ebox)
|
||||
|
||||
(defun ebox-layer-test--render (form)
|
||||
"Render FORM through the public canonical boundary."
|
||||
(ebox-render (ebox-build form)))
|
||||
|
||||
(ert-deftest ebox-layer-minimal-opaque-overlap ()
|
||||
(let ((output
|
||||
(ebox-layer-test--render
|
||||
'(box :width (ch 10) :height (lh 3)
|
||||
(box "abcdefghij\nABCDEFGHIJ\n0123456789")
|
||||
(box :position absolute :left (ch 2) :top (lh 1)
|
||||
:width (ch 4) :height (lh 1) :background-color "red"
|
||||
"TOP")))))
|
||||
(should (equal (substring-no-properties output)
|
||||
"abcdefghij\nABTOP GHIJ\n0123456789"))
|
||||
(should (= (length (ebox-string-lines output)) 3))))
|
||||
|
||||
(ert-deftest ebox-layer-relative-reserves-flow-slot ()
|
||||
(let ((lines
|
||||
(ebox-string-lines
|
||||
(ebox-layer-test--render
|
||||
'(column :width (ch 10) :height (lh 3)
|
||||
(box :position relative :left (ch 2) :top (lh 1)
|
||||
:z-index 1 :width (ch 4) "MOVE")
|
||||
(box "abcdefghij")
|
||||
(box "0123456789"))))))
|
||||
(should (= (length lines) 3))
|
||||
(should-not (string-match-p "MOVE" (car lines)))
|
||||
(should (equal (substring-no-properties (cadr lines)) "abMOVEghij"))
|
||||
(should (equal (substring-no-properties (nth 2 lines)) "0123456789"))))
|
||||
|
||||
(ert-deftest ebox-layer-negative-z-stays-below-normal-content ()
|
||||
(let ((output (ebox-layer-test--render
|
||||
'(box :width (ch 6) :height (lh 1)
|
||||
(box "BOTTOM")
|
||||
(box :position absolute :width (ch 6) :z-index -1 "HIDDEN")))))
|
||||
(should (equal (substring-no-properties output) "BOTTOM"))))
|
||||
|
||||
(ert-deftest ebox-layer-nested-isolation-orders-groups ()
|
||||
(let ((output (ebox-layer-test--render
|
||||
'(box :width (ch 6) :height (lh 1)
|
||||
(box
|
||||
(box "BOTTOM")
|
||||
(box :position absolute :width (ch 6)
|
||||
:z-index 100 "INSIDE"))
|
||||
(box :position absolute :width (ch 6) :z-index 1 "FRONT!")))))
|
||||
(should (equal (substring-no-properties output) "FRONT!"))))
|
||||
|
||||
(ert-deftest ebox-layer-root-projection-escapes-local-clipping ()
|
||||
(let ((output (ebox-layer-test--render
|
||||
'(box :width (ch 10) :height (lh 3)
|
||||
(box :height (lh 1) :overflow hidden
|
||||
(box :id trigger "BUTTON")
|
||||
(box :position absolute :layer root :anchor trigger
|
||||
:width (ch 4) :height (lh 1) "MENU"))
|
||||
(box "abcdefghij\n0123456789")))))
|
||||
(should (equal (substring-no-properties output)
|
||||
"BUTTON \nMENUefghij\n0123456789"))))
|
||||
|
||||
(ert-deftest ebox-layer-missing-anchor-does-not-paint ()
|
||||
(let ((output (ebox-layer-test--render
|
||||
'(box :width (ch 6) :height (lh 1)
|
||||
(box "BOTTOM")
|
||||
(box :position absolute :anchor missing
|
||||
:width (ch 6) "HIDDEN")))))
|
||||
(should (equal (substring-no-properties output) "BOTTOM"))))
|
||||
|
||||
(ert-deftest ebox-layer-hidden-upper-reveals-base ()
|
||||
(let ((output (ebox-layer-test--render
|
||||
'(box :width (ch 6) :height (lh 1)
|
||||
(box "LATEST")
|
||||
(box :position absolute :visibility hidden
|
||||
:width (ch 6) "HIDDEN")))))
|
||||
(should (equal (substring-no-properties output) "LATEST"))))
|
||||
|
||||
(ert-deftest ebox-layer-max-content-row-paints-positioned-descendants ()
|
||||
(let ((output (ebox-layer-test--render
|
||||
'(row :width max-content
|
||||
(box :width (ch 4) :height (lh 1)
|
||||
(box "ABCD")
|
||||
(box :position absolute :left (ch 1)
|
||||
:width (ch 2) "XX"))))))
|
||||
(should (equal (substring-no-properties output) "AXXD"))))
|
||||
|
||||
(ert-deftest ebox-layer-anchor-dependency-resolves-before-paint-order ()
|
||||
(let ((output (ebox-layer-test--render
|
||||
'(box :width (ch 10) :height (lh 4)
|
||||
(box "0123456789\nabcdefghij\nABCDEFGHIJ\n0123456789")
|
||||
(box :position absolute :anchor parent-panel :width (ch 4)
|
||||
:z-index 2 "MENU")
|
||||
(box :id parent-panel :position absolute :left (ch 2)
|
||||
:top (lh 1) :width (ch 4) :z-index 1 "BASE")))))
|
||||
(should (equal (substring-no-properties output)
|
||||
"0123456789\nabBASEghij\nABMENUGHIJ\n0123456789"))))
|
||||
|
||||
(ert-deftest ebox-layer-anchor-cycles-are-rejected ()
|
||||
(should-error
|
||||
(ebox-layer-test--render
|
||||
'(box :width (ch 10) :height (lh 4)
|
||||
(box :id a :position absolute :anchor b "A")
|
||||
(box :id b :position absolute :anchor a "B")))))
|
||||
|
||||
(ert-deftest ebox-layer-absolute-margins-do-not-cover-underlay ()
|
||||
(let ((output (ebox-layer-test--render
|
||||
'(box :width (ch 8) :height (lh 3)
|
||||
(box "abcdefgh\nABCDEFGH\n01234567")
|
||||
(box :position absolute :width (ch 4) :height (lh 1)
|
||||
:margin ((lh 1) (ch 1)) :background-color "red"
|
||||
"TOP!")))))
|
||||
(should (equal (substring-no-properties output)
|
||||
"abcdefgh\nATOP!FGH\n01234567"))))
|
||||
|
||||
(ert-deftest ebox-layer-absolute-keeps-all-flow-layout-sizes ()
|
||||
(dolist (tag '(box row column flex grid))
|
||||
(let* ((form `(,tag :width (ch 8)
|
||||
(box :width (ch 8) :height (lh 1) "abcdefgh")
|
||||
(box :position absolute :left (ch 2)
|
||||
:width (ch 2) :height (lh 9) "XX")))
|
||||
(output (ebox-layer-test--render form)))
|
||||
(ert-info ((format "Layout %s" tag))
|
||||
(should (= (length (ebox-string-lines output)) 1))
|
||||
(should (equal (substring-no-properties output) "abXXefgh"))))))
|
||||
|
||||
(ert-deftest ebox-layer-root-projection-inside-an-absolute-panel ()
|
||||
(let ((output (ebox-layer-test--render
|
||||
'(box :width (ch 10) :height (lh 4)
|
||||
(box "0123456789\nabcdefghij\nABCDEFGHIJ\n0123456789")
|
||||
(box :position absolute :left (ch 2) :top (lh 1)
|
||||
:width (ch 4) :height (lh 1)
|
||||
(box :id trigger "BASE")
|
||||
(box :position absolute :layer root :anchor trigger
|
||||
:width (ch 4) :height (lh 1) "MENU"))))))
|
||||
(should (equal (substring-no-properties output)
|
||||
"0123456789\nabBASEghij\nABMENUGHIJ\n0123456789"))))
|
||||
|
||||
(ert-deftest ebox-layer-native-to-layer-transition-keeps-composition ()
|
||||
(skip-unless (and (fboundp 'ebox-native-reflow-layout-ready-p)
|
||||
(ebox-native-reflow-layout-ready-p)))
|
||||
(let ((ebox-viewport-width 80) (ebox-viewport-height 10)
|
||||
(ebox-runtime-idle-prewarm nil)
|
||||
(ebox-runtime-idle-reflow-cache-prewarm nil))
|
||||
(with-temp-buffer
|
||||
(ebox-render-to-buffer
|
||||
(current-buffer)
|
||||
(ebox-build '(column :width (px 8) :height (lh 2)
|
||||
(box :id "lower" :width (px 8) :height (lh 1) "LOWER001")
|
||||
(box :id "upper" :width (px 8) :height (lh 1) "UPPER001"))))
|
||||
(should (eq (plist-get (ebox--buffer-render-state (current-buffer))
|
||||
:projection-kind) 'native-frame))
|
||||
(should (ebox-region-update "upper" :position 'absolute))
|
||||
(should (plist-get (ebox--buffer-render-state (current-buffer)) :layered-p))
|
||||
(should-not (string-match-p "LOWER001" (buffer-string)))
|
||||
(should (string-match-p "UPPER001" (buffer-string)))
|
||||
(ebox-region-update "lower" :content "LOWER002")
|
||||
(ebox-region-update "upper" :visibility 'hidden)
|
||||
(should (string-match-p "LOWER002" (buffer-string)))
|
||||
(should-not (string-match-p "UPPER001" (buffer-string))))))
|
||||
|
||||
(provide 'ebox-layer-tests)
|
||||
;;; ebox-layer-tests.el ends here
|
||||
@ -97,6 +97,17 @@
|
||||
(should (string-match-p
|
||||
"ebox-render-context-materialize-string" source))))
|
||||
|
||||
(ert-deftest ebox-incremental-boundary-uses-injected-context-port ()
|
||||
"Incremental planning has no upward Surface symbol or publication edge."
|
||||
(let ((source (ebox-layout-boundary-test--source "ebox-incremental.el")))
|
||||
(should-not (string-match-p "ebox-surface" source))
|
||||
(should (ebox-incremental-context-port-p
|
||||
ebox-incremental--context-port))
|
||||
(should
|
||||
(string-match-p
|
||||
"(ebox-incremental-install-context-port"
|
||||
(ebox-layout-boundary-test--source "ebox-surface.el")))))
|
||||
|
||||
(ert-deftest ebox-layout-boundary-preserves-pre-e2-layout-artifacts ()
|
||||
"The injected route preserves every old character and property interval."
|
||||
(dolist (input (ebox-layout-boundary-test--fixtures))
|
||||
|
||||
76
tests/ebox-m0a-characterization-tests.el
Normal file
76
tests/ebox-m0a-characterization-tests.el
Normal file
@ -0,0 +1,76 @@
|
||||
;;; ebox-m0a-characterization-tests.el --- M0a commit characterization -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'ert)
|
||||
(require 'ebox-fixtures)
|
||||
|
||||
(define-error 'ebox-m0a-test-condition "M0a sentinel condition")
|
||||
|
||||
(ert-deftest ebox-m0a-success-return-preserves-framework-report-identity ()
|
||||
"One report moves from provisional publish to completed return in place."
|
||||
(let ((buffer (generate-new-buffer " *ebox-m0a-report-identity*"))
|
||||
seen provisional-state)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(ebox-render-to-buffer
|
||||
buffer (ebox-test-box :key 'root (ebox-test-text "before")))
|
||||
(let ((returned
|
||||
(ebox-commit
|
||||
buffer
|
||||
(ebox-test-box :key 'root (ebox-test-text "after"))
|
||||
(lambda (report)
|
||||
(setq seen report
|
||||
provisional-state
|
||||
(plist-get report :framework-participant-state))))))
|
||||
(should (eq provisional-state 'published))
|
||||
(should (eq seen returned))
|
||||
(should (eq (plist-get returned :framework-participant-state)
|
||||
'completed))))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest ebox-m0a-framework-failure-resignals-raw-condition-symbol-and-data ()
|
||||
"Framework failure keeps its original condition symbol and data payload."
|
||||
(let ((buffer (generate-new-buffer " *ebox-m0a-raw-condition*")) caught)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(ebox-render-to-buffer
|
||||
buffer (ebox-test-box :key 'root (ebox-test-text "before")))
|
||||
(setq caught
|
||||
(condition-case condition
|
||||
(ebox-commit
|
||||
buffer
|
||||
(ebox-test-box :key 'root (ebox-test-text "after"))
|
||||
(lambda (_report)
|
||||
(signal 'ebox-m0a-test-condition '(alpha 17))))
|
||||
(ebox-m0a-test-condition condition)))
|
||||
(should (eq (car caught) 'ebox-m0a-test-condition))
|
||||
(should (equal (cdr caught) '(alpha 17))))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest ebox-m0a-framework-rollback-receives-publish-report-object ()
|
||||
"Framework rollback receives the exact report passed to failed publish."
|
||||
(let ((buffer (generate-new-buffer " *ebox-m0a-rollback-identity*"))
|
||||
published rolled-back)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(ebox-render-to-buffer
|
||||
buffer (ebox-test-box :key 'root (ebox-test-text "before")))
|
||||
(condition-case nil
|
||||
(ebox-commit
|
||||
buffer
|
||||
(ebox-test-box :key 'root (ebox-test-text "after"))
|
||||
(lambda (report)
|
||||
(setq published report)
|
||||
(error "reject"))
|
||||
(lambda (report) (setq rolled-back report)))
|
||||
(error nil))
|
||||
(should published)
|
||||
(should (eq published rolled-back))
|
||||
(should (eq (plist-get rolled-back :framework-participant-state)
|
||||
'rolled-back)))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(provide 'ebox-m0a-characterization-tests)
|
||||
|
||||
;;; ebox-m0a-characterization-tests.el ends here
|
||||
614
tests/ebox-m0a-inventory-fixture.el
Normal file
614
tests/ebox-m0a-inventory-fixture.el
Normal file
@ -0,0 +1,614 @@
|
||||
;;; ebox-m0a-inventory-fixture.el --- M0a architecture inventory -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
;; Machine-readable characterization of the current Ebox ownership model.
|
||||
;; This is an observed-v1 inventory, not a declaration that the future v2 SPI
|
||||
;; or state split already exists.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(defconst ebox-m0a-state-table-required-fields
|
||||
'(:id :storage :owner :authority :current-mutation :aliasing :lifetime
|
||||
:rollback :rebuild :cleanup)
|
||||
"Fields required on every M0a state-table inventory record.")
|
||||
|
||||
(defconst ebox-m0a-non-store-exclusions
|
||||
'((:storage (:global ebox--defer-scroll-content-index)
|
||||
:why dynamic-scalar-not-a-store :owner ebox-layout
|
||||
:evidence defvar-is-a-dynamic-boolean-or-index-value)
|
||||
(:storage (:global ebox--box-content-empty-width)
|
||||
:why dynamic-scalar-not-a-store :owner ebox-layout
|
||||
:evidence value-is-a-numeric-inline-extent-or-nil)
|
||||
(:storage (:global ebox--string-pixel-width-cache-ring-index)
|
||||
:why eviction-cursor-not-a-store :owner ebox-measure
|
||||
:evidence defvar-holds-a-numeric-ring-cursor)
|
||||
(:storage (:state-key :content-index)
|
||||
:why address-scalar-not-a-store :owner ebox-surface
|
||||
:evidence plist-key-holds-a-numeric-content-address)
|
||||
(:storage (:state-key :z-index)
|
||||
:why paint-order-scalar-not-a-store :owner ebox-layer
|
||||
:evidence style-schema-requires-an-integer-used-only-for-layer-ordering)
|
||||
(:storage (:state-key :max-jobs)
|
||||
:why native-limit-not-a-store :owner ebox-native-reflow
|
||||
:evidence plist-key-holds-a-numeric-capacity)
|
||||
(:storage (:state-key :next-index)
|
||||
:why traversal-cursor-not-a-store :owner ebox-layout
|
||||
:evidence plist-key-holds-a-numeric-traversal-cursor)
|
||||
(:storage (:state-key :line-index)
|
||||
:why prewarm-cursor-not-a-store :owner ebox
|
||||
:evidence plist-key-is-incremented-as-a-numeric-task-cursor)
|
||||
(:storage (:state-key :measure-index)
|
||||
:why flex-prewarm-cursor-not-a-store :owner ebox-flex
|
||||
:evidence plist-key-is-incremented-as-a-numeric-measure-cursor)
|
||||
(:storage (:state-key :partial-index)
|
||||
:why native-result-scalar-not-a-store :owner ebox-native-reflow
|
||||
:evidence plist-key-holds-a-numeric-native-result-index)
|
||||
(:storage (:state-key :render-index)
|
||||
:why flex-prewarm-cursor-not-a-store :owner ebox-flex
|
||||
:evidence plist-key-is-incremented-as-a-numeric-render-cursor)
|
||||
(:storage (:state-key :segment-index)
|
||||
:why range-address-scalar-not-a-store :owner ebox-child-range
|
||||
:evidence plist-key-holds-a-numeric-range-address)
|
||||
(:storage (:state-key :buffer-table)
|
||||
:why fresh-projection-result-not-retained :owner ebox-state-contract
|
||||
:evidence plist-key-exists-only-in-a-detached-rebuild-return-value)
|
||||
(:storage (:state-key :region-table)
|
||||
:why fresh-projection-result-not-retained :owner ebox-state-contract
|
||||
:evidence plist-key-exists-only-in-a-detached-rebuild-return-value)
|
||||
(:storage (:state-key :cyclic-parent-table)
|
||||
:why condition-data-key-not-a-store :owner ebox-patch-plan
|
||||
:evidence keyword-labels-a-validation-error-payload)
|
||||
(:storage (:state-key :malformed-parent-table)
|
||||
:why condition-data-key-not-a-store :owner ebox-patch-plan
|
||||
:evidence keyword-labels-a-validation-error-payload)
|
||||
(:storage (:state-key :missing-parent-table)
|
||||
:why condition-data-key-not-a-store :owner ebox-incremental
|
||||
:evidence keyword-labels-a-validation-error-payload)
|
||||
(:storage (:state-key :malformed-region-index)
|
||||
:why condition-data-key-not-a-store :owner ebox-state-contract
|
||||
:evidence keyword-labels-a-validation-error-payload)
|
||||
(:storage (:state-key :public-rule-in-internal-table)
|
||||
:why condition-data-key-not-a-store :owner ebox-style
|
||||
:evidence keyword-labels-a-validation-error-payload))
|
||||
"Scanner-name matches proven by source usage not to be stores.")
|
||||
|
||||
(defconst ebox-m0a-observed-unclassified-stores nil
|
||||
"Real store candidates lacking classification; M0a requires this to be nil.")
|
||||
|
||||
(defun ebox-m0a--storage-module-owner (storage)
|
||||
"Return the current implementation module responsible for STORAGE."
|
||||
(let ((kind (car storage))
|
||||
(container (cadr storage))
|
||||
(name (symbol-name (car (last storage)))))
|
||||
(cond
|
||||
((and (eq kind :struct-slot)
|
||||
(string-prefix-p "ebox-source" (symbol-name container)))
|
||||
'ebox-source)
|
||||
((and (eq kind :struct-slot)
|
||||
(string-prefix-p "ebox-native-reflow" (symbol-name container)))
|
||||
'ebox-native-reflow)
|
||||
((and (eq kind :struct-slot)
|
||||
(eq container 'ebox-incremental--detached-history))
|
||||
'ebox-incremental)
|
||||
((string-match-p "cache--" name) 'ebox-cache)
|
||||
((string-match-p "canonical" name) 'ebox-canonical)
|
||||
((string-match-p "render-burst" name) 'ebox-buffer-backend)
|
||||
((string-match-p "render-owned-text-values" name) 'ebox-render-context)
|
||||
((string-match-p "font\\|char-width\\|pixel-width\\|face-height\\|space-pixel\\|display-signature" name)
|
||||
'ebox-measure)
|
||||
((string-match-p "style" name) 'ebox-style)
|
||||
((string-match-p "surface\\|paint-node\\|inline-style" name) 'ebox-surface)
|
||||
((string-match-p "native" name) 'ebox-native-reflow)
|
||||
((string-match-p "source\\|host-ref" name) 'ebox-source)
|
||||
((string-match-p "child-range\\|ref-index" name) 'ebox-child-range)
|
||||
((string-match-p "flex" name) 'ebox-flex)
|
||||
((string-match-p "layout-fragment" name) 'ebox-fragment)
|
||||
((string-match-p "node-region" name) 'ebox-tree)
|
||||
((string-match-p "subject-table" name) 'ebox-tree)
|
||||
((string-match-p "frame-cache\\|key-table" name) 'ebox-native-reflow)
|
||||
((string-match-p "region-line\\|owner-table" name) 'ebox-incremental)
|
||||
((string-match-p "render-output-provenance" name) 'ebox-render-context)
|
||||
((string-match-p "uniform-width\\|intrinsic-size\\|scroll-table" name) 'ebox-layout)
|
||||
((string-match-p "deferred-render-gc" name) 'ebox-buffer-backend)
|
||||
((string-match-p "window-line\\|box-content\\|region-box\\|scroll-window" name)
|
||||
'ebox-layout)
|
||||
((string-match-p "incremental\\|render-cache\\|viewport\\|detached\\|ancestor\\|proof\\|patchable" name)
|
||||
'ebox-incremental)
|
||||
((string-match-p "selector" name) 'ebox-tree)
|
||||
(t 'ebox))))
|
||||
|
||||
(defun ebox-m0a--supplemental-record
|
||||
(storage proposed-category owner lifecycle)
|
||||
"Classify STORAGE by PROPOSED-CATEGORY, OWNER, and LIFECYCLE."
|
||||
(list :id (list 'supplemental storage) :storage storage
|
||||
:proposed-category proposed-category
|
||||
:owner (if (eq owner 'ebox)
|
||||
(ebox-m0a--storage-module-owner storage)
|
||||
owner)
|
||||
:authority 'ebox
|
||||
:lifetime lifecycle :rollback 'candidate-discard-or-not-authoritative
|
||||
:rebuild 'lazy-or-generation-rebuild
|
||||
:cleanup 'scope-retirement-or-cache-clear))
|
||||
|
||||
(defconst ebox-m0a--disposable-global-cache-symbols
|
||||
'(ebox--box-content-render-cache ebox--char-width-cache
|
||||
ebox--display-signature-cache ebox--face-height-width-cache
|
||||
ebox--flex-content-min-width-table ebox--flex-sized-render-observation-table
|
||||
ebox--flex-slot-safety-cache ebox--layout-fragments-table
|
||||
ebox--node-region-ids-cache ebox--patchable-owner-cache
|
||||
ebox--render-cache-scroll-state-retained-cost-cache
|
||||
ebox--render-cache-signature-cache ebox--render-cache-table
|
||||
ebox--render-output-provenance-table ebox--render-recached-source-node-cache
|
||||
ebox--render-string-max-pixel-width-cache
|
||||
ebox--render-string-pixel-width-cache ebox--rendered-uniform-width-table
|
||||
ebox--rendered-intrinsic-size-table
|
||||
ebox--runtime-ancestor-cache ebox--space-pixel-cache
|
||||
ebox--string-pixel-width-cache ebox--viewport-dependent-node-ids-cache
|
||||
ebox--viewport-dependent-subtree-cache
|
||||
ebox--viewport-height-dependent-subtree-cache
|
||||
ebox--css-viewport-dependent-subtree-cache
|
||||
ebox--window-line-prewarmer-table ebox--window-line-renderer-table
|
||||
ebox-cache--buffer-report-table ebox-cache--registry
|
||||
ebox-canonical--declaration-fact-cache
|
||||
ebox-incremental--allocated-slot-proof-cache
|
||||
ebox-incremental--candidate-path-copy-origin-table
|
||||
ebox-incremental--candidate-proof-node-table
|
||||
ebox-style--box-engine-longhand-cache ebox-style--closed-computed-cache
|
||||
ebox-style--closed-inheritance-cache ebox-style--computed-snapshot-cache
|
||||
ebox-style--declaration-cache ebox-style--text-engine-longhand-cache
|
||||
ebox-surface--inline-style-value-cache ebox-surface--paint-node-chain-cache
|
||||
ebox-surface--paint-node-depth-cache ebox-surface--scroll-line-fragment-cache)
|
||||
"Process or dynamically scoped disposable cache/table variables.")
|
||||
|
||||
(defconst ebox-m0a--generation-bound-global-store-symbols
|
||||
'(ebox--deferred-render-gc-timer ebox--reflow-cache-prewarm-timers
|
||||
ebox--render-burst-records ebox--render-owned-text-values
|
||||
ebox--region-box-table
|
||||
ebox--region-line-index ebox--render-source-index
|
||||
ebox--runtime-prewarm-jobs ebox--runtime-prewarm-timers
|
||||
ebox--scroll-idle-prefetch-inhibited-buffers
|
||||
ebox--scroll-idle-prefetch-timers
|
||||
ebox-native-reflow--compile-property-template-ids
|
||||
ebox-incremental--candidate-full-source-base-index
|
||||
ebox-incremental--previous-source-base-index
|
||||
ebox-incremental--source-base-index ebox-style--property-index)
|
||||
"Generation facts and mutable scheduling stores outside the state plist.")
|
||||
|
||||
(defconst ebox-m0a--nested-generation-store-identities
|
||||
'((:state-key :ancestor-table) (:state-key :candidate-subject-table)
|
||||
(:state-key :host-ref-table)
|
||||
(:state-key :node-subject-table) (:state-key :owner-table)
|
||||
(:state-key :ref-index) (:state-key :region-line-bounds-index)
|
||||
(:state-key :region-line-span-index)
|
||||
(:state-key :rendered-region-line-span-index) (:state-key :source-base-index)
|
||||
(:state-key :source-index) (:state-key :surface-source-base-index)
|
||||
(:struct-slot ebox-canonical-input source-index)
|
||||
(:struct-slot ebox-child-range--sequence ref-index)
|
||||
(:struct-slot ebox-incremental--candidate-paint-patch source-index)
|
||||
(:struct-slot ebox-incremental--candidate-replacement source-index)
|
||||
(:struct-slot ebox-source-index records)
|
||||
(:struct-slot ebox-source-index handle-records)
|
||||
(:struct-slot ebox-source-index subjects)
|
||||
(:struct-slot ebox-source-index node-subjects)
|
||||
(:struct-slot ebox-source-builder records)
|
||||
(:struct-slot ebox-source-builder handle-records)
|
||||
(:struct-slot ebox-source-table added)
|
||||
(:struct-slot ebox-source-table removed)
|
||||
(:struct-slot ebox-source-order replacements)
|
||||
(:struct-slot ebox-source-order removed)
|
||||
(:struct-slot ebox-source-index host-ref-table)
|
||||
(:struct-slot ebox-source-index selector-class-table)
|
||||
(:struct-slot ebox-source-index selector-id-table)
|
||||
(:struct-slot ebox-source-index selector-type-table))
|
||||
"Nested immutable or generation-derived index/table stores.")
|
||||
|
||||
(defconst ebox-m0a--nested-mutable-store-identities
|
||||
'((:state-key :detached-identity-history) (:state-key :native-session)
|
||||
(:state-key :scroll-table)
|
||||
(:struct-slot ebox-incremental--detached-history table)
|
||||
(:struct-slot ebox-native-reflow-preparation ready-timer)
|
||||
(:struct-slot ebox-native-reflow-preparation native-session))
|
||||
"Nested generation-bound mutable authority stores.")
|
||||
|
||||
(defconst ebox-m0a--nested-cache-store-identities
|
||||
'((:state-key :frame-cache) (:state-key :key-table)
|
||||
(:state-key :layout-fragment-cache)
|
||||
(:state-key :native-fragment-owner-cache)
|
||||
(:state-key :node-region-ids-cache)
|
||||
(:state-key :string-max-pixel-width-cache)
|
||||
(:state-key :string-pixel-width-cache)
|
||||
(:state-key :viewport-dependent-node-ids-cache)
|
||||
(:state-key :viewport-dependent-subtree-cache)
|
||||
(:struct-slot ebox-native-reflow-layout-builder string-max-pixel-width-cache)
|
||||
(:struct-slot ebox-native-reflow-layout-builder string-pixel-width-cache)
|
||||
(:struct-slot ebox-source-table memo)
|
||||
(:struct-slot ebox-native-reflow-preparation frame-cache)
|
||||
(:struct-slot ebox-native-reflow-preparation key-table)
|
||||
(:struct-slot ebox-native-reflow-session layout-fragment-cache))
|
||||
"Nested disposable native, viewport, render, and measurement caches.")
|
||||
|
||||
(defconst ebox-m0a-supplemental-state-table-inventory
|
||||
(append
|
||||
(mapcar (lambda (symbol)
|
||||
(ebox-m0a--supplemental-record
|
||||
(list :global symbol) 'disposable-cache 'ebox 'dynamic-or-process))
|
||||
ebox-m0a--disposable-global-cache-symbols)
|
||||
(mapcar (lambda (symbol)
|
||||
(ebox-m0a--supplemental-record
|
||||
(list :global symbol) 'generation-bound-mutable
|
||||
'ebox 'dynamic-or-generation))
|
||||
ebox-m0a--generation-bound-global-store-symbols)
|
||||
(mapcar (lambda (storage)
|
||||
(ebox-m0a--supplemental-record
|
||||
storage 'generation-fact 'ebox 'candidate-or-generation))
|
||||
ebox-m0a--nested-generation-store-identities)
|
||||
(mapcar (lambda (storage)
|
||||
(ebox-m0a--supplemental-record
|
||||
storage 'generation-bound-mutable 'ebox 'candidate-or-runtime))
|
||||
ebox-m0a--nested-mutable-store-identities)
|
||||
(mapcar (lambda (storage)
|
||||
(ebox-m0a--supplemental-record
|
||||
storage 'disposable-cache 'ebox 'candidate-or-dynamic))
|
||||
ebox-m0a--nested-cache-store-identities))
|
||||
"Stores found by static schema scan beyond the primary retained-state list.")
|
||||
|
||||
(defun ebox-m0a--current-store-facts (storage)
|
||||
"Return conservative current mutation and aliasing facts for STORAGE."
|
||||
(cond
|
||||
((equal storage '(:state-key :layout-snapshots))
|
||||
'(:current-mutation in-place-puthash-on-committed-state
|
||||
:aliasing committed-state-table-retained-and-mutated))
|
||||
((memq (car storage) '(:global :struct-slot))
|
||||
'(:current-mutation mutable-container-or-whole-value-replacement
|
||||
:aliasing dynamic-or-enclosing-object-aliasing-not-proven-absent))
|
||||
((memq (cadr storage)
|
||||
'(:native-sync-session :native-sync-pending :native-session
|
||||
:scroll-state-table :scroll-table :detached-identity-history))
|
||||
'(:current-mutation in-place-mutation-and-whole-value-replacement
|
||||
:aliasing candidate-and-committed-state-may-share-owned-handles))
|
||||
(t
|
||||
'(:current-mutation in-place-mutation-or-whole-table-replacement
|
||||
:aliasing candidate-and-committed-aliasing-not-proven-absent))))
|
||||
|
||||
(defun ebox-m0a--normalize-current-record (record)
|
||||
"Return RECORD augmented with current observable M0a facts."
|
||||
(let* ((copy (copy-sequence record))
|
||||
(facts (ebox-m0a--current-store-facts (plist-get copy :storage))))
|
||||
(setq copy (plist-put copy :current-mutation
|
||||
(plist-get facts :current-mutation)))
|
||||
(setq copy (plist-put copy :aliasing (plist-get facts :aliasing)))
|
||||
(when (plist-get copy :proposed-category)
|
||||
(setq copy (plist-put copy :proposal-status 'not-active)))
|
||||
copy))
|
||||
|
||||
(defconst ebox-m0a-state-table-inventory
|
||||
'((:id buffer-runtime/state-store
|
||||
:storage (:global ebox--buffer-render-state-table)
|
||||
:proposed-category generation-bound-mutable :owner ebox-incremental
|
||||
:authority ebox :lifetime process-by-buffer
|
||||
:rollback tp-client-state-or-snapshot :rebuild render-or-commit
|
||||
:cleanup buffer-kill-and-unmount)
|
||||
(:id viewport/pending-frames
|
||||
:storage (:global ebox--window-size-change-pending-frames)
|
||||
:owner ebox-viewport :authority ebox :lifetime until-event-loop-drain
|
||||
:rollback unstarted-frames-requeued-on-quit
|
||||
:rebuild subsequent-window-size-notification
|
||||
:cleanup drain-consumption-and-dead-frame-pruning)
|
||||
(:id viewport/drain-timer
|
||||
:storage (:global ebox--window-size-change-timer)
|
||||
:owner ebox-viewport :authority ebox :lifetime until-event-loop-drain
|
||||
:rollback not-publication-authority
|
||||
:rebuild schedule-remaining-pending-frames
|
||||
:cleanup timer-entry)
|
||||
(:id buffer-runtime/node-table
|
||||
:storage (:state-key :node-table)
|
||||
:proposed-category generation-fact :owner ebox-tree :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-discard
|
||||
:rebuild runtime-index :cleanup generation-replacement)
|
||||
(:id buffer-runtime/parent-table
|
||||
:storage (:state-key :parent-table)
|
||||
:proposed-category generation-fact :owner ebox-tree :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-discard
|
||||
:rebuild runtime-index :cleanup generation-replacement)
|
||||
(:id buffer-runtime/runtime-type-count-table
|
||||
:storage (:state-key :runtime-type-count-table)
|
||||
:proposed-category generation-fact :owner ebox-tree :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-discard
|
||||
:rebuild runtime-index :cleanup generation-replacement)
|
||||
(:id buffer-runtime/range-ref-table
|
||||
:storage (:state-key :range-ref-table)
|
||||
:proposed-category generation-fact :owner ebox-child-range :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-discard
|
||||
:rebuild runtime-index :cleanup generation-replacement)
|
||||
(:id buffer-runtime/region-id-set
|
||||
:storage (:state-key :region-id-set)
|
||||
:proposed-category generation-fact :owner ebox-tree :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-discard
|
||||
:rebuild runtime-index :cleanup generation-replacement)
|
||||
(:id buffer-runtime/region-node-table
|
||||
:storage (:state-key :region-node-table)
|
||||
:proposed-category generation-fact :owner ebox-tree :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-discard
|
||||
:rebuild runtime-index :cleanup generation-replacement)
|
||||
(:id buffer-runtime/region-box-count-table
|
||||
:storage (:state-key :region-box-count-table)
|
||||
:proposed-category generation-fact :owner ebox-tree :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-discard
|
||||
:rebuild runtime-index :cleanup generation-replacement)
|
||||
(:id buffer-runtime/region-box-table
|
||||
:storage (:state-key :region-box-table)
|
||||
:proposed-category generation-bound-mutable :owner ebox-layout :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-snapshot
|
||||
:rebuild layout-materialization :cleanup generation-replacement)
|
||||
(:id buffer-runtime/selector-id-table
|
||||
:storage (:state-key :selector-id-table)
|
||||
:proposed-category generation-fact :owner ebox-tree :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-discard
|
||||
:rebuild selector-index :cleanup generation-replacement)
|
||||
(:id buffer-runtime/selector-class-table
|
||||
:storage (:state-key :selector-class-table)
|
||||
:proposed-category generation-fact :owner ebox-tree :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-discard
|
||||
:rebuild selector-index :cleanup generation-replacement)
|
||||
(:id buffer-runtime/selector-type-table
|
||||
:storage (:state-key :selector-type-table)
|
||||
:proposed-category generation-fact :owner ebox-tree :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-discard
|
||||
:rebuild selector-index :cleanup generation-replacement)
|
||||
(:id buffer-runtime/layout-snapshots
|
||||
:storage (:state-key :layout-snapshots)
|
||||
:proposed-category generation-fact :owner ebox-fragment :authority ebox
|
||||
:lifetime committed-generation :rollback candidate-discard
|
||||
:rebuild layout-snapshot-pass :cleanup generation-replacement)
|
||||
(:id buffer-runtime/layout-fragments
|
||||
:storage (:state-key :layout-fragments)
|
||||
:proposed-category disposable-cache :owner ebox-fragment :authority ebox
|
||||
:lifetime buffer-runtime :rollback copy-on-candidate
|
||||
:rebuild lazy-layout :cleanup clear-cache-or-buffer-kill)
|
||||
(:id buffer-runtime/render-cache
|
||||
:storage (:state-key :render-cache)
|
||||
:proposed-category disposable-cache :owner ebox-incremental :authority ebox
|
||||
:lifetime buffer-runtime :rollback copy-on-candidate
|
||||
:rebuild lazy-render :cleanup clear-cache-or-buffer-kill)
|
||||
(:id buffer-runtime/render-signature-cache
|
||||
:storage (:state-key :render-signature-cache)
|
||||
:proposed-category disposable-cache :owner ebox-incremental :authority ebox
|
||||
:lifetime buffer-runtime :rollback copy-on-candidate
|
||||
:rebuild lazy-render :cleanup clear-cache-or-buffer-kill)
|
||||
(:id buffer-runtime/flex-content-min-widths
|
||||
:storage (:state-key :flex-content-min-widths)
|
||||
:proposed-category disposable-cache :owner ebox-flex :authority ebox
|
||||
:lifetime buffer-runtime :rollback candidate-discard
|
||||
:rebuild lazy-measure :cleanup generation-replacement)
|
||||
(:id buffer-runtime/viewport-height-dependent-subtree-cache
|
||||
:storage (:state-key :viewport-height-dependent-subtree-cache)
|
||||
:proposed-category disposable-cache :owner ebox-incremental :authority ebox
|
||||
:lifetime buffer-runtime :rollback copy-on-candidate
|
||||
:rebuild viewport-proof :cleanup clear-cache-or-buffer-kill)
|
||||
(:id buffer-runtime/render-owned-text-values
|
||||
:storage (:state-key :render-owned-text-values)
|
||||
:proposed-category generation-bound-mutable :owner ebox-surface :authority ebox
|
||||
:lifetime committed-generation :rollback tp-transaction
|
||||
:rebuild surface-projection :cleanup generation-replacement)
|
||||
(:id buffer-runtime/style-binding-states
|
||||
:storage (:state-key :style-binding-states)
|
||||
:proposed-category generation-bound-mutable :owner ebox-surface :authority tp
|
||||
:lifetime mounted-surface :rollback tp-transaction
|
||||
:rebuild surface-projection :cleanup surface-unmount)
|
||||
(:id buffer-runtime/surface-node-object-table
|
||||
:storage (:state-key :surface-node-object-table)
|
||||
:proposed-category disposable-mirror :owner ebox-surface :authority tp
|
||||
:lifetime mounted-surface :rollback tp-transaction
|
||||
:rebuild surface-projection :cleanup surface-unmount)
|
||||
(:id buffer-runtime/region-surface-object-table
|
||||
:storage (:state-key :region-surface-object-table)
|
||||
:proposed-category disposable-mirror :owner ebox-surface :authority tp
|
||||
:lifetime mounted-surface :rollback tp-transaction
|
||||
:rebuild surface-projection :cleanup surface-unmount)
|
||||
(:id buffer-runtime/surface-object-region-table
|
||||
:storage (:state-key :surface-object-region-table)
|
||||
:proposed-category disposable-mirror :owner ebox-surface :authority tp
|
||||
:lifetime mounted-surface :rollback tp-transaction
|
||||
:rebuild surface-projection :cleanup surface-unmount)
|
||||
(:id buffer-runtime/logical-id-region-table
|
||||
:storage (:state-key :logical-id-region-table)
|
||||
:proposed-category disposable-mirror :owner ebox-surface :authority ebox
|
||||
:lifetime committed-generation :rollback tp-transaction
|
||||
:rebuild surface-projection :cleanup generation-replacement)
|
||||
(:id buffer-runtime/scroll-state-table
|
||||
:storage (:state-key :scroll-state-table)
|
||||
:proposed-category generation-bound-mutable :owner ebox-layout :authority ebox
|
||||
:lifetime buffer-runtime :rollback scroll-state-snapshot
|
||||
:rebuild layout-or-scroll-reconciliation :cleanup buffer-kill)
|
||||
(:id buffer-runtime/native-sync-session
|
||||
:storage (:state-key :native-sync-session)
|
||||
:proposed-category generation-bound-mutable :owner ebox-native-commit
|
||||
:authority ebox :lifetime buffer-runtime :rollback private-session-discard
|
||||
:rebuild create-or-fork-session :cleanup buffer-kill)
|
||||
(:id buffer-runtime/native-sync-pending
|
||||
:storage (:state-key :native-sync-pending)
|
||||
:proposed-category generation-bound-mutable :owner ebox-native-commit
|
||||
:authority ebox :lifetime candidate-until-final-accept
|
||||
:rollback private-session-discard :rebuild native-frame-stage
|
||||
:cleanup confirm-or-rollback)
|
||||
(:id candidate/native-local-dirty-entries
|
||||
:storage (:state-key :native-local-dirty-entries)
|
||||
:proposed-category generation-fact :owner ebox-native-commit
|
||||
:authority ebox :lifetime candidate-until-native-input
|
||||
:rollback candidate-discard :rebuild prepared-dirty-set
|
||||
:cleanup native-frame-consume-or-downgrade)
|
||||
(:id native-session/layout-fragment-index
|
||||
:storage (:struct-slot ebox-native-reflow-session layout-fragment-index)
|
||||
:proposed-category generation-bound-mutable :owner ebox-native-reflow
|
||||
:authority ebox :lifetime private-native-session
|
||||
:rollback private-session-discard :rebuild full-bootstrap-or-path-copy
|
||||
:cleanup native-session-release)
|
||||
(:id native-session/layout-style-index
|
||||
:storage (:struct-slot ebox-native-reflow-session layout-style-index)
|
||||
:proposed-category generation-bound-mutable :owner ebox-native-reflow
|
||||
:authority ebox :lifetime private-native-session
|
||||
:rollback private-session-discard :rebuild full-bootstrap-or-style-append
|
||||
:cleanup native-session-release)
|
||||
(:id native-session/layout-property-template-index
|
||||
:storage
|
||||
(:struct-slot ebox-native-reflow-session layout-property-template-index)
|
||||
:proposed-category generation-bound-mutable :owner ebox-native-reflow
|
||||
:authority ebox :lifetime private-native-session
|
||||
:rollback private-session-discard :rebuild full-bootstrap
|
||||
:cleanup native-session-release)
|
||||
(:id native-session/layout-edge-index
|
||||
:storage (:struct-slot ebox-native-reflow-session layout-edge-index)
|
||||
:proposed-category generation-bound-mutable :owner ebox-native-reflow
|
||||
:authority ebox :lifetime private-native-session
|
||||
:rollback private-session-discard :rebuild full-bootstrap-edge-facts
|
||||
:cleanup native-session-release)
|
||||
(:id native-compile/style-index
|
||||
:storage (:global ebox-native-reflow--compile-style-index)
|
||||
:proposed-category generation-fact :owner ebox-native-reflow
|
||||
:authority ebox :lifetime dynamic-native-compile
|
||||
:rollback dynamic-binding-unwind :rebuild retained-session-root
|
||||
:cleanup dynamic-binding-unwind)
|
||||
(:id native-compile/property-template-index
|
||||
:storage (:global ebox-native-reflow--compile-property-template-index)
|
||||
:proposed-category generation-fact :owner ebox-native-reflow
|
||||
:authority ebox :lifetime dynamic-native-compile
|
||||
:rollback dynamic-binding-unwind :rebuild retained-session-root
|
||||
:cleanup dynamic-binding-unwind)
|
||||
(:id native-package/fragment-index
|
||||
:storage (:state-key :native-fragment-index)
|
||||
:proposed-category generation-fact :owner ebox-native-reflow
|
||||
:authority ebox :lifetime native-call-until-success
|
||||
:rollback package-discard :rebuild path-copied-session-root
|
||||
:cleanup strip-from-confirmed-package)
|
||||
(:id native-package/style-index
|
||||
:storage (:state-key :native-style-index)
|
||||
:proposed-category generation-fact :owner ebox-native-reflow
|
||||
:authority ebox :lifetime native-call-until-success
|
||||
:rollback package-discard :rebuild style-append-path-copy
|
||||
:cleanup strip-from-confirmed-package)
|
||||
(:id process/scroll-global-state
|
||||
:storage (:global ebox--scroll-global-state)
|
||||
:proposed-category generation-bound-mutable :owner ebox-incremental
|
||||
:authority ebox :lifetime process-by-region :rollback explicit-snapshot
|
||||
:rebuild render-or-scroll :cleanup region-retirement)
|
||||
(:id process/smooth-scroll-state-table
|
||||
:storage (:global ebox--smooth-scroll-state-table)
|
||||
:proposed-category generation-bound-mutable :owner ebox :authority ebox
|
||||
:lifetime active-scroll :rollback stop-and-restore
|
||||
:rebuild scroll-input :cleanup scroll-stop-or-buffer-kill)
|
||||
(:id process/incremental-batch-table
|
||||
:storage (:global ebox-incremental--batch-table)
|
||||
:proposed-category generation-bound-mutable :owner ebox-incremental
|
||||
:authority ebox :lifetime explicit-batch :rollback discard-pending
|
||||
:rebuild batch-begin :cleanup batch-end-or-buffer-kill)
|
||||
(:id process/render-cache-ring-table
|
||||
:storage (:global ebox--render-cache-ring-table)
|
||||
:proposed-category disposable-cache :owner ebox-incremental :authority ebox
|
||||
:lifetime process-by-render-cache :rollback not-authoritative
|
||||
:rebuild lazy-eviction-index :cleanup cache-retirement)
|
||||
(:id process/render-root-cache-table-table
|
||||
:storage (:global ebox--render-root-cache-table-table)
|
||||
:proposed-category disposable-cache :owner ebox-incremental :authority ebox
|
||||
:lifetime process-by-render-cache :rollback not-authoritative
|
||||
:rebuild lazy-root-cache :cleanup cache-retirement)
|
||||
(:id process/render-cache-entry-side-effects-table
|
||||
:storage (:global ebox--render-cache-entry-side-effects-table)
|
||||
:proposed-category disposable-cache :owner ebox-incremental :authority ebox
|
||||
:lifetime process-by-render-cache :rollback not-authoritative
|
||||
:rebuild lazy-render :cleanup cache-retirement)
|
||||
(:id process/font-cache
|
||||
:storage (:global ebox-font--cache)
|
||||
:proposed-category disposable-cache :owner ebox-font :authority ebox
|
||||
:lifetime process :rollback not-authoritative :rebuild lazy-measure
|
||||
:cleanup ebox-font-clear-cache)
|
||||
(:id process/native-hover-face-cache
|
||||
:storage (:global ebox-interaction--hover-face-cache)
|
||||
:proposed-category disposable-cache :owner ebox-interaction :authority ebox
|
||||
:lifetime live-face-references :rollback discard-unreferenced-candidates
|
||||
:rebuild reproject-owner-native-hover-face
|
||||
:cleanup garbage-collect-unreferenced-faces)
|
||||
(:id process/native-source-cluster-cache
|
||||
:storage (:global ebox-native-reflow--source-cluster-cache)
|
||||
:proposed-category disposable-cache :owner ebox-native-reflow :authority ebox
|
||||
:lifetime process :rollback not-authoritative :rebuild lazy-native-compile
|
||||
:cleanup bounded-eviction-or-clear))
|
||||
"Observed current Ebox runtime, region, scroll, native, and cache stores.")
|
||||
|
||||
(defun ebox-m0a-current-state-inventory ()
|
||||
"Return all stores with observable facts and inactive M2a proposals."
|
||||
(mapcar #'ebox-m0a--normalize-current-record
|
||||
(append ebox-m0a-state-table-inventory
|
||||
ebox-m0a-supplemental-state-table-inventory)))
|
||||
|
||||
(defconst ebox-m0a-spi-inventory
|
||||
'((:id ebox-to-tp/publication :provider tp :consumer ebox-surface
|
||||
:version unversioned-v1 :discovery required-feature
|
||||
:entry-points (tp-with-transaction tp-surface-mount tp-surface-update
|
||||
tp-surface-update-scoped tp-transaction-participate)
|
||||
:rollback tp-transaction :future-v2-status not-required-in-m0a)
|
||||
(:id ebox-to-ecss/cascade :provider ecss :consumer ebox-style
|
||||
:version unversioned-v1 :discovery required-feature
|
||||
:entry-points (ecss-cascade-compute ecss-stylesheet-create)
|
||||
:rollback prepare-only :future-v2-status not-required-in-m0a)
|
||||
(:id ebox-to-ekp/wrap-backend :provider ekp :consumer ebox-layout
|
||||
:version optional-string-v1 :discovery dynamic-require-and-fboundp
|
||||
:entry-points (ekp-pixel-justify)
|
||||
:rollback word-wrap-fallback :future-v2-status not-required-in-m0a)
|
||||
(:id etaf-to-ebox/lowering :provider ebox :consumer etaf-renderer
|
||||
:version unversioned-v1 :discovery required-feature
|
||||
:entry-points (ebox-source-builder-create ebox-source-builder-bind
|
||||
ebox-text-create ebox-box-create ebox-canonical-input-create)
|
||||
:rollback candidate-discard :future-v2-status not-required-in-m0a)
|
||||
(:id etaf-to-ebox/commit :provider ebox :consumer etaf-runtime
|
||||
:version callback-pair-v1 :discovery required-feature
|
||||
:entry-points (ebox-candidate-begin ebox-candidate-replace-host-ref
|
||||
ebox-candidate-replace-range-ref ebox-commit)
|
||||
:rollback framework-callback-pair :future-v2-status not-required-in-m0a)
|
||||
(:id etaf-to-ebox/event-burst :provider ebox :consumer etaf-events
|
||||
:version capability-probe-v1 :discovery fboundp
|
||||
:entry-points (ebox-call-with-render-burst ebox-host-ref-bounds
|
||||
ebox-host-ref-position)
|
||||
:rollback unwind-protect :future-v2-status not-required-in-m0a))
|
||||
"Observed cross-package call groups; no v2 capability object exists yet.")
|
||||
|
||||
(defconst ebox-m0a-public-load-inventory
|
||||
'((:feature ebox :file "ebox.el"
|
||||
:public-entry-points (ebox-render ebox-render-to-buffer ebox-commit
|
||||
ebox-buffer-update-report ebox-candidate-begin
|
||||
ebox-call-with-render-burst))
|
||||
(:feature ebox-dsl :file "ebox-dsl.el"
|
||||
:public-entry-points (ebox-build))
|
||||
(:feature ebox-selector :file "ebox-selector.el"
|
||||
:public-entry-points (ebox-selector-query-buffer
|
||||
ebox-selector-update-buffer)))
|
||||
"Current public feature/load and representative entry-point inventory.")
|
||||
|
||||
(defconst ebox-m0a-characterization-inventory
|
||||
'((:id framework/same-report-success :status current-green
|
||||
:file "tests/ebox-m0a-characterization-tests.el"
|
||||
:test ebox-m0a-success-return-preserves-framework-report-identity)
|
||||
(:id framework/same-report-rollback :status current-green
|
||||
:file "tests/ebox-m0a-characterization-tests.el"
|
||||
:test ebox-m0a-framework-rollback-receives-publish-report-object)
|
||||
(:id condition/raw-symbol-and-data :status current-green
|
||||
:file "tests/ebox-m0a-characterization-tests.el"
|
||||
:test ebox-m0a-framework-failure-resignals-raw-condition-symbol-and-data)
|
||||
(:id framework/full-and-scoped-rollback :status current-green
|
||||
:file "tests/ebox-commit-tests.el"
|
||||
:test ebox-commit-framework-publish-failure-rolls-back-full-and-scoped)
|
||||
(:id framework/final-accept-rollback :status current-green
|
||||
:file "tests/ebox-commit-tests.el"
|
||||
:test ebox-commit-final-accept-failure-rolls-framework-back)
|
||||
(:id native/private-session-failure-isolation :status current-green
|
||||
:file "tests/ebox-commit-tests.el"
|
||||
:test ebox-native-session-isolation-rejects-create-and-fork-errors)
|
||||
(:id paint/atomic-rollback :status current-green
|
||||
:file "tests/ebox-core-render-tests.el"
|
||||
:test ebox-region-update-valid-paint-and-invalid-edge-is-atomic)
|
||||
(:id future/versioned-spi-v2 :status future-not-required
|
||||
:file nil :test nil))
|
||||
"Executable current gates and explicitly non-green future targets for M0a.")
|
||||
|
||||
(provide 'ebox-m0a-inventory-fixture)
|
||||
|
||||
;;; ebox-m0a-inventory-fixture.el ends here
|
||||
360
tests/ebox-m0a-inventory-tests.el
Normal file
360
tests/ebox-m0a-inventory-tests.el
Normal file
@ -0,0 +1,360 @@
|
||||
;;; ebox-m0a-inventory-tests.el --- M0a inventory contracts -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
(require 'ebox)
|
||||
(require 'ebox-m0a-inventory-fixture)
|
||||
(require 'ebox-fixtures)
|
||||
|
||||
(defun ebox-m0a-test--all-state-inventory ()
|
||||
"Return primary and supplemental classified state inventories."
|
||||
(ebox-m0a-current-state-inventory))
|
||||
|
||||
(defun ebox-m0a-test--state-key-records ()
|
||||
"Return inventory records stored as retained state keys."
|
||||
(cl-remove-if-not
|
||||
(lambda (record) (eq (car (plist-get record :storage)) :state-key))
|
||||
(ebox-m0a-test--all-state-inventory)))
|
||||
|
||||
(defun ebox-m0a-test--store-name-p (symbol)
|
||||
"Return non-nil when SYMBOL names a table-like state store."
|
||||
(and (symbolp symbol)
|
||||
(string-match-p
|
||||
"\\(?:-table\\|-cache\\|-index\\|-registry\\|-history\\|-session\\|-jobs\\|-timer\\|-timers\\)\\'"
|
||||
(symbol-name symbol))))
|
||||
|
||||
(defun ebox-m0a-test--keyword-store-name-p (symbol)
|
||||
"Return non-nil when keyword SYMBOL names a nested state store."
|
||||
(and (keywordp symbol) (ebox-m0a-test--store-name-p symbol)))
|
||||
|
||||
(defun ebox-m0a-test--hash-constructor-p (form)
|
||||
"Return non-nil when FORM directly materializes a hash table."
|
||||
(eq (car-safe form) 'make-hash-table))
|
||||
|
||||
(defun ebox-m0a-test--declared-globals (forms)
|
||||
"Return variables declared by top-level FORMS."
|
||||
(let (globals)
|
||||
(dolist (form forms)
|
||||
(when (and (memq (car-safe form) '(defvar defconst))
|
||||
(symbolp (cadr form)))
|
||||
(push (cadr form) globals)))
|
||||
(delete-dups globals)))
|
||||
|
||||
(defun ebox-m0a-test--materialized-global-bindings (form declared-globals)
|
||||
"Return declared globals directly bound to hashes inside FORM.
|
||||
DECLARED-GLOBALS distinguishes dynamic package stores from ordinary lexical
|
||||
scratch tables, which are intentionally outside the retained-store inventory."
|
||||
(let (globals)
|
||||
(ebox-m0a-test--walk-form
|
||||
form
|
||||
(lambda (nested)
|
||||
(pcase (car-safe nested)
|
||||
((or 'let 'let*)
|
||||
(dolist (binding (cadr nested))
|
||||
(when (and (consp binding)
|
||||
(memq (car binding) declared-globals)
|
||||
(ebox-m0a-test--hash-constructor-p (cadr binding)))
|
||||
(push (car binding) globals))))
|
||||
((or 'setq 'setq-default)
|
||||
(let ((pairs (cdr nested)))
|
||||
(while pairs
|
||||
(when (and (memq (car pairs) declared-globals)
|
||||
(ebox-m0a-test--hash-constructor-p (cadr pairs)))
|
||||
(push (car pairs) globals))
|
||||
(setq pairs (cddr pairs))))))))
|
||||
(delete-dups globals)))
|
||||
|
||||
(defun ebox-m0a-test--read-forms (file)
|
||||
"Read and return every top-level Lisp form in FILE."
|
||||
(with-temp-buffer
|
||||
(insert-file-contents file)
|
||||
(let (forms form)
|
||||
(condition-case nil
|
||||
(while t
|
||||
(setq form (read (current-buffer)))
|
||||
(push form forms))
|
||||
(end-of-file nil))
|
||||
(nreverse forms))))
|
||||
|
||||
(defun ebox-m0a-test--walk-form (form function)
|
||||
"Call FUNCTION for FORM and every nested cons or vector element."
|
||||
(funcall function form)
|
||||
(cond
|
||||
((consp form)
|
||||
(ebox-m0a-test--walk-form (car form) function)
|
||||
(ebox-m0a-test--walk-form (cdr form) function))
|
||||
((vectorp form)
|
||||
(mapc (lambda (item) (ebox-m0a-test--walk-form item function)) form))))
|
||||
|
||||
(defun ebox-m0a-test--struct-name (definition)
|
||||
"Return the structure name declared by `cl-defstruct' DEFINITION."
|
||||
(let ((head (cadr definition)))
|
||||
(if (symbolp head) head (car head))))
|
||||
|
||||
(defun ebox-m0a-test--struct-slots (definition)
|
||||
"Return plain slot names declared by `cl-defstruct' DEFINITION."
|
||||
(let ((tail (cddr definition)) slots)
|
||||
(while (and tail (stringp (car tail))) (pop tail))
|
||||
(dolist (slot tail)
|
||||
(let ((name (if (symbolp slot) slot (car-safe slot))))
|
||||
(when (symbolp name) (push name slots))))
|
||||
(nreverse slots)))
|
||||
|
||||
(defun ebox-m0a-test--schema-store-slot-p (structure slot)
|
||||
"Return non-nil when STRUCTURE's SLOT is a known nested store schema."
|
||||
(or (ebox-m0a-test--store-name-p slot)
|
||||
(member
|
||||
(cons structure slot)
|
||||
'((ebox-source-index . records)
|
||||
(ebox-source-index . handle-records)
|
||||
(ebox-source-index . subjects)
|
||||
(ebox-source-index . node-subjects)
|
||||
(ebox-source-builder . records)
|
||||
(ebox-source-builder . handle-records)
|
||||
(ebox-source-table . added)
|
||||
(ebox-source-table . removed)
|
||||
(ebox-source-table . memo)
|
||||
(ebox-source-order . replacements)
|
||||
(ebox-source-order . removed)
|
||||
(ebox-incremental--detached-history . table)))))
|
||||
|
||||
(defun ebox-m0a-test--static-store-candidates ()
|
||||
"Return located store candidates found in active Ebox source forms."
|
||||
(let* ((sources (delete-dups (copy-sequence ebox--compile-sources)))
|
||||
(source-forms
|
||||
(mapcar (lambda (source)
|
||||
(cons source (ebox-m0a-test--read-forms source)))
|
||||
sources))
|
||||
(declared-globals
|
||||
(delete-dups
|
||||
(apply #'append
|
||||
(mapcar (lambda (entry)
|
||||
(ebox-m0a-test--declared-globals (cdr entry)))
|
||||
source-forms))))
|
||||
candidates)
|
||||
(dolist (entry source-forms)
|
||||
(let* ((source (car entry))
|
||||
(module (intern (file-name-base source)))
|
||||
(forms (cdr entry)))
|
||||
(dolist (form forms)
|
||||
(when (and (memq (car-safe form) '(defvar defconst))
|
||||
(or (ebox-m0a-test--store-name-p (cadr form))
|
||||
(ebox-m0a-test--hash-constructor-p (caddr form))))
|
||||
(push (list :storage (list :global (cadr form))
|
||||
:defined-in source :declaring-module module)
|
||||
candidates))
|
||||
(dolist (symbol
|
||||
(ebox-m0a-test--materialized-global-bindings
|
||||
form declared-globals))
|
||||
(push (list :storage (list :global symbol)
|
||||
:defined-in source :declaring-module module)
|
||||
candidates))
|
||||
(when (eq (car-safe form) 'cl-defstruct)
|
||||
(let ((structure (ebox-m0a-test--struct-name form)))
|
||||
(dolist (slot (ebox-m0a-test--struct-slots form))
|
||||
(when (ebox-m0a-test--schema-store-slot-p structure slot)
|
||||
(push (list :storage (list :struct-slot structure slot)
|
||||
:defined-in source :declaring-module module)
|
||||
candidates)))))
|
||||
(ebox-m0a-test--walk-form
|
||||
form
|
||||
(lambda (nested)
|
||||
(when (ebox-m0a-test--keyword-store-name-p nested)
|
||||
(push (list :storage (list :state-key nested)
|
||||
:defined-in source :declaring-module module)
|
||||
candidates)))))))
|
||||
(sort (delete-dups candidates)
|
||||
(lambda (left right)
|
||||
(string< (prin1-to-string left) (prin1-to-string right))))))
|
||||
|
||||
(defun ebox-m0a-test--inventory-storage-identities ()
|
||||
"Return all classified and explicitly blocked storage identities."
|
||||
(append
|
||||
(mapcar (lambda (record) (plist-get record :storage))
|
||||
(ebox-m0a-test--all-state-inventory))
|
||||
(mapcar (lambda (record) (plist-get record :storage))
|
||||
ebox-m0a-non-store-exclusions)))
|
||||
|
||||
(ert-deftest ebox-m0a-inventory-records-have-complete-lifecycle-fields ()
|
||||
"Every state inventory record names its current lifecycle contract."
|
||||
(dolist (record (ebox-m0a-test--all-state-inventory))
|
||||
(dolist (field ebox-m0a-state-table-required-fields)
|
||||
(should (plist-member record field))
|
||||
(should (plist-get record field)))))
|
||||
|
||||
(ert-deftest ebox-m0a-non-store-exclusions-carry-source-evidence ()
|
||||
"Every scanner exclusion records why its matching name is not a store."
|
||||
(dolist (record ebox-m0a-non-store-exclusions)
|
||||
(should (plist-get record :storage))
|
||||
(should (plist-get record :why))
|
||||
(should (plist-get record :owner))
|
||||
(should (plist-get record :evidence))))
|
||||
|
||||
(ert-deftest ebox-m0a-has-no-observed-unclassified-stores ()
|
||||
"M0a cannot pass while a real discovered store remains unclassified."
|
||||
(should-not ebox-m0a-observed-unclassified-stores))
|
||||
|
||||
(ert-deftest ebox-m0a-static-store-scan-is-fail-closed ()
|
||||
"Every located static candidate is classified or proven not to be a store."
|
||||
(let ((known (ebox-m0a-test--inventory-storage-identities)))
|
||||
(dolist (candidate (ebox-m0a-test--static-store-candidates))
|
||||
(should (plist-get candidate :defined-in))
|
||||
(should (plist-get candidate :declaring-module))
|
||||
(should (member (plist-get candidate :storage) known)))))
|
||||
|
||||
(ert-deftest ebox-m0a-materialized-global-scan-excludes-lexical-scratch-tables ()
|
||||
"Dynamic package stores are candidates; ordinary lexical tables are not."
|
||||
(should
|
||||
(equal
|
||||
(ebox-m0a-test--materialized-global-bindings
|
||||
'(let ((scratch (make-hash-table :test #'eq))
|
||||
(ebox-m0a-test--dynamic-store (make-hash-table :test #'equal)))
|
||||
(puthash 'key 'value scratch))
|
||||
'(ebox-m0a-test--dynamic-store))
|
||||
'(ebox-m0a-test--dynamic-store))))
|
||||
|
||||
(ert-deftest ebox-m0a-lazy-and-nested-stores-are-classified-not-blocked ()
|
||||
"Known lazy native/viewport/scroll and nested stores have classifications."
|
||||
(let ((classified
|
||||
(mapcar (lambda (record) (plist-get record :storage))
|
||||
(ebox-m0a-test--all-state-inventory)))
|
||||
(critical
|
||||
'((:global ebox-cache--registry)
|
||||
(:global ebox-cache--buffer-report-table)
|
||||
(:global ebox--char-width-cache)
|
||||
(:global ebox--render-burst-records)
|
||||
(:global ebox--render-owned-text-values)
|
||||
(:global ebox--string-pixel-width-cache)
|
||||
(:global ebox--scroll-idle-prefetch-inhibited-buffers)
|
||||
(:global ebox--scroll-idle-prefetch-timers)
|
||||
(:global ebox--runtime-prewarm-jobs)
|
||||
(:global ebox--runtime-prewarm-timers)
|
||||
(:global ebox--window-size-change-pending-frames)
|
||||
(:global ebox--window-size-change-timer)
|
||||
(:global ebox--window-line-renderer-table)
|
||||
(:global ebox--window-line-prewarmer-table)
|
||||
(:global ebox-native-reflow--compile-property-template-ids)
|
||||
(:state-key :source-index)
|
||||
(:state-key :detached-identity-history)
|
||||
(:state-key :viewport-dependent-node-ids-cache)
|
||||
(:state-key :native-session)
|
||||
(:struct-slot ebox-source-index host-ref-table)
|
||||
(:struct-slot ebox-source-table added)
|
||||
(:struct-slot ebox-source-table removed)
|
||||
(:struct-slot ebox-source-table memo)
|
||||
(:struct-slot ebox-incremental--detached-history table)
|
||||
(:struct-slot ebox-native-reflow-preparation frame-cache)
|
||||
(:struct-slot ebox-native-reflow-preparation ready-timer)
|
||||
(:struct-slot ebox-native-reflow-session layout-fragment-cache))))
|
||||
(dolist (storage critical)
|
||||
(should (member storage classified)))))
|
||||
|
||||
(ert-deftest ebox-m0a-supplemental-stores-name-concrete-module-owners ()
|
||||
"Representative supplemental stores resolve to their implementation modules."
|
||||
(dolist (expected
|
||||
'(((:global ebox-cache--registry) . ebox-cache)
|
||||
((:global ebox--char-width-cache) . ebox-measure)
|
||||
((:global ebox--render-burst-records) . ebox-buffer-backend)
|
||||
((:global ebox--render-owned-text-values) . ebox-render-context)
|
||||
((:state-key :owner-table) . ebox-incremental)
|
||||
((:struct-slot ebox-source-index host-ref-table) . ebox-source)
|
||||
((:struct-slot ebox-native-reflow-preparation frame-cache)
|
||||
. ebox-native-reflow)))
|
||||
(let ((record
|
||||
(cl-find (car expected) (ebox-m0a-test--all-state-inventory)
|
||||
:key (lambda (item) (plist-get item :storage))
|
||||
:test #'equal)))
|
||||
(should (eq (plist-get record :owner) (cdr expected))))))
|
||||
|
||||
(ert-deftest ebox-m0a-inventory-record-identities-are-unique ()
|
||||
"Every inventory record has one stable machine-readable identity."
|
||||
(let ((ids (mapcar (lambda (record) (plist-get record :id))
|
||||
(ebox-m0a-test--all-state-inventory))))
|
||||
(should (= (length ids) (length (delete-dups (copy-sequence ids)))))))
|
||||
|
||||
(ert-deftest ebox-m0a-proposed-state-categories-remain-inactive ()
|
||||
"M0a records proposed M2a categories without asserting their activation."
|
||||
(dolist (record (ebox-m0a-test--all-state-inventory))
|
||||
(when (plist-get record :proposed-category)
|
||||
(should (eq (plist-get record :proposal-status) 'not-active)))))
|
||||
|
||||
(ert-deftest ebox-m0a-layout-snapshots-record-current-in-place-aliasing ()
|
||||
"Layout snapshots expose current committed-state mutation, not target purity."
|
||||
(let ((record
|
||||
(cl-find '(:state-key :layout-snapshots)
|
||||
(ebox-m0a-test--all-state-inventory)
|
||||
:key (lambda (item) (plist-get item :storage))
|
||||
:test #'equal)))
|
||||
(should (eq (plist-get record :current-mutation)
|
||||
'in-place-puthash-on-committed-state))
|
||||
(should (eq (plist-get record :aliasing)
|
||||
'committed-state-table-retained-and-mutated))))
|
||||
|
||||
(ert-deftest ebox-m0a-global-store-inventory-resolves-current-bindings ()
|
||||
"Every inventoried process store names a currently bound variable."
|
||||
(dolist (record (ebox-m0a-test--all-state-inventory))
|
||||
(when (eq (car (plist-get record :storage)) :global)
|
||||
(should (boundp (cadr (plist-get record :storage)))))))
|
||||
|
||||
(ert-deftest ebox-m0a-inventory-covers-all-materialized-runtime-hash-tables ()
|
||||
"A normal committed runtime exposes no unclassified retained hash table."
|
||||
(let ((buffer (generate-new-buffer " *ebox-m0a-runtime-inventory*")))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(ebox-render-to-buffer
|
||||
buffer
|
||||
(ebox-test-column
|
||||
(ebox-test-box :key 'first (ebox-test-text "first"))
|
||||
(ebox-test-box :key 'second (ebox-test-text "second"))))
|
||||
(let ((state (ebox--buffer-render-state buffer))
|
||||
actual expected)
|
||||
(while state
|
||||
(when (hash-table-p (cadr state))
|
||||
(push (car state) actual))
|
||||
(setq state (cddr state)))
|
||||
(dolist (record (ebox-m0a-test--state-key-records))
|
||||
(let ((key (cadr (plist-get record :storage))))
|
||||
(when (hash-table-p
|
||||
(plist-get (ebox--buffer-render-state buffer) key))
|
||||
(push key expected))))
|
||||
(should (equal (sort actual #'string-lessp)
|
||||
(sort expected #'string-lessp)))))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest ebox-m0a-current-spi-entry-points-are-symbol-lists ()
|
||||
"Every observed SPI group names a non-empty symbolic call surface."
|
||||
(dolist (record ebox-m0a-spi-inventory)
|
||||
(let ((entry-points (plist-get record :entry-points)))
|
||||
(should entry-points)
|
||||
(should (cl-every #'symbolp entry-points))
|
||||
(should (eq (plist-get record :future-v2-status)
|
||||
'not-required-in-m0a)))))
|
||||
|
||||
(ert-deftest ebox-m0a-public-load-inventory-resolves-current-entry-points ()
|
||||
"Every inventoried public feature and representative function currently loads."
|
||||
(dolist (record ebox-m0a-public-load-inventory)
|
||||
(should (require (plist-get record :feature) nil t))
|
||||
(dolist (entry-point (plist-get record :public-entry-points))
|
||||
(should (fboundp entry-point)))))
|
||||
|
||||
(ert-deftest ebox-m0a-current-characterization-references-existing-tests ()
|
||||
"Every current-green behavior gate points at an existing ERT definition."
|
||||
(dolist (record ebox-m0a-characterization-inventory)
|
||||
(when (eq (plist-get record :status) 'current-green)
|
||||
(let* ((file (plist-get record :file))
|
||||
(test (plist-get record :test))
|
||||
(source
|
||||
(with-temp-buffer
|
||||
(insert-file-contents file)
|
||||
(buffer-string))))
|
||||
(should
|
||||
(string-match-p
|
||||
(format "(ert-deftest[[:space:]\n]+%s\\_>"
|
||||
(regexp-quote (symbol-name test)))
|
||||
source))))))
|
||||
|
||||
(provide 'ebox-m0a-inventory-tests)
|
||||
|
||||
;;; ebox-m0a-inventory-tests.el ends here
|
||||
447
tests/ebox-node-memo-generation-tests.el
Normal file
447
tests/ebox-node-memo-generation-tests.el
Normal file
@ -0,0 +1,447 @@
|
||||
;;; ebox-node-memo-generation-tests.el --- Node memo ownership -*- lexical-binding: t; -*-
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'ert)
|
||||
(setq load-prefer-newer t)
|
||||
(load-file (expand-file-name "../ebox.el"
|
||||
(file-name-directory load-file-name)))
|
||||
|
||||
(defconst ebox-node-memo-test--keys
|
||||
'(:render-signature-cache :viewport-height-dependent-subtree-cache
|
||||
:flex-content-min-widths))
|
||||
|
||||
(defun ebox-node-memo-test--input (map)
|
||||
"Build an ordinary responsive Flex/Grid tree with nested scroll and MAP."
|
||||
(ebox-build
|
||||
`(column :width (vw 100)
|
||||
(text "Header")
|
||||
(column :id "body" :width stretch :height (vh 100)
|
||||
:overflow scroll :padding ((lh 0) (px 2))
|
||||
,@(cl-loop
|
||||
for index below 6
|
||||
collect
|
||||
`(flex :width stretch :align-items stretch
|
||||
;; Visible overflow exercises content-based automatic minima,
|
||||
;; populating the intrinsic-width memo this fixture audits.
|
||||
(box :flex-grow 1 :flex-basis (px 40) :wrap-mode char :overflow visible
|
||||
(text ,(propertize
|
||||
(format "Open %02d %s" index (make-string 48 ?x))
|
||||
'keymap map 'mouse-face 'highlight 'help-echo "Open row")))
|
||||
(grid :width (px 40) :grid-template-columns ((fr 1))
|
||||
(text "Tag")))))
|
||||
(text "Footer"))))
|
||||
|
||||
(defun ebox-node-memo-test--counts (state)
|
||||
"Return total/current/stale key counts for the node memos in STATE."
|
||||
(let ((current (make-hash-table :test 'eq)))
|
||||
(ebox-runtime-index-map (lambda (_ node) (puthash node t current))
|
||||
(plist-get state :node-table))
|
||||
(mapcar
|
||||
(lambda (key)
|
||||
(let ((memo (plist-get state key)) (live 0) (stale 0))
|
||||
(should (hash-table-p memo))
|
||||
(should (eq (hash-table-test memo) 'eq))
|
||||
(maphash (lambda (node _value)
|
||||
(if (gethash node current) (cl-incf live) (cl-incf stale)))
|
||||
memo)
|
||||
(list key :total (hash-table-count memo) :current live :stale stale)))
|
||||
ebox-node-memo-test--keys)))
|
||||
|
||||
(defmacro ebox-node-memo-test--with-mounted (&rest body)
|
||||
"Run BODY with a pure responsive fixture in BUFFER and INPUT."
|
||||
(declare (indent 0))
|
||||
`(let* ((buffer (generate-new-buffer " *ebox-node-memo-boundary*"))
|
||||
(map (make-sparse-keymap))
|
||||
(_key (define-key map [mouse-1] #'ignore))
|
||||
(input (ebox-node-memo-test--input map))
|
||||
(ebox-viewport-width 180) (ebox-viewport-height 6)
|
||||
(ebox-runtime-idle-prewarm nil)
|
||||
(ebox-runtime-idle-reflow-cache-prewarm nil))
|
||||
(unwind-protect
|
||||
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
|
||||
(lambda () nil)))
|
||||
(ebox-render-to-buffer buffer input)
|
||||
,@body)
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(defun ebox-node-memo-test--resize (count axes)
|
||||
"Repeat COUNT real viewport changes on AXES and check generation ownership."
|
||||
(let* ((buffer (generate-new-buffer " *ebox-node-memo*"))
|
||||
(map (make-sparse-keymap))
|
||||
(_key (define-key map [mouse-1] #'ignore))
|
||||
(input (ebox-node-memo-test--input map))
|
||||
(host (ebox-canonical-input-root-host-ref input))
|
||||
(ebox-viewport-width 180)
|
||||
(ebox-viewport-height 6)
|
||||
(ebox-runtime-idle-prewarm nil)
|
||||
(ebox-runtime-idle-reflow-cache-prewarm nil)
|
||||
history)
|
||||
(unwind-protect
|
||||
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
|
||||
(lambda () nil)))
|
||||
(ebox-render-to-buffer buffer input)
|
||||
(let* ((initial-state (ebox--buffer-render-state buffer))
|
||||
(initial-text (with-current-buffer buffer (buffer-string)))
|
||||
(objects (copy-hash-table
|
||||
(plist-get initial-state :surface-node-object-table)))
|
||||
(node-count (ebox-runtime-index-size (plist-get initial-state :node-table))))
|
||||
(should (ebox-host-ref-position buffer host))
|
||||
(push (cons 0 (ebox-node-memo-test--counts initial-state)) history)
|
||||
;; A later cache hit may legitimately leave a node memo empty;
|
||||
;; require real population at mount, not a forced fill each turn.
|
||||
(dolist (entry (cdar history))
|
||||
(should (> (plist-get (cdr entry) :current) 0)))
|
||||
(dotimes (iteration count)
|
||||
(let* ((alternate (= (% iteration 2) 0))
|
||||
(width (if (and alternate (memq axes '(width both))) 220 180))
|
||||
(height (if (and alternate (memq axes '(height both))) 9 6)))
|
||||
(ebox-rerender-buffer-with-context buffer width height)
|
||||
(let ((state (ebox--buffer-render-state buffer)))
|
||||
(should (eq (plist-get (ebox-buffer-update-report buffer) :viewport-axes)
|
||||
axes))
|
||||
(should (= node-count (ebox-runtime-index-size (plist-get state :node-table))))
|
||||
(should (ebox-host-ref-position buffer host))
|
||||
(maphash
|
||||
(lambda (id object)
|
||||
(should (eq object
|
||||
(gethash id (plist-get state :surface-node-object-table)))))
|
||||
objects)
|
||||
(push (cons (1+ iteration) (ebox-node-memo-test--counts state)) history))))
|
||||
;; The sequence ends at A. Keep the public text/property and Host
|
||||
;; contracts independent of the memo-size assertion below.
|
||||
(should (equal-including-properties
|
||||
initial-text (with-current-buffer buffer (buffer-string))))
|
||||
(with-current-buffer buffer
|
||||
(let* ((pos (string-match "Open 00" (buffer-string)))
|
||||
(output (buffer-string)))
|
||||
(should pos)
|
||||
(should (eq (lookup-key (get-text-property pos 'keymap output)
|
||||
[mouse-1]) #'ignore))
|
||||
(should (equal (get-text-property pos 'help-echo output) "Open row"))))
|
||||
(setq history (nreverse history))
|
||||
(ert-info ((format "axes=%S nodes=%d history=%S" axes node-count history))
|
||||
(dolist (generation history)
|
||||
(dolist (entry (cdr generation))
|
||||
(should (= (plist-get (cdr entry) :stale) 0))
|
||||
(should (<= (plist-get (cdr entry) :total) node-count)))))))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest ebox-node-memo-generation-width-eight-resizes ()
|
||||
"Eight width changes must not retain preceding generations' node keys."
|
||||
(ebox-node-memo-test--resize 8 'width))
|
||||
|
||||
(ert-deftest ebox-node-memo-generation-height-eight-resizes ()
|
||||
"Eight height changes must not retain preceding generations' node keys."
|
||||
(ebox-node-memo-test--resize 8 'height))
|
||||
|
||||
(ert-deftest ebox-node-memo-generation-both-twenty-four-resizes ()
|
||||
"Twenty-four two-axis changes keep memo ownership bounded to live nodes."
|
||||
(ebox-node-memo-test--resize 24 'both))
|
||||
|
||||
(ert-deftest ebox-node-memo-generation-scroll-signatures-match-current-body ()
|
||||
"Height changes after a real scroll retain only correct recursive facts."
|
||||
(ebox-node-memo-test--with-mounted
|
||||
(ebox-rerender-buffer-with-context buffer 180 3)
|
||||
(let* ((state (ebox--buffer-render-state buffer))
|
||||
(id (car (plist-get state :scroll-region-ids))))
|
||||
(should (= (ebox--surface-scroll-region-by buffer id 1 nil) 1))
|
||||
(setq state (ebox--buffer-render-state buffer))
|
||||
(should (= (plist-get (gethash id (plist-get state :scroll-state-table))
|
||||
:scroll-offset) 1))
|
||||
;; The memoizer accepts every current node, including a recursive root.
|
||||
;; Populate a valid old fact through its real computation, not a mock.
|
||||
(let ((ebox--render-cache-signature-cache
|
||||
(plist-get state :render-signature-cache)))
|
||||
(ebox--render-cache-node-body-signature (plist-get state :root-node)))
|
||||
(ebox-rerender-buffer-with-context buffer 180 20)
|
||||
(setq state (ebox--buffer-render-state buffer))
|
||||
(let ((ebox--render-cache-signature-cache nil))
|
||||
(maphash
|
||||
(lambda (node cached)
|
||||
(ert-info ((format "Current node %S, scroll-offset=%S"
|
||||
(plist-get node :node-id) (plist-get node :scroll-offset)))
|
||||
(should (equal cached (ebox--render-cache-node-body-signature node)))))
|
||||
(plist-get state :render-signature-cache))))))
|
||||
|
||||
(ert-deftest ebox-node-memo-generation-retains-canonical-region-index ()
|
||||
"Resize keeps offscreen regions and canonical owners across real cache hits."
|
||||
(let* ((buffer (generate-new-buffer " *ebox-node-memo-regions*"))
|
||||
(ebox-viewport-width 180) (ebox-viewport-height 6)
|
||||
(ebox-runtime-idle-prewarm nil)
|
||||
(ebox-runtime-idle-reflow-cache-prewarm nil)
|
||||
(input
|
||||
(ebox-build
|
||||
`(column :width (vw 100)
|
||||
(box :id "fixed" :width (px 80) (box (text "Cached child")))
|
||||
(flex :width stretch
|
||||
(box :flex-grow 1 :flex-basis (px 40) (text "Allocated")))
|
||||
(column :width stretch :height (lh 2) :overflow scroll
|
||||
,@(cl-loop for index below 64 collect
|
||||
`(box :height (lh 1) (text ,(format "Row %02d" index))))))))
|
||||
(probe (symbol-function 'ebox--render-cache-probe))
|
||||
fixed-node-id cache-hits)
|
||||
(unwind-protect
|
||||
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
|
||||
(lambda () nil)))
|
||||
(ebox-render-to-buffer buffer input)
|
||||
(setq fixed-node-id
|
||||
(plist-get (car (ebox-selector-query-buffer buffer "#fixed"))
|
||||
:node-id))
|
||||
(should fixed-node-id)
|
||||
(cl-letf (((symbol-function 'ebox--render-cache-probe)
|
||||
(lambda (node &rest args)
|
||||
(let ((result (apply probe node args)))
|
||||
(when (and (equal (plist-get node :node-id) fixed-node-id)
|
||||
(plist-get result :rendered))
|
||||
(push t cache-hits))
|
||||
result))))
|
||||
(dolist (width '(220 180))
|
||||
(ebox-rerender-buffer-with-context buffer width 6)
|
||||
(let* ((state (ebox--buffer-render-state buffer))
|
||||
(regions (plist-get state :region-box-table))
|
||||
(ids (plist-get state :region-id-set))
|
||||
(nodes (plist-get state :node-table))
|
||||
(scroll (gethash (car (plist-get state :scroll-region-ids))
|
||||
(plist-get state :scroll-state-table))))
|
||||
(should scroll)
|
||||
(should-not (plist-get scroll :content-lines-complete-p))
|
||||
(should-not (with-current-buffer buffer
|
||||
(string-match-p "Row 63" (buffer-string))))
|
||||
(should (= (hash-table-count regions) (hash-table-count ids)))
|
||||
(maphash
|
||||
(lambda (id _)
|
||||
(let ((box (gethash id regions)))
|
||||
(should box)
|
||||
(should (eq box (ebox-runtime-index-get (plist-get box :node-id) nodes)))))
|
||||
ids))))
|
||||
(should cache-hits))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest ebox-node-memo-generation-seed-preserves-nil-and-zero ()
|
||||
"Seeding reads the exact current source key, including cached nil or zero."
|
||||
(dolist (value '(nil 0))
|
||||
(let* ((source (list :node-id 1))
|
||||
(orphan (list :node-id 1))
|
||||
(candidate (list :node-id 1))
|
||||
(old-nodes (make-hash-table :test 'eql))
|
||||
(new-nodes (make-hash-table :test 'eql))
|
||||
(memo (make-hash-table :test 'eq))
|
||||
(missing (make-symbol "missing")))
|
||||
(puthash 1 source old-nodes)
|
||||
(puthash 1 candidate new-nodes)
|
||||
(puthash source value memo)
|
||||
(puthash orphan 'poison memo)
|
||||
(let ((seeded (ebox-incremental--seed-candidate-node-cache
|
||||
memo old-nodes new-nodes)))
|
||||
(should (= (hash-table-count seeded) 1))
|
||||
(should (eq (gethash candidate seeded missing) value))
|
||||
(should (eq (gethash orphan seeded missing) missing)))
|
||||
(remhash source memo)
|
||||
(should (= (hash-table-count
|
||||
(ebox-incremental--seed-candidate-node-cache
|
||||
memo old-nodes new-nodes)) 0)))))
|
||||
|
||||
(ert-deftest ebox-node-memo-generation-isolates-persistent-caches-only ()
|
||||
"Viewport cache isolation leaves node memo policy to the source-copy boundary."
|
||||
(let* ((signature (make-hash-table :test 'eq))
|
||||
(height (make-hash-table :test 'eq))
|
||||
(width (make-hash-table :test 'eq))
|
||||
(render (make-hash-table :test 'equal))
|
||||
(fragments (make-hash-table :test 'equal))
|
||||
(overrides (list :render-signature-cache signature
|
||||
:viewport-height-dependent-subtree-cache height
|
||||
:flex-content-min-widths width :render-cache render
|
||||
:layout-fragments fragments))
|
||||
(first (ebox-surface--isolated-viewport-overrides overrides))
|
||||
(second (ebox-surface--isolated-viewport-overrides overrides)))
|
||||
(dolist (key '(:render-cache :layout-fragments))
|
||||
(should-not (eq (plist-get first key) (plist-get overrides key)))
|
||||
(should-not (eq (plist-get second key) (plist-get first key))))
|
||||
(dolist (key ebox-node-memo-test--keys)
|
||||
(should (eq (plist-get first key) (plist-get overrides key)))
|
||||
(should (eq (plist-get second key) (plist-get overrides key))))))
|
||||
|
||||
(ert-deftest ebox-node-memo-generation-does-not-copy-historical-memo-keys ()
|
||||
"Thirty thousand same-ID orphans per memo do not enter viewport table copies."
|
||||
(ebox-node-memo-test--with-mounted
|
||||
(let* ((state (ebox--buffer-render-state buffer))
|
||||
(id (plist-get (plist-get state :root-node) :node-id))
|
||||
(memos (mapcar (lambda (key) (plist-get state key)) ebox-node-memo-test--keys))
|
||||
(initial (with-current-buffer buffer (buffer-string)))
|
||||
(copy-function (symbol-function 'copy-hash-table))
|
||||
copied-memo-sizes)
|
||||
(dolist (memo memos)
|
||||
(dotimes (index 30000)
|
||||
(puthash (list :node-id id :orphan index) 'poison memo)))
|
||||
(let ((sizes (mapcar #'hash-table-count memos)))
|
||||
(cl-letf (((symbol-function 'copy-hash-table)
|
||||
(lambda (table)
|
||||
(when (memq table memos)
|
||||
(push (hash-table-count table) copied-memo-sizes))
|
||||
(funcall copy-function table))))
|
||||
(ebox-rerender-buffer-with-context buffer 220 9)
|
||||
(ebox-rerender-buffer-with-context buffer 180 6))
|
||||
(should-not copied-memo-sizes)
|
||||
(should (equal sizes (mapcar #'hash-table-count memos))))
|
||||
(should (equal-including-properties
|
||||
initial (with-current-buffer buffer (buffer-string))))
|
||||
(dolist (entry (ebox-node-memo-test--counts (ebox--buffer-render-state buffer)))
|
||||
(should (= (plist-get (cdr entry) :stale) 0))
|
||||
(should (<= (plist-get (cdr entry) :total) 34))))))
|
||||
|
||||
(ert-deftest ebox-node-memo-generation-context-miss-starts-fresh ()
|
||||
"Display and stylesheet proof misses get fresh node memos before rendering."
|
||||
(dolist (change '(display style))
|
||||
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
|
||||
(ebox-style-add-rule "#body" '(:background-color "#ddeeff") :layer 'base)
|
||||
(ebox-node-memo-test--with-mounted
|
||||
(let ((render (symbol-function 'ebox-surface--render-candidate))
|
||||
(display (symbol-function 'ebox--display-signature-for-window))
|
||||
(seen nil))
|
||||
(when (eq change 'style)
|
||||
(ebox-style-add-rule "#body" '(:background-color "#112233") :layer 'base))
|
||||
(cl-letf (((symbol-function 'ebox--display-signature-for-window)
|
||||
(lambda (window)
|
||||
(if (eq change 'display) '(ebox-node-memo-display changed)
|
||||
(funcall display window))))
|
||||
((symbol-function 'ebox-incremental--layout-owner-plan)
|
||||
(lambda (&rest _)
|
||||
(ert-fail "Changed context must not probe old local geometry")))
|
||||
((symbol-function 'ebox-surface--render-candidate)
|
||||
(lambda (state)
|
||||
(push t seen)
|
||||
(dolist (key ebox-node-memo-test--keys)
|
||||
(should (= (hash-table-count (plist-get state key)) 0)))
|
||||
(funcall render state))))
|
||||
(ebox-rerender-buffer-with-context buffer 220 9))
|
||||
(should seen)
|
||||
(should-not (plist-get (ebox-buffer-update-report buffer) :projection-kind))
|
||||
(let* ((state (ebox--buffer-render-state buffer))
|
||||
(ebox-viewport-width 220) (ebox-viewport-height 9)
|
||||
(fresh-input (ebox-canonical-input-create
|
||||
(list (plist-get state :root-node))
|
||||
(plist-get state :source-index))))
|
||||
(should (equal-including-properties
|
||||
(with-current-buffer buffer (buffer-string)) (ebox-render fresh-input)))
|
||||
(dolist (entry (ebox-node-memo-test--counts state))
|
||||
(should (= (plist-get (cdr entry) :stale) 0)))))))))
|
||||
|
||||
(ert-deftest ebox-node-memo-generation-failed-producers-own-private-tables ()
|
||||
"Late failures preserve source memo entries; every retry gets private tables."
|
||||
(ebox-node-memo-test--with-mounted
|
||||
(let* ((old (ebox--buffer-render-state buffer))
|
||||
(surface (plist-get old :surface))
|
||||
(revision (tp-surface-revision surface))
|
||||
(initial (with-current-buffer buffer (buffer-string)))
|
||||
(memos (mapcar (lambda (key) (plist-get old key)) ebox-node-memo-test--keys))
|
||||
(snapshots (mapcar (lambda (memo)
|
||||
(let ((copy (copy-hash-table memo)))
|
||||
(maphash (lambda (node value)
|
||||
(puthash node (copy-tree value t) copy)) memo)
|
||||
copy)) memos))
|
||||
(render (symbol-function 'ebox-surface--render-candidate))
|
||||
produced-tables)
|
||||
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
|
||||
(lambda (state)
|
||||
(dolist (key ebox-node-memo-test--keys)
|
||||
(should (= (hash-table-count (plist-get state key)) 0)))
|
||||
(let ((tables (mapcar (lambda (key) (plist-get state key))
|
||||
ebox-node-memo-test--keys)))
|
||||
(dolist (previous (cons memos produced-tables))
|
||||
(cl-mapc (lambda (a b) (should-not (eq a b))) tables previous))
|
||||
(push tables produced-tables))
|
||||
(let ((output (funcall render state)))
|
||||
(dolist (entry (ebox-node-memo-test--counts state))
|
||||
(should (= (plist-get (cdr entry) :stale) 0)))
|
||||
output))))
|
||||
(dotimes (_ 2)
|
||||
(let ((tp--surface-publication-step-function
|
||||
(lambda (step _surface)
|
||||
(when (eq step 'client-state) (error "Reject memo candidate")))))
|
||||
(should-error (ebox-rerender-buffer-with-context buffer 220 9)))
|
||||
(should (eq old (ebox--buffer-render-state buffer)))
|
||||
(should (= revision (tp-surface-revision surface)))
|
||||
(should (equal-including-properties
|
||||
initial (with-current-buffer buffer (buffer-string)))))
|
||||
(ebox-rerender-buffer-with-context buffer 220 9))
|
||||
(should (= (length produced-tables) 3))
|
||||
(cl-mapc (lambda (memo before)
|
||||
(should (= (hash-table-count memo) (hash-table-count before)))
|
||||
(maphash (lambda (node value)
|
||||
(should (equal-including-properties value (gethash node memo))))
|
||||
before)) memos snapshots)
|
||||
(should (ebox-host-ref-position buffer (ebox-canonical-input-root-host-ref input))))))
|
||||
|
||||
(ert-deftest ebox-node-memo-generation-same-fallback-producer-owns-fresh-memos ()
|
||||
"Repeated evaluation of one fallback closure must not write captured memos."
|
||||
(ebox-node-memo-test--with-mounted
|
||||
(let* ((old (ebox--buffer-render-state buffer))
|
||||
(display '(ebox-node-memo-display fallback))
|
||||
;; Prepare once. A changed display requires the ordinary fallback.
|
||||
(prepared (ebox-incremental-prepare-viewport-commit
|
||||
buffer 220 9 'both display t))
|
||||
(overrides (plist-get prepared :state-overrides))
|
||||
(source-memos (mapcar (lambda (key) (plist-get overrides key))
|
||||
ebox-node-memo-test--keys))
|
||||
(sizes (mapcar #'hash-table-count source-memos))
|
||||
(root (plist-get prepared :root))
|
||||
;; Both public materializations below invoke this exact closure.
|
||||
(producer (ebox-surface-producer root old t overrides))
|
||||
(render (symbol-function 'ebox-surface--render-candidate))
|
||||
states history outputs first-memos first-snapshots)
|
||||
(should-not (plist-get prepared :projection-kind))
|
||||
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
|
||||
(lambda (state)
|
||||
(let ((output (funcall render state)))
|
||||
(push state states)
|
||||
(push (ebox-node-memo-test--counts state) history)
|
||||
(unless first-memos
|
||||
(setq first-memos
|
||||
(mapcar (lambda (key) (plist-get state key))
|
||||
ebox-node-memo-test--keys)
|
||||
first-snapshots
|
||||
(mapcar (lambda (memo)
|
||||
(let ((copy (copy-hash-table memo)))
|
||||
(maphash
|
||||
(lambda (node value)
|
||||
(puthash node (copy-tree value t) copy))
|
||||
memo)
|
||||
copy)) first-memos)))
|
||||
output))))
|
||||
(dotimes (_ 2)
|
||||
(push (tp-surface-materialize-string producer) outputs)))
|
||||
(should (= (length states) 2))
|
||||
(should-not (eq (plist-get (car states) :root-node)
|
||||
(plist-get (cadr states) :root-node)))
|
||||
(should (equal-including-properties (car outputs) (cadr outputs)))
|
||||
(let ((reference
|
||||
(tp-surface-materialize-string
|
||||
(ebox-surface-producer
|
||||
root old t
|
||||
(list :viewport-width 220 :viewport-height 9
|
||||
:display-signature display
|
||||
:source-base-index (plist-get old :source-index))))))
|
||||
(should (equal-including-properties (car outputs) reference)))
|
||||
(let* ((output (car outputs))
|
||||
(position (string-match "Open 00" output)))
|
||||
(should position)
|
||||
(should (eq (lookup-key (get-text-property position 'keymap output)
|
||||
[mouse-1]) #'ignore)))
|
||||
(ert-info ((format "same closure, captured sizes %S -> %S; history %S"
|
||||
sizes (mapcar #'hash-table-count source-memos) history))
|
||||
(should (equal sizes (mapcar #'hash-table-count source-memos)))
|
||||
(dolist (key ebox-node-memo-test--keys)
|
||||
(should-not (eq (plist-get (car states) key) (plist-get overrides key)))
|
||||
(should-not (eq (plist-get (car states) key) (plist-get (cadr states) key))))
|
||||
(cl-mapc
|
||||
(lambda (memo before)
|
||||
(should (= (hash-table-count memo) (hash-table-count before)))
|
||||
(maphash (lambda (node value)
|
||||
(should (equal-including-properties value (gethash node memo))))
|
||||
before)) first-memos first-snapshots)
|
||||
(dolist (generation history)
|
||||
(dolist (entry generation)
|
||||
(should (= (plist-get (cdr entry) :stale) 0))))))))
|
||||
|
||||
(provide 'ebox-node-memo-generation-tests)
|
||||
;;; ebox-node-memo-generation-tests.el ends here
|
||||
@ -11,6 +11,7 @@
|
||||
|
||||
(defconst ebox-test--autoload-source-files
|
||||
'("ebox.el"
|
||||
"ebox-interaction.el"
|
||||
"ebox-layout-config.el"
|
||||
"ebox-node-factory.el"
|
||||
"ebox-measure.el"
|
||||
@ -30,6 +31,30 @@
|
||||
(insert "\n"))
|
||||
(buffer-string)))
|
||||
|
||||
(defun ebox-test--production-lisp-text ()
|
||||
"Return all package-root Ebox Lisp production source as text."
|
||||
(with-temp-buffer
|
||||
(dolist (file (directory-files ebox-test--root t "\\`ebox.*\\.el\\'"))
|
||||
(insert-file-contents file)
|
||||
(insert "\n"))
|
||||
(buffer-string)))
|
||||
|
||||
(ert-deftest ebox-package-c1b-is-v2-only ()
|
||||
"Ebox 3 requires structured TP and contains no executable v1 bridge."
|
||||
(let ((facade
|
||||
(with-temp-buffer
|
||||
(insert-file-contents (expand-file-name "ebox.el" ebox-test--root))
|
||||
(buffer-string)))
|
||||
(source (ebox-test--production-lisp-text)))
|
||||
(should (string-match-p "Version: 3.0.0" facade))
|
||||
(should (string-match-p (regexp-quote "(tp \"2.0.0\")") facade))
|
||||
(should-not (string-match-p "ebox-transaction-participant-route" source))
|
||||
(should-not (string-match-p "(tp-transaction-participate\\_>" source))
|
||||
(should (string-match-p "(tp-transaction-participate-v2\\_>" source))
|
||||
(should (equal ebox-framework-spi-required-tp-version "2.0.0"))
|
||||
(should (memq tp-transaction-protocol
|
||||
ebox-framework-spi-supported-tp-protocols))))
|
||||
|
||||
(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))
|
||||
@ -66,9 +91,8 @@
|
||||
(ert-deftest ebox-canonical-module-loads-typed-port-without-facade ()
|
||||
"The canonical autoload boundary should not depend on loading `ebox.el'."
|
||||
(let* ((emacs (expand-file-name invocation-name invocation-directory))
|
||||
(parent (file-name-directory (directory-file-name ebox-test--root)))
|
||||
(ecss (expand-file-name "ecss" parent))
|
||||
(tp (expand-file-name "tp" parent)))
|
||||
(ecss (file-name-directory (locate-library "ecss")))
|
||||
(tp (file-name-directory (locate-library "tp"))))
|
||||
(with-temp-buffer
|
||||
(should
|
||||
(zerop
|
||||
@ -103,9 +127,8 @@
|
||||
(ert-deftest ebox-native-command-autoloads-may-target-the-facade ()
|
||||
"Startup registries may safely autoload native commands from `ebox'."
|
||||
(let* ((emacs (expand-file-name invocation-name invocation-directory))
|
||||
(parent (file-name-directory (directory-file-name ebox-test--root)))
|
||||
(ecss (expand-file-name "ecss" parent))
|
||||
(tp (expand-file-name "tp" parent)))
|
||||
(ecss (file-name-directory (locate-library "ecss")))
|
||||
(tp (file-name-directory (locate-library "tp"))))
|
||||
(with-temp-buffer
|
||||
(should
|
||||
(zerop
|
||||
@ -132,13 +155,15 @@
|
||||
(should (commandp 'ebox-byte-compile))
|
||||
(should
|
||||
(equal ebox--compile-sources
|
||||
'("ebox-cache.el" "ebox-source.el" "ebox-state-contract.el"
|
||||
"ebox-font.el" "ebox-style.el"
|
||||
'("ebox-cache.el" "ebox-interaction.el" "ebox-source.el" "ebox-runtime-index.el"
|
||||
"ebox-state-contract.el"
|
||||
"ebox-font.el" "ebox-size.el" "ebox-style.el"
|
||||
"ebox-layout-config.el"
|
||||
"ebox-node-factory.el"
|
||||
"ebox-child-range.el"
|
||||
"ebox-tree.el" "ebox-measure.el"
|
||||
"ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el"
|
||||
"ebox-fragment.el" "ebox-render-context.el" "ebox-composite.el"
|
||||
"ebox-layer.el" "ebox-layout.el"
|
||||
"ebox-flex.el" "ebox-grid.el" "ebox-canonical.el"
|
||||
"ebox-buffer-backend.el" "ebox-patch-plan.el"
|
||||
"ebox-incremental.el"
|
||||
@ -221,6 +246,8 @@
|
||||
("ebox-byte-compile" . "defun")
|
||||
("ebox-native-build" . "defun")
|
||||
("ebox-native-status" . "defun")
|
||||
("ebox-next-interaction" . "defun")
|
||||
("ebox-previous-interaction" . "defun")
|
||||
("ebox-buffer-mode" . "define-minor-mode")
|
||||
("ebox-render-to-buffer" . "defun")
|
||||
("ebox-display-buffer" . "defun")))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user