diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ab92734..d6aa503 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index d185390..3d9d7b1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,3 +12,5 @@ The old `emacs-box` checkout is a historical full-stack source tree. It is inten
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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a5cbe3..3144e3d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,22 @@ All notable changes to Ebox are documented here.
### Added
-- Direct dependencies on the independent ECSS 0.1.0 cascade engine and TP 1.0.1 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.
@@ -15,12 +30,34 @@ All notable changes to Ebox are documented here.
### 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.
-- TP 1.0.1 is the minimum consumer-first dependency. Ebox accepts its structured
- transition protocol and TP 2.0.0's final v2-only protocol, while always using
- the public structured participant API.
+- 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.
@@ -44,10 +81,31 @@ All notable changes to Ebox are documented here.
### Migration
-- Install ECSS 0.1.0 and TP 1.0.1 or newer, in either order, before Ebox 3.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 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.
diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md
index fd91e7b..2ddcb67 100644
--- a/CHANGELOG.zh-CN.md
+++ b/CHANGELOG.zh-CN.md
@@ -6,7 +6,18 @@ Ebox 的重要变化记录在这里。
### 新增
-- 直接依赖独立的 ECSS 0.1.0 cascade engine 与 TP 1.0.1 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 的投影;子盒子覆盖背景色时仍能保持文字可读。
@@ -15,10 +26,24 @@ Ebox 的重要变化记录在这里。
### 变更
+- `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 不会再被误接受。
-- TP 1.0.1 是 consumer-first 迁移的最低依赖。Ebox 接受其 structured 过渡协议与
- TP 2.0.0 的最终 v2-only 协议,但始终只调用公开 structured participant API。
+- 早期 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。
@@ -41,10 +66,26 @@ Ebox 的重要变化记录在这里。
### 迁移
-- ECSS 0.1.0 与 TP 1.0.1 或更高版本互相独立,安装顺序任意;两者都安装后再安装
+- 将旧几何字面量改为显式单位:例如 `: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 3.0 重新 mount 声明式 source。
+
+### 发布状态与支持边界
+
+- `3.0.0` 仍未发布;这些修改准备源码分发,不代表已上传包归档或发布版本 tag。
+- 每个已挂载 buffer 拥有一套视口布局,需要独立宽度的视图使用不同 buffer。纵向
+ 几何仍以完整行为单位。
+- 保留常见 UI 文本簇,但完整 Unicode 字素分段、浏览器级双向布局、层叠和绝对定位
+ 不属于当前契约。
diff --git a/DESIGN.md b/DESIGN.md
index 99b2387..51d1f38 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -30,9 +30,42 @@ 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 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`. Emacs faces and text properties belong to the final adapter and are not Ebox author properties. CSS compatibility is intentionally partial: 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
diff --git a/DESIGN.zh.md b/DESIGN.zh.md
index e0deca0..4cf98da 100644
--- a/DESIGN.zh.md
+++ b/DESIGN.zh.md
@@ -30,9 +30,34 @@ 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 支持显式 `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 与文本属性只属于最终 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` 依赖,
+也不引入层叠或重叠组合。
## 尺寸组合
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ 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.
+
+
+ Copyright (C)
+
+ 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 .
+
+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:
+
+ Copyright (C)
+ 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
+.
+
+ 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
+.
diff --git a/Makefile b/Makefile
index 5f2bc0c..c841540 100644
--- a/Makefile
+++ b/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
@@ -14,6 +19,7 @@ NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release
.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
@@ -22,6 +28,29 @@ interaction-tests:
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
diff --git a/README.md b/README.md
index 302e29b..24d9dae 100644
--- a/README.md
+++ b/README.md
@@ -7,10 +7,20 @@ application also needs Components, reactive state, behaviors, or lifecycle.
## Install
-Ebox requires Emacs 29.1 or newer, ECSS 0.1.0 or newer, and TP 1.0.1 or newer.
-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")
@@ -21,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`,
@@ -93,8 +151,10 @@ 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; application state,
-focus navigation, and command behavior remain the author's responsibility.
+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).
@@ -102,6 +162,8 @@ and the Playground's [interaction lab](../ebox-playground/README.md#native-inter
- `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.
@@ -120,11 +182,11 @@ 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's public structured
-participant API. TP 1.0.1 supports the consumer-first migration protocol;
-TP 2.0.0 publishes the final v2-only protocol. Missing or malformed structured
-participant capability stops Ebox loading instead of selecting a compatibility
-writer.
+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
@@ -136,6 +198,31 @@ 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
@@ -151,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.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index b2054d5..1f6871c 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -6,9 +6,18 @@ Component、响应式状态、behavior 或生命周期时,使用同级 ETAF
## 安装
-Ebox 需要 Emacs 29.1 或更高版本、ECSS 0.1.0 或更高版本,以及 TP 1.0.1 或
-更高版本。包管理器应自动安装声明的依赖。使用同级源码 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")
@@ -19,6 +28,48 @@ Ebox 需要 Emacs 29.1 或更高版本、ECSS 0.1.0 或更高版本,以及 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`、
@@ -80,7 +131,9 @@ padding 和 border,不包含该 Box 的 margin 和结构性换行符。悬停
使用 `ebox-keymap-create` 将普通零参数回调绑定到激活键或自定义按键;鼠标命令会
自动在被点击窗口的 buffer 中运行。`ebox-region-update` 可以使用当前已挂载 buffer
中的语义 ID,或显式 region handle,替换或清除节点能力。原生 keymap 可用于
-独立的 Ebox 交互;应用状态、焦点导航和命令行为由作者管理。参见
+独立的 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)。
@@ -88,6 +141,7 @@ padding 和 border,不包含该 Box 的 margin 和结构性换行符。悬停
- `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 变化。
@@ -103,8 +157,8 @@ Ebox 会在分配 runtime identity 前复制 canonical input,因此同一个 b
卸载后查询会报错。识别提交版本时同时比较 mount ID 与 revision,避免重新挂载后
revision 从头计数导致混淆。
-Ebox 只通过 TP 的公开 structured participant API 注册可回滚状态。TP 1.0.1
-支持 consumer-first 迁移协议;TP 2.0.0 发布最终的 v2-only 协议。structured
+Ebox 只通过 TP 2.0.0 的公开 structured participant API 与最终 v2 协议注册可回滚状态。
+旧的过渡版本不具备本版本要求的原生属性所有权与 hover identity 保证。structured
participant capability 缺失或格式错误时,Ebox 会停止加载,不会选择兼容 writer。
## 可选 native 模块
@@ -117,6 +171,25 @@ 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
@@ -132,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)。第三方文件保留各自的
+许可证声明。
diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md
index 9a07feb..de5fa1d 100644
--- a/docs/maintainer/ebox-current-implementation-reference.en.md
+++ b/docs/maintainer/ebox-current-implementation-reference.en.md
@@ -47,6 +47,16 @@ The active contract also covers `Makefile`, `.github/workflows/ci.yml`, `tests/e
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.
@@ -113,7 +123,7 @@ Caller-owned Source Tree
- `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 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 1.0.1 dependency, while `ebox-framework-spi-supported-tp-protocols` lists the consumer-first and final v2 protocol identities. 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-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
@@ -162,6 +172,12 @@ 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
@@ -198,6 +214,70 @@ Run focused tests first, then `make check` after changes to shared rendering, Gr
`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
diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md
index f53b0b8..e87fed5 100644
--- a/docs/maintainer/ebox-current-implementation-reference.zh.md
+++ b/docs/maintainer/ebox-current-implementation-reference.zh.md
@@ -47,6 +47,13 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
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 包。
@@ -111,7 +118,7 @@ CSS 尺寸由 `tests/ebox-size-tests.el`、`tests/ebox-size-style-tests.el`、
- `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 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 1.0.1 依赖,`ebox-framework-spi-supported-tp-protocols` 列出 consumer-first 与最终 v2 protocol identity。initial TP/Ebox timing 在 publication 期间真实测量,以 defensive snapshot 写入 completed report 后再 replay;不生成虚假的零耗时数据,也不携带 mutate authority。Ebox 不创建 selected port 或 consumer bootstrap。
+- `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。
@@ -153,6 +160,12 @@ 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
@@ -189,6 +202,54 @@ make diff-check
`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 作者字面量使用已有的源码感知迁移工具,不另写一套搜索替换脚本。
diff --git a/docs/user/ebox-api-reference.en.md b/docs/user/ebox-api-reference.en.md
index 925d34c..87df6c5 100644
--- a/docs/user/ebox-api-reference.en.md
+++ b/docs/user/ebox-api-reference.en.md
@@ -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)
@@ -71,6 +75,14 @@ another public node or wrapper.
| Text layout | `:text-align`, `:wrap-mode` (`word`, `char`, `kp`, `none`) |
| Overflow | `:overflow` (`visible`, `hidden`, `scroll`) |
+`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);
@@ -169,7 +181,19 @@ 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.
-These capabilities add no Ebox focus navigation or application state system.
+`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.
@@ -271,13 +295,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.
@@ -398,7 +431,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`.
+- 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`,
diff --git a/docs/user/ebox-api-reference.zh.md b/docs/user/ebox-api-reference.zh.md
index eb312b3..8e554a6 100644
--- a/docs/user/ebox-api-reference.zh.md
+++ b/docs/user/ebox-api-reference.zh.md
@@ -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)
@@ -67,6 +70,11 @@ participation property 就是普通子 Box property,不会创建另一种公
| 文本布局 | `:text-align`、`:wrap-mode`(`word`、`char`、`kp`、`none`) |
| Overflow | `:overflow`(`visible`、`hidden`、`scroll`) |
+`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 字符串、裸长度、单元素像素列表及旧的
@@ -146,7 +154,16 @@ Emacs 设置及窗口系统;单独设置指针形状不会绑定命令。
键盘回调在当前 buffer 中运行,由 point 所在位置进行原生分派。
仍支持直接传入原生 keymap;其鼠标命令需要自行从事件取得目标窗口,并在该窗口的
-buffer 中操作。这些能力不引入 Ebox 焦点导航或应用状态系统。
+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
@@ -241,13 +258,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 与最近一次成功报告。
@@ -355,7 +379,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-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`、
diff --git a/docs/user/ebox-user-guide.en.md b/docs/user/ebox-user-guide.en.md
index f1a9989..181e7b5 100644
--- a/docs/user/ebox-user-guide.en.md
+++ b/docs/user/ebox-user-guide.en.md
@@ -237,9 +237,21 @@ 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` currently clips vertical lines; horizontal
-overflow is not pixel-clipped by the buffer backend, so content can extend
-beyond the computed width.
+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
@@ -268,6 +280,36 @@ 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.
+### 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.
+
### Native node interaction
@@ -325,10 +367,25 @@ 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. Raw native keymaps are
-also accepted; their mouse commands must handle the event's target buffer
-themselves. Ebox uses native command dispatch; it does not create application
-state or focus navigation. A hand pointer alone does not bind a click command.
+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 "" #'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
@@ -348,6 +405,21 @@ 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
@@ -420,8 +492,12 @@ not select an unrelated scroll box elsewhere on the page.
"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
diff --git a/docs/user/ebox-user-guide.zh.md b/docs/user/ebox-user-guide.zh.md
index fe0a034..5a28cc8 100644
--- a/docs/user/ebox-user-guide.zh.md
+++ b/docs/user/ebox-user-guide.zh.md
@@ -207,8 +207,16 @@ Box 高度为零;`(box :height (lh 1))` 明确保留一行空白。
中间结果保留小数,例如 853 像素视口的 `33vw` 在显示量化前是 281.49 像素。
当前 buffer 后端按完整行生成 block 几何,小数行目标在这个边界量化。作者接口不
接受纵向 `px` 长度。
-`:overflow hidden` 当前只裁剪纵向行;buffer 后端尚未实现横向像素裁剪,因此内容
-可能超出已经计算确定的容器宽度。
+`: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)`、
@@ -234,6 +242,29 @@ Text 的样式声明只接受字体、前景/背景和文本装饰 property;
用 `:outer inline` 或 `:outer block` 表达 Box 如何参与父布局。子布局算法仍由 form
名称决定。
+### 换行与可选 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、所选字体和窗口系统。
+
### 原生节点交互
@@ -284,9 +315,20 @@ margin 及结构性换行符;悬停遵循上面的规则。嵌套的 Text 或
辅助函数让鼠标回调在事件窗口的 buffer 中运行,因此回调可以直接按语义 ID 更新:
`(ebox-region-update "action" :help-echo "Activated")`。
-使用键盘绑定时,先把 point 移到交互范围内。仍可直接传入原生 keymap,其鼠标命令
-需要自行处理事件的目标 buffer。Ebox 使用原生命令分派,不创建应用状态或焦点导航。
-单独设置手形指针不会绑定点击命令。
+使用键盘绑定时,先把 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 "" #'ebox-previous-interaction)
+```
+
+仍可直接传入原生 keymap,其鼠标命令需要自行处理事件的目标 buffer。Ebox 使用原生
+命令分派,不创建应用状态或焦点管理系统。单独设置手形指针不会绑定点击命令。
Playground 的[交互实验室](../../../ebox-playground/README.zh-CN.md#native-interaction-lab)
把业务函数与状态放在 `interaction-reference.el`,把布局放在
@@ -304,6 +346,18 @@ 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
@@ -367,7 +421,10 @@ Ebox 对 keymap 创建快照;发布新绑定时应替换 `:keymap`,而不是
"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
diff --git a/ebox-grid.el b/ebox-grid.el
index 0be51aa..9b9802f 100644
--- a/ebox-grid.el
+++ b/ebox-grid.el
@@ -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))
diff --git a/ebox-incremental.el b/ebox-incremental.el
index 7d345e2..216b497 100644
--- a/ebox-incremental.el
+++ b/ebox-incremental.el
@@ -49,6 +49,7 @@
(declare-function tp-object-mounts
"tp-surface" (object))
(declare-function ebox-string-lines "ebox" (string))
+(declare-function ebox-lines-join "ebox" (lines))
(declare-function ebox-get "ebox" (box key))
(declare-function ebox--ensure-node-id "ebox" (node))
(declare-function ebox--ensure-region-id "ebox" (box))
diff --git a/ebox-interaction.el b/ebox-interaction.el
index 9744bc1..1c6af51 100644
--- a/ebox-interaction.el
+++ b/ebox-interaction.el
@@ -20,6 +20,10 @@
(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
@@ -103,6 +107,136 @@ This function does not supply focus navigation, state or event propagation."
(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.
diff --git a/ebox-layout.el b/ebox-layout.el
index 8d46503..df56f1f 100644
--- a/ebox-layout.el
+++ b/ebox-layout.el
@@ -18,6 +18,7 @@
(require 'ebox-buffer-backend)
(require 'ebox-runtime-index)
+(declare-function ekp-pixel-justify "ekp" (string line-pixel))
(declare-function ebox--render-grid "ebox-grid" (node))
(declare-function ebox--render-grid-box-children
"ebox-grid" (box props children))
@@ -85,6 +86,9 @@
(defvar ebox--region-id-counter)
(defvar ebox--runtime-node-id-counter)
+(defvar ebox--scroll-global-state)
+(defvar ebox--scroll-idle-prefetch-timers)
+(defvar ebox--smooth-scroll-state-table)
(defvar ebox--region-box-table)
(defvar ebox--render-region-id)
(defvar ebox--defer-scroll-content-index)
@@ -699,6 +703,56 @@ pairs. It is intentionally small; full Unicode segmentation is out of scope."
(when (> width max-width) (setq max-width width))))
(max max-width (ebox--space-pixel-width)))))
+(defun ebox--clip-line (line max-pixel)
+ "Clip LINE to MAX-PIXEL without splitting supported grapheme clusters.
+Retain visible text properties. A partially visible fixed display space
+keeps its properties and uses the remaining width; a glyph that cannot fit
+is omitted as a whole. Any remaining width is neutral box content, never
+an interactive stand-in for an omitted glyph."
+ ;; The prefix-plus-pixel-filler algorithm follows kinney's GPL-3.0-or-later
+ ;; `s-pixel-floor' and `s-pixel-left' (2026), extended here for graphemes,
+ ;; source properties and clipped display spaces without an extra dependency.
+ ;; https://github.com/Kinneyzhang/s-pixel
+ ;; commit a2a0d6ae6b3bd71c1a70084d0ea37f56463e5fe0.
+ (if (<= (ebox--string-pixel-width line) max-pixel)
+ line
+ (let ((position 0)
+ (end (length line))
+ (width 0)
+ clipped)
+ (while (and (< position end) (not clipped))
+ (let* ((display (get-text-property position 'display line))
+ (space-width (ebox--display-space-width display))
+ (next (if (and display (not space-width))
+ ;; A replacement string or image is one display unit.
+ (max (ebox--grapheme-cluster-end line position)
+ (or (next-single-property-change
+ position 'display line end) end))
+ (ebox--grapheme-cluster-end line position)))
+ ;; Measure the complete prefix, preserving the existing font
+ ;; measurement contract when adjacent glyph shaping matters.
+ (next-width (ebox--substring-pixel-width line 0 next))
+ (remaining (- max-pixel width)))
+ (if (<= next-width max-pixel)
+ (setq position next width next-width)
+ (setq clipped
+ (concat
+ (substring line 0 position)
+ (when (> remaining 0)
+ (let ((space (ebox-pixel-space remaining)))
+ (when space-width
+ (add-text-properties
+ 0 (length space)
+ (text-properties-at position line) space)
+ (put-text-property
+ 0 (length space) 'display
+ (cons 'space
+ (plist-put (copy-tree (cdr display))
+ :width (list remaining)))
+ space))
+ space)))))))
+ (or clipped (substring line 0 position)))))
+
(defun ebox--wrap-line (line max-pixel wrap-mode)
"Wrap a single LINE to MAX-PIXEL width, preserving text properties.
WRAP-MODE can be `word', `char', or `kp'.
@@ -803,10 +857,11 @@ When WRAP-MODE is `kp', use Knuth-Plass algorithm via ekp package."
((memq wrap-mode '(nil none))
text)
((eq wrap-mode 'kp)
- (if (and (require 'ekp nil t)
- (fboundp 'ekp-pixel-justify))
- (ekp-pixel-justify text max-pixel)
- (ebox--wrap-text text max-pixel 'word)))
+ (unless (require 'ekp nil t)
+ (error "Ebox :wrap-mode kp requires EKP; install the ekp package"))
+ (unless (fboundp 'ekp-pixel-justify)
+ (error "Ebox :wrap-mode kp requires EKP with ekp-pixel-justify; update EKP"))
+ (ekp-pixel-justify text max-pixel))
(t
(let* ((lines (ebox-string-lines text))
(wrapped (mapcan (lambda (l)
@@ -1630,32 +1685,39 @@ one carrier line preserves their paint without inventing a content line."
(defun ebox--format-content-string (box content)
"Format BOX CONTENT (wrap and justify) returning a string."
- (let ((wrap-mode (ebox-get box :wrap-mode))
- (align (ebox-get box :text-align))
- (known-width
- (gethash content ebox--rendered-uniform-width-table)))
- (if (and (eq align 'left)
- (or (and (ebox-style-no-soft-wrap-p wrap-mode)
- (or (plist-get box :ebox-content-width-exact-p)
- (and (numberp known-width)
- (= known-width
- (ebox--content-pixel box)))))
- (and (memq wrap-mode '(word char))
- (numberp known-width)
- (= known-width (ebox--content-pixel box)))))
- content
- (let* ((pixel-width (ebox--content-pixel box))
- (wrapped-content
- (if (plist-get box :ebox-content-layout-complete-p)
- content
- (ebox--wrap-text content pixel-width wrap-mode))))
- ;; kp mode distributes inner gaps, but Ebox still owns the box geometry.
- ;; Pad ragged lines (notably the last line) back to the content width
- ;; while preserving the requested alignment for those lines.
- (ebox--record-rendered-intrinsic-size
- (ebox--lines-justify wrapped-content pixel-width align)
- (ebox--rendered-intrinsic-size content :width)
- (ebox--rendered-intrinsic-size content :height))))))
+ (let* ((wrap-mode (ebox-get box :wrap-mode))
+ (align (ebox-get box :text-align))
+ (known-width (gethash content ebox--rendered-uniform-width-table))
+ (formatted
+ (if (and (eq align 'left)
+ (or (and (ebox-style-no-soft-wrap-p wrap-mode)
+ (or (plist-get box :ebox-content-width-exact-p)
+ (and (numberp known-width)
+ (= known-width (ebox--content-pixel box)))))
+ (and (memq wrap-mode '(word char))
+ (numberp known-width)
+ (= known-width (ebox--content-pixel box)))))
+ content
+ (let* ((pixel-width (ebox--content-pixel box))
+ (wrapped-content
+ (if (plist-get box :ebox-content-layout-complete-p)
+ content
+ (ebox--wrap-text content pixel-width wrap-mode))))
+ ;; KP distributes inner gaps; Ebox still owns the Box geometry.
+ ;; Pad ragged lines back to the requested content width.
+ (ebox--record-rendered-intrinsic-size
+ (ebox--lines-justify wrapped-content pixel-width align)
+ (ebox--rendered-intrinsic-size content :width)
+ (ebox--rendered-intrinsic-size content :height))))))
+ (if (eq (ebox-get box :overflow) 'hidden)
+ (let ((pixel-width (ebox--content-pixel box)))
+ (ebox--record-rendered-intrinsic-size
+ (ebox-lines-join
+ (mapcar (lambda (line) (ebox--clip-line line pixel-width))
+ (ebox-string-lines formatted)))
+ (and (ebox--rendered-intrinsic-size content :width) pixel-width)
+ (ebox--rendered-intrinsic-size content :height)))
+ formatted)))
(defun ebox--format-content (box)
"Format BOX content (wrap and justify) returning a string."
diff --git a/ebox-measure.el b/ebox-measure.el
index c507087..a5398e7 100644
--- a/ebox-measure.el
+++ b/ebox-measure.el
@@ -14,6 +14,7 @@
(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:
@@ -75,11 +76,7 @@ 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))
- (let ((width (plist-get (cdr display) :width)))
- (cond
- ((numberp width) width)
- ((and (consp width) (numberp (car width))) (car width))))))
+ (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.
@@ -129,17 +126,17 @@ An already integral STRING is returned unchanged."
(width (ebox--fixed-pixel-space-width
(get-text-property position 'display string))))
(when width
- (cl-loop for index from position below next
- do (push index positions) (push width widths)))
+ (push (cons position next) positions)
+ (push width widths))
(setq position next)))
(cl-mapc
- (lambda (index used)
- (let ((display (get-text-property index 'display string)))
+ (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
- index (1+ index) 'display
+ (car range) (cdr range) 'display
(ebox--register-render-owned-text-value 'display updated)
result)))))
(nreverse positions) (ebox-size-quantize (nreverse widths)))
@@ -236,7 +233,7 @@ constant across the selected slice."
;; display-space fractions just as the segmented path does;
;; Emacs's glyph measurement quantizes them before padding.
(width (if space-width
- (* space-width (length sample))
+ 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)))
@@ -268,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.
diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el
index a9c74d4..d489f52 100644
--- a/ebox-native-reflow.el
+++ b/ebox-native-reflow.el
@@ -18,6 +18,9 @@
(require 'ebox-runtime-index)
(require 'ebox-size)
+(declare-function ebox-string-height "ebox" (string))
+(declare-function ebox-surface--retained-property-value-equal-p
+ "ebox-surface" (left right))
(declare-function ebox-native--module-version "ebox_native_reflow" ())
(declare-function ebox-native--module-layout-ready-p "ebox_native_reflow" ())
(declare-function ebox-native--module-create-session "ebox_native_reflow"
@@ -49,6 +52,7 @@
(declare-function ebox-native--module-stats "ebox_native_reflow" (session))
(declare-function ebox-native--module-release "ebox_native_reflow" (session))
(declare-function ebox--grapheme-clusters "ebox-layout" (string))
+(declare-function ebox--grapheme-cluster-end "ebox-layout" (string start))
(declare-function ebox--format-content-string "ebox-layout" (box content))
(declare-function ebox-style-no-soft-wrap-p "ebox-style" (value))
(declare-function ebox-style-soft-wrap-p "ebox-style" (value))
@@ -101,7 +105,7 @@
(defvar ebox-native-reflow--compile-size-node nil
"Owning node whose font measurements resolve fixed native size inputs.")
-(defconst ebox-native-reflow-abi-version "13:7:12"
+(defconst ebox-native-reflow-abi-version "14:7:12"
"Version tuple shared by the native module, layout IR, and render tape.")
(defconst ebox-native-reflow--minimum-rust-version "1.82.0"
@@ -1681,6 +1685,18 @@ copied separately; no pending frame or registered Rust document is inherited."
(clrhash ebox-native-reflow--source-cluster-cache))
(puthash cache-key measured
ebox-native-reflow--source-cluster-cache))
+ ;; One native atom carries one opaque property template. Splitting a
+ ;; cluster at a property-run boundary would turn combining marks or
+ ;; ZWJ tails into independent atoms that clipping/wrapping could lose.
+ ;; Check the last measured cluster against the complete source, also
+ ;; on cache hits; unsupported mixed-property clusters use Elisp layout.
+ (when (and (< next length) (> (length measured) 0)
+ (> (ebox--grapheme-cluster-end
+ line (- next (length (plist-get
+ (aref measured (1- (length measured)))
+ :text))))
+ next))
+ (error "Native reflow cannot split a grapheme across text property runs"))
(dolist (cluster (append measured nil))
(push (append (copy-sequence cluster)
(when template-id
diff --git a/ebox-selector.el b/ebox-selector.el
index d87af76..b3450ea 100644
--- a/ebox-selector.el
+++ b/ebox-selector.el
@@ -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))
diff --git a/ebox-spi.el b/ebox-spi.el
index 699dec5..0df2ce1 100644
--- a/ebox-spi.el
+++ b/ebox-spi.el
@@ -16,7 +16,7 @@
(require 'ebox-surface)
(require 'tp-transaction)
-(defconst ebox-framework-spi-required-tp-version "1.0.1"
+(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
diff --git a/ebox-surface.el b/ebox-surface.el
index f5f2bbc..4cc5d76 100644
--- a/ebox-surface.el
+++ b/ebox-surface.el
@@ -24,6 +24,8 @@
(require 'tp)
(defvar ebox-region-types)
+(defvar ebox--region-id-counter)
+(defvar ebox--runtime-node-id-counter)
(defvar ebox--region-box-table)
(defvar ebox--scroll-global-state)
(defvar ebox--paint-origin-property)
@@ -49,16 +51,24 @@
(defvar ebox-incremental--allocated-slot-proof-cache)
(defvar ebox-incremental--buffer-render-state-override)
(declare-function tp-runtime-manifest "tp" ())
+(declare-function ebox--region-ids-visible-in-buffer-p "ebox" (buffer region-ids))
+(declare-function ebox--scroll-line-region-ids "ebox" (line))
+(declare-function ebox--scroll-build-region-line-span-index "ebox" (lines))
(declare-function tp-transaction-participate-v2 "tp-reactive" (&rest args))
(define-error 'ebox-surface-tp-protocol-error
- "Incompatible TP transaction protocol")
+ "Ebox requires TP 2.0.0 or newer with structured transactions")
(defun ebox-surface--validate-tp-v2-capability (manifest)
"Validate MANIFEST as the required public TP v2 participant capability."
- (let ((api (plist-get manifest :structured-participant-api))
+ (let ((version (plist-get manifest :version))
+ (api (plist-get manifest :structured-participant-api))
(protocol (plist-get manifest :transaction-protocol)))
- (unless (and (memq protocol
+ (unless (and (stringp version)
+ (condition-case nil
+ (not (version< version "2.0.0"))
+ (error nil))
+ (memq protocol
'(tp-transaction-protocol-v1+v2
tp-transaction-protocol-v2))
(eq api 'tp-transaction-participate-v2)
diff --git a/ebox.el b/ebox.el
index 91e3896..2e55d22 100644
--- a/ebox.el
+++ b/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: 3.0.0
-;; Package-Requires: ((emacs "29.1") (ecss "0.1.0") (tp "1.0.1"))
+;; 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:
@@ -4908,11 +4914,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
@@ -4943,6 +4955,8 @@ through dirty-set and patch-set execution before falling back to root rerender."
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
diff --git a/native/Cargo.toml b/native/Cargo.toml
index 58740bf..1517428 100644
--- a/native/Cargo.toml
+++ b/native/Cargo.toml
@@ -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]
diff --git a/native/c/ebox_module.c b/native/c/ebox_module.c
index 45e5501..6823dbf 100644
--- a/native/c/ebox_module.c
+++ b/native/c/ebox_module.c
@@ -164,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[] = "13:7:12";
+ static const char version[] = "14:7:12";
return env->make_string(env, version, (ptrdiff_t) (sizeof version - 1));
}
diff --git a/native/core/Cargo.toml b/native/core/Cargo.toml
index 60e81ad..bb9e6ca 100644
--- a/native/core/Cargo.toml
+++ b/native/core/Cargo.toml
@@ -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]
diff --git a/native/src/layout.rs b/native/src/layout.rs
index 331fb5c..c31e825 100644
--- a/native/src/layout.rs
+++ b/native/src/layout.rs
@@ -2079,6 +2079,35 @@ impl Line {
self
}
+ fn clipped(&self, width: i64) -> Self {
+ if self.width <= width {
+ return line_plan::clone_line(self);
+ }
+ let mut kept = Vec::new();
+ let mut remaining = width.max(0);
+ for atom in self.atoms.to_vec() {
+ if atom.width() <= remaining {
+ remaining -= atom.width();
+ kept.push(atom);
+ } else {
+ if remaining > 0 {
+ // A display space can shrink. An indivisible glyph is
+ // omitted and its replacement blank has no hit ownership.
+ let properties = match atom {
+ Atom::Space { properties, .. } => properties,
+ Atom::Text { .. } => AtomProperties::default(),
+ };
+ kept.push(Atom::Space {
+ width: remaining,
+ properties,
+ });
+ }
+ break;
+ }
+ }
+ Self::from_atoms(&kept)
+ }
+
fn blank(width: i64) -> Self {
Self::blank_with_properties(width, AtomProperties::default())
}
@@ -7648,6 +7677,13 @@ fn render_node_body(
}
}
}
+ if *overflow == Overflow::Hidden && formatted.max_width() > content_width {
+ // Horizontal clipping changes atom positions but not line
+ // origins. Preserve break properties and let the ordinary
+ // owner projection rebuild the affected local metadata.
+ formatted = formatted.map_lines(|_, line| line.clipped(content_width));
+ change = None;
+ }
formatted = project_box_lines(
scope,
BoxProjectionSlot::ContentOwn,
@@ -8739,6 +8775,118 @@ mod tests {
assert!(character_lines.iter().all(|line| line.width <= 3));
}
+ #[test]
+ fn hidden_inline_overflow_clips_before_padding_and_borders() {
+ for overflow_value in [Overflow::Hidden, Overflow::Visible, Overflow::Scroll] {
+ let mut root = text_box(
+ 1,
+ measured_text(vec!["ABCDEFGHIJ"
+ .chars()
+ .map(|ch| cluster(&ch.to_string(), 1, Some(0)))
+ .collect()]),
+ None,
+ );
+ let LayoutNode::Box {
+ width,
+ overflow,
+ padding_left,
+ padding_right,
+ border_left,
+ border_right,
+ ..
+ } = &mut root
+ else {
+ unreachable!()
+ };
+ *width = Size::Pixels { value: 7 };
+ *overflow = overflow_value;
+ *padding_left = 1;
+ *padding_right = 1;
+ *border_left = 1;
+ *border_right = 1;
+ let document = LayoutDocument {
+ version: LAYOUT_VERSION,
+ space_width: 1,
+ style_count: 0,
+ property_template_count: 1,
+ styles: Vec::new(),
+ root,
+ };
+ let tape = document.layout_tape(test_context(), None).unwrap();
+ assert_eq!(
+ tape.lines[0].width,
+ if overflow_value == Overflow::Hidden {
+ 7
+ } else {
+ 14
+ }
+ );
+ let flat = flatten_layout_tape(tape, true).unwrap();
+ let text = flat
+ .characters
+ .iter()
+ .map(|ch| ch.value)
+ .collect::();
+ assert!(text.contains(if overflow_value == Overflow::Hidden {
+ "ABC"
+ } else {
+ "ABCDEFGHIJ"
+ }));
+ if overflow_value == Overflow::Hidden {
+ assert!(!text.contains('D'));
+ }
+ assert!(flat
+ .characters
+ .first()
+ .unwrap()
+ .properties
+ .roles
+ .iter()
+ .any(|role| role.role == RegionRole::BorderLeft));
+ assert!(flat
+ .characters
+ .last()
+ .unwrap()
+ .properties
+ .roles
+ .iter()
+ .any(|role| role.role == RegionRole::BorderRight));
+ }
+ }
+
+ #[test]
+ fn hidden_inline_overflow_keeps_clusters_and_partial_spaces() {
+ let source =
+ Line::from_clusters(&[cluster("e\u{301}", 2, Some(0)), cluster("👨👩👧👦", 4, Some(1))]);
+ let clipped = source.clipped(3);
+ assert_eq!(clipped.width, 3);
+ let atoms = clipped.atoms.to_vec();
+ assert_eq!(atoms.len(), 2);
+ assert!(matches!(&atoms[0], Atom::Text { text, .. } if text == "e\u{301}"));
+ assert_eq!(atoms[0].properties().property_template_ids, vec![0]);
+ assert!(matches!(&atoms[1], Atom::Space { width: 1, .. }));
+ assert!(atoms[1].properties().property_template_ids.is_empty());
+ let space = Line::blank_with_properties(
+ 20,
+ AtomProperties {
+ property_template_ids: vec![0],
+ ..AtomProperties::default()
+ },
+ )
+ .clipped(3);
+ assert_eq!(space.width, 3);
+ assert_eq!(
+ space
+ .atoms
+ .first()
+ .unwrap()
+ .properties()
+ .property_template_ids,
+ vec![0]
+ );
+ assert_eq!(source.clipped(0).width, 0);
+ }
+
#[test]
fn source_template_ids_follow_measured_clusters() {
let root = text_box(
diff --git a/release-dependencies.json b/release-dependencies.json
new file mode 100644
index 0000000..6c27dbd
--- /dev/null
+++ b/release-dependencies.json
@@ -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"
+ }
+ }
+}
diff --git a/scripts/ebox-release-smoke.el b/scripts/ebox-release-smoke.el
new file mode 100644
index 0000000..23a3637
--- /dev/null
+++ b/scripts/ebox-release-smoke.el
@@ -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
diff --git a/scripts/ebox-release.py b/scripts/ebox-release.py
new file mode 100644
index 0000000..d09c3dc
--- /dev/null
+++ b/scripts/ebox-release.py
@@ -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)
diff --git a/scripts/test_ebox_release.py b/scripts/test_ebox_release.py
new file mode 100644
index 0000000..8521ff1
--- /dev/null
+++ b/scripts/test_ebox_release.py
@@ -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()
diff --git a/tests/ebox-ci-contract-tests.el b/tests/ebox-ci-contract-tests.el
index ede5002..686a6a8 100644
--- a/tests/ebox-ci-contract-tests.el
+++ b/tests/ebox-ci-contract-tests.el
@@ -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))))
diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el
index d510d27..963767c 100644
--- a/tests/ebox-commit-tests.el
+++ b/tests/ebox-commit-tests.el
@@ -1744,29 +1744,39 @@
(should (= (ebox-commit-test--participant-v2-count) 2)))
(ert-deftest ebox-transaction-participant-accepts-structured-protocols ()
- "Both consumer-first and final TP manifests satisfy Ebox's v2 contract."
+ "Supported structured protocols also require the current TP semantics."
(dolist (protocol '(tp-transaction-protocol-v1+v2
tp-transaction-protocol-v2))
(should
(ebox-surface--validate-tp-v2-capability
- (list :transaction-protocol protocol
+ (list :version "2.0.0" :transaction-protocol protocol
:structured-participant-api
'tp-transaction-participate-v2)))))
+(ert-deftest ebox-transaction-participant-rejects-old-provider-semantics ()
+ "An API symbol alone does not prove native property ownership support."
+ (dolist (version '(nil "1.0.1" "2.0.0-pre" "invalid" 2))
+ (should-error
+ (ebox-surface--validate-tp-v2-capability
+ (list :version version
+ :transaction-protocol 'tp-transaction-protocol-v2
+ :structured-participant-api 'tp-transaction-participate-v2))
+ :type 'ebox-surface-tp-protocol-error)))
+
(ert-deftest ebox-transaction-participant-rejects-missing-or-malformed-v2 ()
"Missing or incompatible structured capabilities fail closed."
(should-error
(ebox-surface--validate-tp-v2-capability
- '(:transaction-protocol tp-transaction-protocol-v1+v2))
+ '(:version "2.0.0" :transaction-protocol tp-transaction-protocol-v1+v2))
:type 'ebox-surface-tp-protocol-error)
(should-error
(ebox-surface--validate-tp-v2-capability
- '(:transaction-protocol tp-transaction-protocol-v1+v2
+ '(:version "2.0.0" :transaction-protocol tp-transaction-protocol-v1+v2
:structured-participant-api ignore))
:type 'ebox-surface-tp-protocol-error)
(should-error
(ebox-surface--validate-tp-v2-capability
- '(:transaction-protocol incompatible
+ '(:version "2.0.0" :transaction-protocol incompatible
:structured-participant-api tp-transaction-participate-v2))
:type 'ebox-surface-tp-protocol-error))
diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el
index 2ae9623..a977f0a 100644
--- a/tests/ebox-core-render-tests.el
+++ b/tests/ebox-core-render-tests.el
@@ -4526,12 +4526,19 @@
(ebox--render-source-index (ebox-test-source-index box-input))
(content (ebox-pixel-space 120))
(wrap-count 0)
- (original-wrap (symbol-function 'ebox--wrap-text)))
+ (original-wrap (symbol-function 'ebox--wrap-text))
+ (original-require (symbol-function 'require)))
(ebox--record-rendered-uniform-width content (nth 2 case))
(cl-letf (((symbol-function 'ebox--wrap-text)
(lambda (&rest arguments)
(cl-incf wrap-count)
- (apply original-wrap arguments))))
+ (apply original-wrap arguments)))
+ ((symbol-function 'require)
+ (lambda (feature &optional filename noerror)
+ (if (eq feature 'ekp) t
+ (funcall original-require feature filename noerror))))
+ ((symbol-function 'ekp-pixel-justify)
+ (lambda (text _width) text)))
(ebox--format-content-string box content))
(should (> wrap-count 0)))))
@@ -7001,6 +7008,117 @@ line compositor's complete property output, including empty lines and chrome."
'(paint-patch)))
(should-not (plist-get report :full-rerender))))))
+(ert-deftest ebox-overflow-hidden-clips-inline-content-before-chrome ()
+ "Hidden overflow clips content on both axes without consuming its chrome."
+ (ebox-test--reset-runtime-state)
+ (dolist (chrome '(nil t))
+ (let* ((input
+ (ebox-build
+ `(box :width (ch 3) :height (lh 1) :wrap-mode none
+ :overflow hidden :box-sizing content-box
+ ,@(when chrome
+ '(:padding-inline (px 3) :border ((px 1) solid "red")))
+ (text :help-echo "visible" "ABCDEFGHIJ\nKLMNOP"))))
+ (rendered (ebox-render input))
+ (start (string-match "ABC" rendered)))
+ (should start)
+ (should-not (string-match-p "[D-Z]" rendered))
+ (should (= (ebox-string-height rendered) 1))
+ (should (= (ebox-string-pixel-width rendered)
+ (+ (* 3 (ebox--size-character-width)) (if chrome 8 0))))
+ (should (equal (get-text-property start 'help-echo rendered) "visible"))
+ (when chrome
+ (should (get-text-property 0 'ebox-bl rendered))
+ (should (get-text-property (1- (length rendered)) 'ebox-br rendered))))))
+
+(ert-deftest ebox-overflow-hidden-preserves-whole-graphemes ()
+ "A clipped cluster disappears as a whole and never leaves a partial glyph."
+ (ebox-test--reset-runtime-state)
+ (dolist (cluster '("é" "界" "👨👩👧👦" "🇨🇳"))
+ (let* ((width (ebox--string-pixel-width cluster))
+ (input (ebox-build
+ `(box :width (px ,width) :wrap-mode none :overflow hidden
+ ,(concat cluster cluster))))
+ (output (ebox-render input)))
+ (should (equal (substring-no-properties output) cluster))
+ (let ((narrow (ebox-render
+ (ebox-build
+ `(box :width (px ,(max 0 (1- width)))
+ :wrap-mode none :overflow hidden ,cluster)))))
+ (should (string-blank-p narrow))
+ (should (= (ebox-string-pixel-width narrow) (max 0 (1- width))))))))
+
+(ert-deftest ebox-overflow-hidden-clips-pixel-spaces-with-source-properties ()
+ "A partial display space preserves only the visible space's properties."
+ (ebox-test--reset-runtime-state)
+ (let* ((space (propertize (ebox-pixel-space 30) 'help-echo "space"))
+ (input (ebox-build
+ `(box :width (px 10) :wrap-mode none :overflow hidden
+ (text ,(concat space "invisible")))))
+ (output (ebox-render input)))
+ (should (= (ebox-string-pixel-width output) 10))
+ (should (string-blank-p output))
+ (should (equal (get-text-property 0 'help-echo output) "space"))))
+
+(ert-deftest ebox-overflow-hidden-preserves-grouped-display-space-geometry ()
+ "One shared replacement display is measured once and keeps its suffix."
+ (let* ((display '(space :width (15) :height 2 :ascent 80))
+ (source (concat (propertize "abc" 'display display 'help-echo "glue")
+ "suffix"))
+ (output (ebox--clip-line source 20))
+ (partial (ebox--clip-line source 10)))
+ (should (= (ebox-string-pixel-width (substring source 0 3)) 15))
+ (should (= (ebox-string-pixel-width output) 20))
+ (should (equal (substring-no-properties output) "abcsuffi"))
+ (should (equal (get-text-property 0 'display output) display))
+ (should (= (length partial) 1))
+ (should (= (ebox-string-pixel-width partial) 10))
+ (should (equal (get-text-property 0 'display partial)
+ '(space :width (10) :height 2 :ascent 80)))
+ (should (equal (get-text-property 0 'help-echo partial) "glue"))
+ (should (equal (get-text-property 1 'display source) display))))
+
+(ert-deftest ebox-overflow-hidden-inline-updates-match-fresh-output ()
+ "Content growth, shrinking, and overflow reversal use the same clip boundary."
+ (ebox-test--reset-runtime-state)
+ (ebox-test--with-rendered-buffer
+ (ebox-build '(box :id clip :width (ch 3) :overflow hidden :wrap-mode none
+ (text :id value "ABCDEFGHIJ")))
+ (dolist (value '("ZYXWVUT" "A" "123456789"))
+ (ebox-region-update 'value :content value)
+ (let ((fresh (ebox-render
+ (ebox-build
+ `(box :width (ch 3) :overflow hidden :wrap-mode none
+ ,value)))))
+ (should (equal (buffer-substring-no-properties (point-min) (point-max))
+ (substring-no-properties fresh)))
+ (should (= (ebox-string-pixel-width (buffer-string))
+ (ebox-string-pixel-width fresh)))))
+ (ebox-region-update 'clip :overflow 'visible)
+ (should (string-match-p "123456789" (buffer-string)))
+ (ebox-region-update 'clip :overflow 'hidden)
+ (should (equal (buffer-substring-no-properties (point-min) (point-max))
+ "123"))))
+
+(ert-deftest ebox-overflow-hidden-excludes-clipped-child-hit-properties ()
+ "A clipped child remains in the source without leaving an invisible action."
+ (ebox-test--reset-runtime-state)
+ (let ((map (make-sparse-keymap)))
+ (define-key map (kbd "RET") #'ignore)
+ (ebox-test--with-rendered-buffer
+ (ebox-build `(box :id clip :width (ch 3) :wrap-mode none :overflow hidden
+ (text "ABC")
+ (text :id clipped :keymap ,map :help-echo "hidden" "HIDE")))
+ (should (equal (buffer-substring-no-properties (point-min) (point-max)) "ABC"))
+ (should-not (text-property-not-all (point-min) (point-max) 'keymap nil))
+ (should-not (text-property-not-all (point-min) (point-max) 'help-echo nil))
+ (ebox-region-update 'clipped :content "NEW")
+ (should (equal (buffer-substring-no-properties (point-min) (point-max)) "ABC"))
+ (ebox-region-update 'clip :width '(ch 6))
+ (should (equal (buffer-substring-no-properties (point-min) (point-max)) "ABCNEW"))
+ (should (eq (lookup-key (get-text-property (+ (point-min) 3) 'keymap)
+ (kbd "RET")) #'ignore)))))
+
(ert-deftest ebox-overflow-hidden-clips-extra-content-lines ()
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
@@ -13345,6 +13463,53 @@ explicit visible-window handoff may admit its full-content publication."
'char))
(list china usa)))))
+(ert-deftest ebox-kp-wrap-mode-requires-functional-ekp ()
+ "Explicit KP requests never silently change to word wrapping."
+ (let ((original-require (symbol-function 'require))
+ (original-fboundp (symbol-function 'fboundp)))
+ (dolist (available '(nil t))
+ (cl-letf (((symbol-function 'require)
+ (lambda (feature &optional filename noerror)
+ (if (eq feature 'ekp) available
+ (funcall original-require feature filename noerror))))
+ ((symbol-function 'fboundp)
+ (lambda (function)
+ (and (not (eq function 'ekp-pixel-justify))
+ (funcall original-fboundp function)))))
+ (let ((failure (should-error (ebox--wrap-text "abc" 100 'kp))))
+ (should (string-match-p "EKP" (error-message-string failure))))))))
+
+(ert-deftest ebox-ordinary-wrap-does-not-load-ekp ()
+ "The optional dependency is required only when KP is requested."
+ (let ((original-require (symbol-function 'require)))
+ (cl-letf (((symbol-function 'require)
+ (lambda (feature &optional filename noerror)
+ (when (eq feature 'ekp) (ert-fail "Unexpected EKP load"))
+ (funcall original-require feature filename noerror))))
+ (dolist (mode '(word char none))
+ (should (stringp (ebox--wrap-text "alpha beta" 30 mode)))))))
+
+(ert-deftest ebox-kp-missing-dependency-update-preserves-mounted-output ()
+ "Dependency failure cannot publish a different wrapping algorithm."
+ (ebox-test--reset-runtime-state)
+ (ebox-test--with-rendered-buffer
+ (ebox-build '(box :id paragraph :width (px 100) "alpha beta gamma"))
+ (let ((before (buffer-string))
+ (revision (plist-get (ebox-surface-buffer-snapshot (current-buffer))
+ :revision))
+ (original-require (symbol-function 'require)))
+ (cl-letf (((symbol-function 'require)
+ (lambda (feature &optional filename noerror)
+ (unless (eq feature 'ekp)
+ (funcall original-require feature filename noerror)))))
+ (should-error (ebox-region-update 'paragraph :wrap-mode 'kp)))
+ (should (equal-including-properties (buffer-string) before))
+ (should (equal (plist-get (ebox-surface-buffer-snapshot (current-buffer))
+ :revision)
+ revision))
+ (ebox-region-update 'paragraph :content "still usable")
+ (should (string-match-p "still usable" (buffer-string))))))
+
(ert-deftest ebox-kp-wrap-mode-invokes-ekp-justification ()
(ebox-test--reset-runtime-state)
(let (require-called justify-args)
@@ -13363,6 +13528,30 @@ explicit visible-window handoff may admit its full-content publication."
(should require-called)
(should (equal justify-args (list "abc" 100)))))
+(ert-deftest ebox-kp-real-pixel-budget-and-glue-fit-hidden-content ()
+ "Installed EKP receives content pixels and its real glue survives clipping."
+ (skip-unless (locate-library "ekp"))
+ (require 'ekp)
+ (let* ((width (* 12 (ebox--space-pixel-width)))
+ (original (symbol-function 'ekp-pixel-justify))
+ calls output)
+ (cl-letf (((symbol-function 'ekp-pixel-justify)
+ (lambda (text pixels)
+ (push pixels calls)
+ (funcall original text pixels))))
+ (setq output
+ (ebox-render
+ (ebox-build
+ `(box :width (px ,width) :box-sizing content-box
+ :wrap-mode kp :overflow hidden :padding-inline (px 2)
+ :border "red"
+ "alpha beta gamma delta epsilon zeta eta theta")))))
+ (should calls)
+ (should (cl-every (lambda (pixels) (= pixels width)) calls))
+ (should (> (ebox-string-height output) 1))
+ (dolist (line (ebox-string-lines output))
+ (should (= (ebox-string-pixel-width line) (+ width 6))))))
+
(ert-deftest ebox-kp-wrap-mode-respects-text-align ()
"KP-wrapped lines should honor the box's horizontal text alignment."
(ebox-test--reset-runtime-state)
diff --git a/tests/ebox-css-flex-grid-tests.el b/tests/ebox-css-flex-grid-tests.el
index 577955a..5d25cbd 100644
--- a/tests/ebox-css-flex-grid-tests.el
+++ b/tests/ebox-css-flex-grid-tests.el
@@ -475,8 +475,8 @@
: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))
- ;; The buffer backend's existing hidden overflow clips vertically only.
- (should (= (ebox--string-max-pixel-width horizontal) 100)))))
+ (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."
diff --git a/tests/ebox-css-layout-tests.el b/tests/ebox-css-layout-tests.el
index b6b19a6..425ff5a 100644
--- a/tests/ebox-css-layout-tests.el
+++ b/tests/ebox-css-layout-tests.el
@@ -90,9 +90,15 @@
21)))
(ert-deftest ebox-css-layout-pixel-quantization-preserves-private-input ()
- "Fractional runs split per glyph; zero widths keep ownership and newlines."
+ "Distinct fractional display glyphs retain ownership and line boundaries."
(let* ((display '(space :width (0.5) :height 1))
- (source (propertize " \n " 'display display 'ebox-content 17))
+ ;; 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))
diff --git a/tests/ebox-display-tests.el b/tests/ebox-display-tests.el
new file mode 100644
index 0000000..9324158
--- /dev/null
+++ b/tests/ebox-display-tests.el
@@ -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
diff --git a/tests/ebox-docs-contract-tests.el b/tests/ebox-docs-contract-tests.el
index f44769d..5b5c1bf 100644
--- a/tests/ebox-docs-contract-tests.el
+++ b/tests/ebox-docs-contract-tests.el
@@ -23,7 +23,7 @@
"Public documents that must describe only the canonical author grammar.")
(defconst ebox-docs-test--active-files
- '("Makefile" ".github/workflows/ci.yml"
+ '("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"
@@ -33,7 +33,7 @@
"ebox-surface.el" "ebox-dsl.el" "ebox-spi.el"
"ebox-selector.el" "ebox-native-reflow.el"
"tests/ebox-size-tests.el" "tests/ebox-size-style-tests.el"
- "tests/ebox-interaction-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"
@@ -54,12 +54,16 @@
"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-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" "size-tests" "size-migration-tests"
- "core-tests" "runtime-index-tests" "interaction-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"
diff --git a/tests/ebox-font-tests.el b/tests/ebox-font-tests.el
index 4082658..8de58b5 100644
--- a/tests/ebox-font-tests.el
+++ b/tests/ebox-font-tests.el
@@ -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
diff --git a/tests/ebox-interaction-tests.el b/tests/ebox-interaction-tests.el
index afb1905..7bec7df 100644
--- a/tests/ebox-interaction-tests.el
+++ b/tests/ebox-interaction-tests.el
@@ -958,6 +958,238 @@ The helper keeps face objects out of the caller's ERT assertion temporaries."
(should (eq (key-binding (kbd "RET")) #'backward-char))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
+(ert-deftest ebox-interaction-navigation-nested-owners-and-boundaries ()
+ "Inherited fragments form one stop while explicit nested maps stay distinct."
+ (with-temp-buffer
+ (ebox-render-to-buffer
+ (current-buffer)
+ (ebox-build '(column
+ (text "intro")
+ (box :keymap (keymap (13 . ignore))
+ (column (text "parent-one")
+ (text :keymap nil "blocked")
+ (text :keymap (keymap (13 . forward-char)) "child")
+ (text "parent-two")))
+ (text :keymap (keymap (13 . ignore)) "last"))))
+ (let* ((text (buffer-string))
+ (parent (1+ (ebox-interaction-test--position text "parent-one")))
+ (child (1+ (ebox-interaction-test--position text "child")))
+ (tail (1+ (ebox-interaction-test--position text "parent-two")))
+ (last (1+ (ebox-interaction-test--position text "last"))))
+ (goto-char (point-min))
+ (should (= (ebox-next-interaction) parent))
+ (should (= (ebox-next-interaction) child))
+ (should (= (ebox-next-interaction) last))
+ (should-error (ebox-next-interaction) :type 'user-error)
+ (should (= (point) last))
+ (should (= (ebox-previous-interaction 2) parent))
+ (should-error (ebox-previous-interaction) :type 'user-error)
+ (should (= (point) parent))
+ (should (= (ebox-next-interaction 0) parent))
+ (should (= (ebox-previous-interaction -1) child))
+ (should (= (ebox-next-interaction -1) parent))
+ (goto-char tail)
+ (should (= (ebox-previous-interaction) child))
+ (goto-char tail)
+ (should (= (ebox-next-interaction) last))
+ (goto-char (point-min))
+ (should-error (ebox-next-interaction 4) :type 'user-error)
+ (should (= (point) (point-min)))
+ (should-error (ebox-next-interaction 1.5) :type 'wrong-type-argument))))
+
+(ert-deftest ebox-interaction-navigation-uses-live-mount-and-declared-maps ()
+ "Buffer keymaps, copied rendering and empty or blocked maps are not stops."
+ (with-temp-buffer
+ (insert (ebox-render (ebox-build '(text :keymap (keymap (13 . ignore)) "copy"))))
+ (should-error (ebox-next-interaction) :type 'user-error))
+ (with-temp-buffer
+ (use-local-map (let ((map (make-sparse-keymap)))
+ (define-key map (kbd "RET") #'ignore) map))
+ (ebox-render-to-buffer
+ (current-buffer)
+ (ebox-build '(column (text "intro")
+ (text :keymap (keymap) "empty")
+ (text :keymap (keymap (13 . undefined)) "disabled")
+ (box :visibility hidden :keymap (keymap (13 . ignore)) "hidden")
+ (box :height (lh 0) :overflow hidden
+ :keymap (keymap (13 . ignore)) "clipped")
+ (text :keymap (keymap (13 . ignore)) "active"))))
+ (let* ((text (buffer-string))
+ (active (1+ (ebox-interaction-test--position text "active"))))
+ (goto-char (point-min))
+ (should (= (ebox-next-interaction) active))
+ (should-error (ebox-next-interaction) :type 'user-error)
+ (should-error (ebox-previous-interaction) :type 'user-error))))
+
+(ert-deftest ebox-interaction-navigation-narrowing-and-invisible-text ()
+ "Only accessible visible characters contribute a stop, without widening."
+ (with-temp-buffer
+ (ebox-render-to-buffer
+ (current-buffer)
+ (ebox-build '(column (text :keymap (keymap (13 . ignore)) "first")
+ (text :keymap (keymap (13 . forward-char)) "second")
+ (text :keymap (keymap (13 . backward-char)) "third"))))
+ (let* ((text (buffer-string))
+ (first (1+ (ebox-interaction-test--position text "first")))
+ (second (1+ (ebox-interaction-test--position text "second")))
+ (third (1+ (ebox-interaction-test--position text "third"))))
+ (save-restriction
+ (narrow-to-region (+ first 2) third)
+ (goto-char (point-max))
+ (should (= (ebox-previous-interaction) second))
+ (should (= (ebox-previous-interaction) (point-min)))
+ (should-error (ebox-previous-interaction) :type 'user-error)
+ (should (= (point-min) (+ first 2))))
+ (let ((overlay (make-overlay second (+ second 6))))
+ (unwind-protect
+ (progn
+ (overlay-put overlay 'invisible t)
+ (goto-char first)
+ (should (= (ebox-next-interaction) third))
+ (move-overlay overlay second (+ second 2))
+ (goto-char first)
+ (should (= (ebox-next-interaction) (+ second 2))))
+ (delete-overlay overlay))))))
+
+(ert-deftest ebox-interaction-navigation-padding-and-native-overrides ()
+ "Nested padding resolves to its own box; foreign maps do not create stops."
+ (with-temp-buffer
+ (ebox-render-to-buffer
+ (current-buffer)
+ (ebox-build '(column (text "intro")
+ (row :keymap (keymap (13 . ignore))
+ (box :padding-left (ch 2)
+ :keymap (keymap (13 . forward-char)) "child")
+ (text "parent"))
+ (text "ordinary"))))
+ (let* ((text (buffer-string))
+ (child (1+ (ebox-interaction-test--position text "child")))
+ (parent (1+ (ebox-interaction-test--position text "parent")))
+ (ordinary (1+ (ebox-interaction-test--position text "ordinary"))))
+ (goto-char (point-min))
+ (let ((padding (ebox-next-interaction)))
+ (should (< padding child))
+ (should (eq (key-binding (kbd "RET")) #'forward-char))
+ (should (= (ebox-next-interaction) parent)))
+ (let ((inhibit-read-only t))
+ (put-text-property ordinary (+ ordinary 8) 'keymap '(keymap (13 . ignore))))
+ (should-error (ebox-next-interaction) :type 'user-error)
+ (let ((overlay (make-overlay ordinary (+ ordinary 8))))
+ (unwind-protect
+ (progn
+ (overlay-put overlay 'keymap '(keymap (13 . forward-char)))
+ (should-error (ebox-next-interaction) :type 'user-error))
+ (delete-overlay overlay))))))
+
+(ert-deftest ebox-interaction-navigation-follows-updates-and-scroll ()
+ "Updated, removed and newly visible actions are read from the publication."
+ (with-temp-buffer
+ (ebox-render-to-buffer
+ (current-buffer)
+ (ebox-build '(column
+ (text "intro")
+ (text :id "first" :keymap (keymap (13 . ignore)) "first")
+ (box :id "scroll" :height (lh 1) :width (ch 20) :overflow scroll
+ (column
+ (text :keymap (keymap (13 . forward-char)) "second")
+ (text :keymap (keymap (13 . backward-char)) "third"))))))
+ (goto-char (point-min))
+ (ebox-next-interaction)
+ (should (looking-at "first"))
+ (ebox-region-update (ebox-region-resolve (current-buffer) "first") :keymap nil)
+ (goto-char (point-min))
+ (ebox-next-interaction)
+ (should (looking-at "second"))
+ (ebox-region-update (ebox-region-resolve (current-buffer) "scroll") :scroll-offset 1)
+ (goto-char (point-min))
+ (ebox-next-interaction)
+ (should (looking-at "third"))
+ (ebox-region-update (ebox-region-resolve (current-buffer) "first")
+ :keymap '(keymap (13 . ignore)) :content "restored")
+ (goto-char (point-min))
+ (ebox-next-interaction)
+ (should (looking-at "restored"))
+ (should-error
+ (ebox-commit (current-buffer) (ebox-build '(text "rejected"))
+ (lambda (_report) (error "Reject navigation change"))))
+ (goto-char (point-min))
+ (ebox-next-interaction)
+ (should (looking-at "restored"))
+ (ebox-commit (current-buffer) (ebox-build '(text "no interactions")))
+ (should-error (ebox-next-interaction) :type 'user-error)))
+
+(ert-deftest ebox-interaction-navigation-keymap-prefixes-parents-and-menus ()
+ "Inspect native binding graphs without executing dynamic menu predicates."
+ (let* ((map (make-sparse-keymap))
+ (prefix (make-sparse-keymap))
+ (parent (make-sparse-keymap))
+ (calls 0))
+ (define-key prefix "r" map)
+ (define-key map "p" prefix)
+ (should-not (ebox-interaction--navigable-keymap-p map))
+ (define-key parent (kbd "RET") #'ignore)
+ (set-keymap-parent map parent)
+ (should (ebox-interaction--navigable-keymap-p map))
+ (define-key map (kbd "RET") #'undefined)
+ (should-not (ebox-interaction--navigable-keymap-p map))
+ (define-key map (kbd "RET") nil)
+ (should-not (lookup-key map (kbd "RET")))
+ (should-not (ebox-interaction--navigable-keymap-p map))
+ (keymap-unset map "RET" t)
+ (should (ebox-interaction--navigable-keymap-p map))
+ (set-keymap-parent map nil)
+ (define-key map [menu-bar item]
+ (list 'menu-item "Action" #'ignore
+ :enable '(progn (cl-incf calls) nil)
+ :filter (lambda (definition) (cl-incf calls) definition)))
+ (should (ebox-interaction--navigable-keymap-p map))
+ (should (= calls 0)))
+ (let ((full (make-keymap)) (parent (make-sparse-keymap)))
+ (define-key parent (kbd "RET") #'ignore)
+ (set-keymap-parent full parent)
+ (set-char-table-range (nth 1 full) t #'undefined)
+ (should-not (ebox-interaction--navigable-keymap-p full))
+ (define-key full (kbd "RET") nil)
+ (should-not (lookup-key full (kbd "RET")))
+ (should-not (ebox-interaction--navigable-keymap-p full))
+ (define-key full (kbd "RET") #'ignore)
+ (should (ebox-interaction--navigable-keymap-p full))))
+
+(ert-deftest ebox-interaction-navigation-inherits-effective-prefix-bindings ()
+ "Native prefix inheritance remains navigable without losing child blocking."
+ (let ((map (make-sparse-keymap))
+ (parent (make-sparse-keymap))
+ (prefix (make-sparse-keymap))
+ (calls 0))
+ (define-key parent "pr" #'ignore)
+ (define-key map "p" prefix)
+ (set-keymap-parent map parent)
+ (should (eq (lookup-key map "pr") #'ignore))
+ (should (ebox-interaction--navigable-keymap-p map))
+ (with-temp-buffer
+ (ebox-render-to-buffer
+ (current-buffer)
+ (ebox-build (list 'column '(text "intro") (list 'text :keymap map "action"))))
+ (goto-char (point-min))
+ (ebox-next-interaction)
+ (should (looking-at "action"))
+ (should (eq (key-binding "pr") #'ignore)))
+ (define-key prefix "r" #'undefined)
+ (should (eq (lookup-key map "pr") #'undefined))
+ (should-not (ebox-interaction--navigable-keymap-p map))
+ (keymap-unset prefix "r" t)
+ (define-key parent "pr"
+ (list 'menu-item "Inherited" #'ignore
+ :filter (lambda (definition) (cl-incf calls) definition)))
+ (should (ebox-interaction--navigable-keymap-p map))
+ (should (= calls 0))
+ (should (eq (car (lookup-key parent "p")) 'keymap))
+ ;; Inspection must retain the original menu wrapper and callback.
+ (should (eq (car (cdr (assq ?r (cdr (lookup-key parent "p"))))) 'menu-item))
+ (define-key map "p" #'undefined)
+ (should-not (ebox-interaction--navigable-keymap-p map))
+ (should (= calls 0))))
+
(provide 'ebox-interaction-tests)
;;; ebox-interaction-tests.el ends here
diff --git a/tests/ebox-package-tests.el b/tests/ebox-package-tests.el
index 49d063f..25f0836 100644
--- a/tests/ebox-package-tests.el
+++ b/tests/ebox-package-tests.el
@@ -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"
@@ -46,11 +47,11 @@
(buffer-string)))
(source (ebox-test--production-lisp-text)))
(should (string-match-p "Version: 3.0.0" facade))
- (should (string-match-p (regexp-quote "(tp \"1.0.1\")") 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 "1.0.1"))
+ (should (equal ebox-framework-spi-required-tp-version "2.0.0"))
(should (memq tp-transaction-protocol
ebox-framework-spi-supported-tp-protocols))))
@@ -90,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
@@ -127,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
@@ -246,6 +245,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")))
diff --git a/tests/ebox-size-native-tests.el b/tests/ebox-size-native-tests.el
index 80e2133..b74c2ea 100644
--- a/tests/ebox-size-native-tests.el
+++ b/tests/ebox-size-native-tests.el
@@ -25,6 +25,214 @@
(should-not ebox-native-reflow--loaded-module-path))
(delete-file module))))
+(ert-deftest ebox-size-native-rejects-pre-inline-clipping-module-abi ()
+ "An older module cannot silently keep the previous visible inline overflow."
+ (let ((module (make-temp-file "ebox-old-native-clipping-"))
+ (ebox-native-reflow--load-attempted-p nil)
+ (ebox-native-reflow--available-p nil)
+ (ebox-native-reflow--load-error nil)
+ (ebox-native-reflow--loaded-module-path nil)
+ (ebox-native-reflow--loaded-module-hash nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'ebox-native-reflow--candidate-paths)
+ (lambda () (list module)))
+ ((symbol-function 'module-load) #'ignore)
+ ((symbol-function 'ebox-native--module-version)
+ (lambda () "13:7:12")))
+ (should-not (ebox-native-reflow-load))
+ (should (string-match-p "ABI mismatch" ebox-native-reflow--load-error)))
+ (delete-file module))))
+
+(ert-deftest ebox-size-native-inline-clipping-matches-elisp ()
+ "Native clipping retains content properties and Box chrome within its width."
+ (skip-unless (ebox-native-reflow-layout-ready-p))
+ (dolist (text '("ABCDEFGHIJ" "界界界" "ééé" "👨👩👧👦👨👩👧👦"))
+ (dolist (width '(0 3 8 24))
+ (let* ((input (ebox-build
+ `(box :width (px ,width) :box-sizing content-box
+ :padding-inline (px 2) :border "red"
+ :overflow hidden :wrap-mode none
+ (text :help-echo "clipped text" ,text))))
+ (node (ebox-canonical-input--single-root input "Native clipping"))
+ (normal (let ((ebox--surface-materialization-active t))
+ (ebox--render-with-cache node)))
+ (frame (ebox-native-reflow-execute-sync
+ node '(:key 1 :viewport-width 80 :viewport-height 20
+ :runtime-revision 0 :context-hash 0 :complete t)))
+ (actual (plist-get frame :rendered)))
+ (ert-info ((format "width=%s text=%s" width text))
+ (should (plist-get frame :native-frame))
+ (should (= (ebox-string-pixel-width actual) (+ width 6)))
+ (should (equal-including-properties actual normal)))))))
+
+(ert-deftest ebox-size-native-inline-clipping-retains-update-and-rollback-parity ()
+ "Retained native publication uses the same clip boundary as a fresh render."
+ (skip-unless (ebox-native-reflow-layout-ready-p))
+ (let ((ebox-viewport-width 80)
+ (ebox-viewport-height 20)
+ (ebox-runtime-idle-prewarm nil)
+ (ebox-runtime-idle-reflow-cache-prewarm nil))
+ (cl-labels ((source (text width overflow)
+ (ebox-build
+ `(column :key root :width (px 80)
+ (box :key clip :width (px ,width)
+ :overflow ,overflow :wrap-mode none
+ :border "red" :padding-inline (px 2)
+ (text :key label :help-echo "value" ,text))
+ (box :key peer "unchanged"))))
+ (assert-fresh ()
+ (let ((state (ebox--buffer-render-state (current-buffer))))
+ (should (equal-including-properties
+ (buffer-string)
+ (ebox--render-node
+ (plist-get state :root-node)
+ (plist-get state :source-index)))))))
+ (with-temp-buffer
+ (ebox-render-to-buffer (current-buffer) (source "ABCDEFGHIJ" 9 'hidden))
+ (dolist (case '(("ZYXWVUT" 9 hidden) ("A" 9 hidden)
+ ("123456789" 9 visible) ("123456789" 9 hidden)
+ ("界界界" 11 hidden)))
+ (let ((report (ebox-commit (current-buffer) (apply #'source case))))
+ (should (eq (plist-get report :projection-kind) 'native-frame))
+ (assert-fresh)))
+ (let ((before (buffer-string))
+ (revision (ebox-surface-buffer-revision (current-buffer))))
+ (should-error
+ (ebox-commit (current-buffer) (source "NEWVALUE" 10 'hidden)
+ (lambda (_report) (error "Reject clipping update"))))
+ (should (equal-including-properties before (buffer-string)))
+ (should (= (ebox-surface-buffer-revision (current-buffer)) revision)))
+ (ebox-commit (current-buffer) (source "NEWVALUE" 10 'hidden))
+ (assert-fresh)
+ (ebox-unmount-buffer (current-buffer))))))
+
+(ert-deftest ebox-size-native-declines-cross-property-graphemes ()
+ "A single native atom cannot encode different properties inside a cluster."
+ (dolist (parts '(("e" "́") ("👨" "👩👧👦") ("🇨" "🇳")))
+ (let* ((base (propertize (car parts) 'help-echo "base"))
+ (mark (propertize (cadr parts) 'help-echo "mark"))
+ (source (concat base mark)))
+ ;; Prime the property-run cache before checking the cross-run boundary.
+ (should (ebox-native-reflow--compile-text base))
+ (dotimes (_ 2)
+ (let ((failure (should-error (ebox-native-reflow--compile-text source))))
+ (should (string-match-p "grapheme" (error-message-string failure)))))
+ (should (ebox-native-reflow--compile-text
+ (concat (propertize (concat (car parts) (cadr parts))
+ 'help-echo "complete")
+ (propertize "x" 'help-echo "next")))))))
+
+(ert-deftest ebox-size-native-cross-property-grapheme-fallback-and-rollback ()
+ "Initial render and native-to-Elisp updates preserve complete styled clusters."
+ (skip-unless (ebox-native-reflow-layout-ready-p))
+ (let ((ebox-viewport-width 80)
+ (ebox-viewport-height 20)
+ (ebox-runtime-idle-prewarm nil)
+ (ebox-runtime-idle-reflow-cache-prewarm nil))
+ (dolist (parts '(("e" "́") ("👨" "👩👧👦") ("🇨" "🇳")))
+ (let* ((plain (concat (car parts) (cadr parts)))
+ (styled (concat (propertize (car parts) 'help-echo "base")
+ (propertize (cadr parts) 'help-echo "mark")))
+ (width (ebox--string-pixel-width plain)))
+ (cl-labels ((source (text)
+ (ebox-build `(box :key root :width (px ,width)
+ :overflow hidden :wrap-mode none
+ (text :key label ,(concat text plain)))))
+ (assert-fresh ()
+ (let ((state (ebox--buffer-render-state (current-buffer))))
+ (should (equal (buffer-substring-no-properties
+ (point-min) (point-max)) plain))
+ (should (equal-including-properties
+ (buffer-string)
+ (ebox--render-node
+ (plist-get state :root-node)
+ (plist-get state :source-index)))))))
+ (with-temp-buffer
+ (ebox-render-to-buffer (current-buffer) (source styled))
+ (assert-fresh)
+ (should (equal (get-text-property (point-min) 'help-echo) "base"))
+ (should (equal (get-text-property
+ (+ (point-min) (length (car parts))) 'help-echo)
+ "mark"))
+ (ebox-unmount-buffer (current-buffer)))
+ (with-temp-buffer
+ (ebox-render-to-buffer (current-buffer) (source plain))
+ (should (eq (plist-get (ebox--buffer-render-state (current-buffer))
+ :projection-kind) 'native-frame))
+ (let ((before (buffer-string))
+ (revision (ebox-surface-buffer-revision (current-buffer))))
+ (should-error
+ (ebox-commit (current-buffer) (source styled)
+ (lambda (_report) (error "Reject grapheme fallback"))))
+ (should (equal-including-properties before (buffer-string)))
+ (should (= (ebox-surface-buffer-revision (current-buffer)) revision)))
+ (ebox-commit (current-buffer) (source styled))
+ (assert-fresh)
+ (should (equal (get-text-property (point-min) 'help-echo) "base"))
+ (ebox-commit (current-buffer) (source plain))
+ (assert-fresh)
+ (ebox-unmount-buffer (current-buffer))))))))
+
+(ert-deftest ebox-size-native-grouped-display-capability-preserves-pixel-runs ()
+ "Opaque source displays fall back; formatted glue compiles as one atom."
+ (let* ((display '(space :width (15)))
+ (plain (propertize " " 'display display))
+ (formatted (ebox-native-reflow--compile-text plain t))
+ (clusters (plist-get (aref (plist-get formatted :lines) 0) :clusters)))
+ (should (= (length clusters) 1))
+ (should (= (plist-get (aref clusters 0) :width) 15))
+ (should (eq (plist-get (aref clusters 0) :pixel-space) t))
+ (dolist (text (list plain
+ (concat (propertize "a" 'display display 'help-echo "first")
+ (propertize "bc" 'display display 'help-echo "rest"))))
+ (let ((failure (should-error (ebox-native-reflow--compile-text text))))
+ (should (string-match-p "unsafe property display"
+ (error-message-string failure)))))))
+
+(ert-deftest ebox-size-native-grouped-display-fallback-keeps-suffix-and-rollback ()
+ "Public native-capable mounts preserve one grouped display and its suffix."
+ (skip-unless (ebox-native-reflow-layout-ready-p))
+ (let* ((ebox-viewport-width 80)
+ (ebox-viewport-height 20)
+ (ebox-runtime-idle-prewarm nil)
+ (ebox-runtime-idle-reflow-cache-prewarm nil)
+ (display '(space :width (15)))
+ (grouped (concat (propertize "a" 'display display 'help-echo "first")
+ (propertize "bc" 'display display 'help-echo "rest")
+ "suffix")))
+ (cl-labels ((source (text)
+ (ebox-build `(box :key root :width (px 20) :overflow hidden
+ :wrap-mode none (text :key label ,text))))
+ (assert-grouped ()
+ (should (equal (buffer-substring-no-properties
+ (point-min) (point-max)) "abcsuffi"))
+ (should (= (ebox-string-pixel-width (buffer-string)) 20))
+ (should (= (string-pixel-width (buffer-string)) 20))
+ (should (eq (get-text-property (point-min) 'display)
+ (get-text-property (1+ (point-min)) 'display)))
+ (should (equal (get-text-property (point-min) 'help-echo) "first"))
+ (should (equal (get-text-property (1+ (point-min)) 'help-echo) "rest"))))
+ (with-temp-buffer
+ (ebox-render-to-buffer (current-buffer) (source grouped))
+ (assert-grouped)
+ (should-not (eq (plist-get (ebox--buffer-render-state (current-buffer))
+ :projection-kind) 'native-frame))
+ (ebox-unmount-buffer (current-buffer)))
+ (with-temp-buffer
+ (ebox-render-to-buffer (current-buffer) (source "plain"))
+ (should (eq (plist-get (ebox--buffer-render-state (current-buffer))
+ :projection-kind) 'native-frame))
+ (let ((before (buffer-string))
+ (revision (ebox-surface-buffer-revision (current-buffer))))
+ (should-error
+ (ebox-commit (current-buffer) (source grouped)
+ (lambda (_report) (error "Reject display fallback"))))
+ (should (equal-including-properties before (buffer-string)))
+ (should (= (ebox-surface-buffer-revision (current-buffer)) revision)))
+ (ebox-commit (current-buffer) (source grouped))
+ (assert-grouped)
+ (ebox-unmount-buffer (current-buffer))))))
+
(ert-deftest ebox-size-native-fixed-units-use-owning-font ()
"Fixed units and math compile using the source node's measured font."
(let ((node (list :font-owner t)))
diff --git a/tests/ebox-spi-tests.el b/tests/ebox-spi-tests.el
index b48e5f1..0943c63 100644
--- a/tests/ebox-spi-tests.el
+++ b/tests/ebox-spi-tests.el
@@ -38,7 +38,7 @@
(should (ebox-framework-spi-validate-provider first))
(should-not (eq first second))
(should (= (ebox-framework-spi-provider-spi-version first) 2))
- (should (equal ebox-framework-spi-required-tp-version "1.0.1"))
+ (should (equal ebox-framework-spi-required-tp-version "2.0.0"))
(should (equal ebox-framework-spi-supported-tp-protocols
'(tp-transaction-protocol-v1+v2
tp-transaction-protocol-v2)))
@@ -120,7 +120,7 @@
"etaf-renderer-port" "etaf-spi-bootstrap"))
(should-not (string-match-p (regexp-quote pattern) source)))
(should (string-match-p "Version: 3.0.0" facade))
- (should (string-match-p "(tp \"1.0.1\")" facade))
+ (should (string-match-p "(tp \"2.0.0\")" facade))
(should (functionp #'ebox-render-to-buffer))
(should (functionp #'ebox-commit))
(should (functionp #'ebox-framework-spi-capabilities))))