From a8417d56c68bd69535e56e379786072e322cfc46 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Thu, 10 Sep 2026 00:22:34 +0800 Subject: [PATCH] Prepare standalone distribution and harden clipping and interaction --- .github/workflows/ci.yml | 47 +- AGENTS.md | 2 + CHANGELOG.md | 68 +- CHANGELOG.zh-CN.md | 49 +- DESIGN.md | 35 +- DESIGN.zh.md | 27 +- LICENSE | 674 ++++++++++++++++++ Makefile | 29 + README.md | 114 ++- README.zh-CN.md | 90 ++- ...box-current-implementation-reference.en.md | 82 ++- ...box-current-implementation-reference.zh.md | 63 +- docs/user/ebox-api-reference.en.md | 42 +- docs/user/ebox-api-reference.zh.md | 33 +- docs/user/ebox-user-guide.en.md | 94 ++- docs/user/ebox-user-guide.zh.md | 69 +- ebox-grid.el | 2 + ebox-incremental.el | 1 + ebox-interaction.el | 134 ++++ ebox-layout.el | 122 +++- ebox-measure.el | 37 +- ebox-native-reflow.el | 18 +- ebox-selector.el | 1 + ebox-spi.el | 2 +- ebox-surface.el | 16 +- ebox.el | 26 +- native/Cargo.toml | 1 + native/c/ebox_module.c | 2 +- native/core/Cargo.toml | 1 + native/src/layout.rs | 148 ++++ release-dependencies.json | 19 + scripts/ebox-release-smoke.el | 145 ++++ scripts/ebox-release.py | 239 +++++++ scripts/test_ebox_release.py | 100 +++ tests/ebox-ci-contract-tests.el | 11 +- tests/ebox-commit-tests.el | 20 +- tests/ebox-core-render-tests.el | 193 ++++- tests/ebox-css-flex-grid-tests.el | 4 +- tests/ebox-css-layout-tests.el | 10 +- tests/ebox-display-tests.el | 73 ++ tests/ebox-docs-contract-tests.el | 10 +- tests/ebox-font-tests.el | 39 + tests/ebox-interaction-tests.el | 232 ++++++ tests/ebox-package-tests.el | 17 +- tests/ebox-size-native-tests.el | 208 ++++++ tests/ebox-spi-tests.el | 4 +- 46 files changed, 3206 insertions(+), 147 deletions(-) create mode 100644 LICENSE create mode 100644 release-dependencies.json create mode 100644 scripts/ebox-release-smoke.el create mode 100644 scripts/ebox-release.py create mode 100644 scripts/test_ebox_release.py create mode 100644 tests/ebox-display-tests.el 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))))